1 // Copyright (c) 1999-2004 Brian Wellington (bwelling@xbill.org)
2
3 package org.xbill.DNS;
4
5 import java.io.*;
6 import java.util.*;
7
8 /**
9 * A cache of DNS records. The cache obeys TTLs, so items are purged after
10 * their validity period is complete. Negative answers are cached, to
11 * avoid repeated failed DNS queries. The credibility of each RRset is
12 * maintained, so that more credible records replace less credible records,
13 * and lookups can specify the minimum credibility of data they are requesting.
14 * @see RRset
15 * @see Credibility
16 *
17 * @author Brian Wellington
18 */
19
20 public class Cache {
21
22 private interface Element {
expired()23 public boolean expired();
compareCredibility(int cred)24 public int compareCredibility(int cred);
getType()25 public int getType();
26 }
27
28 private static int
limitExpire(long ttl, long maxttl)29 limitExpire(long ttl, long maxttl) {
30 if (maxttl >= 0 && maxttl < ttl)
31 ttl = maxttl;
32 long expire = (System.currentTimeMillis() / 1000) + ttl;
33 if (expire < 0 || expire > Integer.MAX_VALUE)
34 return Integer.MAX_VALUE;
35 return (int)expire;
36 }
37
38 private static class CacheRRset extends RRset implements Element {
39 private static final long serialVersionUID = 5971755205903597024L;
40
41 int credibility;
42 int expire;
43
44 public
CacheRRset(Record rec, int cred, long maxttl)45 CacheRRset(Record rec, int cred, long maxttl) {
46 super();
47 this.credibility = cred;
48 this.expire = limitExpire(rec.getTTL(), maxttl);
49 addRR(rec);
50 }
51
52 public
CacheRRset(RRset rrset, int cred, long maxttl)53 CacheRRset(RRset rrset, int cred, long maxttl) {
54 super(rrset);
55 this.credibility = cred;
56 this.expire = limitExpire(rrset.getTTL(), maxttl);
57 }
58
59 public final boolean
expired()60 expired() {
61 int now = (int)(System.currentTimeMillis() / 1000);
62 return (now >= expire);
63 }
64
65 public final int
compareCredibility(int cred)66 compareCredibility(int cred) {
67 return credibility - cred;
68 }
69
70 public String
toString()71 toString() {
72 StringBuffer sb = new StringBuffer();
73 sb.append(super.toString());
74 sb.append(" cl = ");
75 sb.append(credibility);
76 return sb.toString();
77 }
78 }
79
80 private static class NegativeElement implements Element {
81 int type;
82 Name name;
83 int credibility;
84 int expire;
85
86 public
NegativeElement(Name name, int type, SOARecord soa, int cred, long maxttl)87 NegativeElement(Name name, int type, SOARecord soa, int cred,
88 long maxttl)
89 {
90 this.name = name;
91 this.type = type;
92 long cttl = 0;
93 if (soa != null)
94 cttl = soa.getMinimum();
95 this.credibility = cred;
96 this.expire = limitExpire(cttl, maxttl);
97 }
98
99 public int
getType()100 getType() {
101 return type;
102 }
103
104 public final boolean
expired()105 expired() {
106 int now = (int)(System.currentTimeMillis() / 1000);
107 return (now >= expire);
108 }
109
110 public final int
compareCredibility(int cred)111 compareCredibility(int cred) {
112 return credibility - cred;
113 }
114
115 public String
toString()116 toString() {
117 StringBuffer sb = new StringBuffer();
118 if (type == 0)
119 sb.append("NXDOMAIN " + name);
120 else
121 sb.append("NXRRSET " + name + " " + Type.string(type));
122 sb.append(" cl = ");
123 sb.append(credibility);
124 return sb.toString();
125 }
126 }
127
128 private static class CacheMap extends LinkedHashMap {
129 private int maxsize = -1;
130
CacheMap(int maxsize)131 CacheMap(int maxsize) {
132 super(16, (float) 0.75, true);
133 this.maxsize = maxsize;
134 }
135
136 int
getMaxSize()137 getMaxSize() {
138 return maxsize;
139 }
140
141 void
setMaxSize(int maxsize)142 setMaxSize(int maxsize) {
143 /*
144 * Note that this doesn't shrink the size of the map if
145 * the maximum size is lowered, but it should shrink as
146 * entries expire.
147 */
148 this.maxsize = maxsize;
149 }
150
removeEldestEntry(Map.Entry eldest)151 protected boolean removeEldestEntry(Map.Entry eldest) {
152 return maxsize >= 0 && size() > maxsize;
153 }
154 }
155
156 private CacheMap data;
157 private int maxncache = -1;
158 private int maxcache = -1;
159 private int dclass;
160
161 private static final int defaultMaxEntries = 50000;
162
163 /**
164 * Creates an empty Cache
165 *
166 * @param dclass The DNS class of this cache
167 * @see DClass
168 */
169 public
Cache(int dclass)170 Cache(int dclass) {
171 this.dclass = dclass;
172 data = new CacheMap(defaultMaxEntries);
173 }
174
175 /**
176 * Creates an empty Cache for class IN.
177 * @see DClass
178 */
179 public
Cache()180 Cache() {
181 this(DClass.IN);
182 }
183
184 /**
185 * Creates a Cache which initially contains all records in the specified file.
186 */
187 public
Cache(String file)188 Cache(String file) throws IOException {
189 data = new CacheMap(defaultMaxEntries);
190 Master m = new Master(file);
191 Record record;
192 while ((record = m.nextRecord()) != null)
193 addRecord(record, Credibility.HINT, m);
194 }
195
196 private synchronized Object
exactName(Name name)197 exactName(Name name) {
198 return data.get(name);
199 }
200
201 private synchronized void
removeName(Name name)202 removeName(Name name) {
203 data.remove(name);
204 }
205
206 private synchronized Element []
allElements(Object types)207 allElements(Object types) {
208 if (types instanceof List) {
209 List typelist = (List) types;
210 int size = typelist.size();
211 return (Element []) typelist.toArray(new Element[size]);
212 } else {
213 Element set = (Element) types;
214 return new Element[] {set};
215 }
216 }
217
218 private synchronized Element
oneElement(Name name, Object types, int type, int minCred)219 oneElement(Name name, Object types, int type, int minCred) {
220 Element found = null;
221
222 if (type == Type.ANY)
223 throw new IllegalArgumentException("oneElement(ANY)");
224 if (types instanceof List) {
225 List list = (List) types;
226 for (int i = 0; i < list.size(); i++) {
227 Element set = (Element) list.get(i);
228 if (set.getType() == type) {
229 found = set;
230 break;
231 }
232 }
233 } else {
234 Element set = (Element) types;
235 if (set.getType() == type)
236 found = set;
237 }
238 if (found == null)
239 return null;
240 if (found.expired()) {
241 removeElement(name, type);
242 return null;
243 }
244 if (found.compareCredibility(minCred) < 0)
245 return null;
246 return found;
247 }
248
249 private synchronized Element
findElement(Name name, int type, int minCred)250 findElement(Name name, int type, int minCred) {
251 Object types = exactName(name);
252 if (types == null)
253 return null;
254 return oneElement(name, types, type, minCred);
255 }
256
257 private synchronized void
addElement(Name name, Element element)258 addElement(Name name, Element element) {
259 Object types = data.get(name);
260 if (types == null) {
261 data.put(name, element);
262 return;
263 }
264 int type = element.getType();
265 if (types instanceof List) {
266 List list = (List) types;
267 for (int i = 0; i < list.size(); i++) {
268 Element elt = (Element) list.get(i);
269 if (elt.getType() == type) {
270 list.set(i, element);
271 return;
272 }
273 }
274 list.add(element);
275 } else {
276 Element elt = (Element) types;
277 if (elt.getType() == type)
278 data.put(name, element);
279 else {
280 LinkedList list = new LinkedList();
281 list.add(elt);
282 list.add(element);
283 data.put(name, list);
284 }
285 }
286 }
287
288 private synchronized void
removeElement(Name name, int type)289 removeElement(Name name, int type) {
290 Object types = data.get(name);
291 if (types == null) {
292 return;
293 }
294 if (types instanceof List) {
295 List list = (List) types;
296 for (int i = 0; i < list.size(); i++) {
297 Element elt = (Element) list.get(i);
298 if (elt.getType() == type) {
299 list.remove(i);
300 if (list.size() == 0)
301 data.remove(name);
302 return;
303 }
304 }
305 } else {
306 Element elt = (Element) types;
307 if (elt.getType() != type)
308 return;
309 data.remove(name);
310 }
311 }
312
313 /** Empties the Cache. */
314 public synchronized void
clearCache()315 clearCache() {
316 data.clear();
317 }
318
319 /**
320 * Adds a record to the Cache.
321 * @param r The record to be added
322 * @param cred The credibility of the record
323 * @param o The source of the record (this could be a Message, for example)
324 * @see Record
325 */
326 public synchronized void
addRecord(Record r, int cred, Object o)327 addRecord(Record r, int cred, Object o) {
328 Name name = r.getName();
329 int type = r.getRRsetType();
330 if (!Type.isRR(type))
331 return;
332 Element element = findElement(name, type, cred);
333 if (element == null) {
334 CacheRRset crrset = new CacheRRset(r, cred, maxcache);
335 addRRset(crrset, cred);
336 } else if (element.compareCredibility(cred) == 0) {
337 if (element instanceof CacheRRset) {
338 CacheRRset crrset = (CacheRRset) element;
339 crrset.addRR(r);
340 }
341 }
342 }
343
344 /**
345 * Adds an RRset to the Cache.
346 * @param rrset The RRset to be added
347 * @param cred The credibility of these records
348 * @see RRset
349 */
350 public synchronized void
addRRset(RRset rrset, int cred)351 addRRset(RRset rrset, int cred) {
352 long ttl = rrset.getTTL();
353 Name name = rrset.getName();
354 int type = rrset.getType();
355 Element element = findElement(name, type, 0);
356 if (ttl == 0) {
357 if (element != null && element.compareCredibility(cred) <= 0)
358 removeElement(name, type);
359 } else {
360 if (element != null && element.compareCredibility(cred) <= 0)
361 element = null;
362 if (element == null) {
363 CacheRRset crrset;
364 if (rrset instanceof CacheRRset)
365 crrset = (CacheRRset) rrset;
366 else
367 crrset = new CacheRRset(rrset, cred, maxcache);
368 addElement(name, crrset);
369 }
370 }
371 }
372
373 /**
374 * Adds a negative entry to the Cache.
375 * @param name The name of the negative entry
376 * @param type The type of the negative entry
377 * @param soa The SOA record to add to the negative cache entry, or null.
378 * The negative cache ttl is derived from the SOA.
379 * @param cred The credibility of the negative entry
380 */
381 public synchronized void
addNegative(Name name, int type, SOARecord soa, int cred)382 addNegative(Name name, int type, SOARecord soa, int cred) {
383 long ttl = 0;
384 if (soa != null)
385 ttl = soa.getTTL();
386 Element element = findElement(name, type, 0);
387 if (ttl == 0) {
388 if (element != null && element.compareCredibility(cred) <= 0)
389 removeElement(name, type);
390 } else {
391 if (element != null && element.compareCredibility(cred) <= 0)
392 element = null;
393 if (element == null)
394 addElement(name, new NegativeElement(name, type,
395 soa, cred,
396 maxncache));
397 }
398 }
399
400 /**
401 * Finds all matching sets or something that causes the lookup to stop.
402 */
403 protected synchronized SetResponse
lookup(Name name, int type, int minCred)404 lookup(Name name, int type, int minCred) {
405 int labels;
406 int tlabels;
407 Element element;
408 Name tname;
409 Object types;
410 SetResponse sr;
411
412 labels = name.labels();
413
414 for (tlabels = labels; tlabels >= 1; tlabels--) {
415 boolean isRoot = (tlabels == 1);
416 boolean isExact = (tlabels == labels);
417
418 if (isRoot)
419 tname = Name.root;
420 else if (isExact)
421 tname = name;
422 else
423 tname = new Name(name, labels - tlabels);
424
425 types = data.get(tname);
426 if (types == null)
427 continue;
428
429 /*
430 * If this is the name, look for the actual type or a CNAME
431 * (unless it's an ANY query, where we return everything).
432 * Otherwise, look for a DNAME.
433 */
434 if (isExact && type == Type.ANY) {
435 sr = new SetResponse(SetResponse.SUCCESSFUL);
436 Element [] elements = allElements(types);
437 int added = 0;
438 for (int i = 0; i < elements.length; i++) {
439 element = elements[i];
440 if (element.expired()) {
441 removeElement(tname, element.getType());
442 continue;
443 }
444 if (!(element instanceof CacheRRset))
445 continue;
446 if (element.compareCredibility(minCred) < 0)
447 continue;
448 sr.addRRset((CacheRRset)element);
449 added++;
450 }
451 /* There were positive entries */
452 if (added > 0)
453 return sr;
454 } else if (isExact) {
455 element = oneElement(tname, types, type, minCred);
456 if (element != null &&
457 element instanceof CacheRRset)
458 {
459 sr = new SetResponse(SetResponse.SUCCESSFUL);
460 sr.addRRset((CacheRRset) element);
461 return sr;
462 } else if (element != null) {
463 sr = new SetResponse(SetResponse.NXRRSET);
464 return sr;
465 }
466
467 element = oneElement(tname, types, Type.CNAME, minCred);
468 if (element != null &&
469 element instanceof CacheRRset)
470 {
471 return new SetResponse(SetResponse.CNAME,
472 (CacheRRset) element);
473 }
474 } else {
475 element = oneElement(tname, types, Type.DNAME, minCred);
476 if (element != null &&
477 element instanceof CacheRRset)
478 {
479 return new SetResponse(SetResponse.DNAME,
480 (CacheRRset) element);
481 }
482 }
483
484 /* Look for an NS */
485 element = oneElement(tname, types, Type.NS, minCred);
486 if (element != null && element instanceof CacheRRset)
487 return new SetResponse(SetResponse.DELEGATION,
488 (CacheRRset) element);
489
490 /* Check for the special NXDOMAIN element. */
491 if (isExact) {
492 element = oneElement(tname, types, 0, minCred);
493 if (element != null)
494 return SetResponse.ofType(SetResponse.NXDOMAIN);
495 }
496
497 }
498 return SetResponse.ofType(SetResponse.UNKNOWN);
499 }
500
501 /**
502 * Looks up Records in the Cache. This follows CNAMEs and handles negatively
503 * cached data.
504 * @param name The name to look up
505 * @param type The type to look up
506 * @param minCred The minimum acceptable credibility
507 * @return A SetResponse object
508 * @see SetResponse
509 * @see Credibility
510 */
511 public SetResponse
lookupRecords(Name name, int type, int minCred)512 lookupRecords(Name name, int type, int minCred) {
513 return lookup(name, type, minCred);
514 }
515
516 private RRset []
findRecords(Name name, int type, int minCred)517 findRecords(Name name, int type, int minCred) {
518 SetResponse cr = lookupRecords(name, type, minCred);
519 if (cr.isSuccessful())
520 return cr.answers();
521 else
522 return null;
523 }
524
525 /**
526 * Looks up credible Records in the Cache (a wrapper around lookupRecords).
527 * Unlike lookupRecords, this given no indication of why failure occurred.
528 * @param name The name to look up
529 * @param type The type to look up
530 * @return An array of RRsets, or null
531 * @see Credibility
532 */
533 public RRset []
findRecords(Name name, int type)534 findRecords(Name name, int type) {
535 return findRecords(name, type, Credibility.NORMAL);
536 }
537
538 /**
539 * Looks up Records in the Cache (a wrapper around lookupRecords). Unlike
540 * lookupRecords, this given no indication of why failure occurred.
541 * @param name The name to look up
542 * @param type The type to look up
543 * @return An array of RRsets, or null
544 * @see Credibility
545 */
546 public RRset []
findAnyRecords(Name name, int type)547 findAnyRecords(Name name, int type) {
548 return findRecords(name, type, Credibility.GLUE);
549 }
550
551 private final int
getCred(int section, boolean isAuth)552 getCred(int section, boolean isAuth) {
553 if (section == Section.ANSWER) {
554 if (isAuth)
555 return Credibility.AUTH_ANSWER;
556 else
557 return Credibility.NONAUTH_ANSWER;
558 } else if (section == Section.AUTHORITY) {
559 if (isAuth)
560 return Credibility.AUTH_AUTHORITY;
561 else
562 return Credibility.NONAUTH_AUTHORITY;
563 } else if (section == Section.ADDITIONAL) {
564 return Credibility.ADDITIONAL;
565 } else
566 throw new IllegalArgumentException("getCred: invalid section");
567 }
568
569 private static void
markAdditional(RRset rrset, Set names)570 markAdditional(RRset rrset, Set names) {
571 Record first = rrset.first();
572 if (first.getAdditionalName() == null)
573 return;
574
575 Iterator it = rrset.rrs();
576 while (it.hasNext()) {
577 Record r = (Record) it.next();
578 Name name = r.getAdditionalName();
579 if (name != null)
580 names.add(name);
581 }
582 }
583
584 /**
585 * Adds all data from a Message into the Cache. Each record is added with
586 * the appropriate credibility, and negative answers are cached as such.
587 * @param in The Message to be added
588 * @return A SetResponse that reflects what would be returned from a cache
589 * lookup, or null if nothing useful could be cached from the message.
590 * @see Message
591 */
592 public SetResponse
addMessage(Message in)593 addMessage(Message in) {
594 boolean isAuth = in.getHeader().getFlag(Flags.AA);
595 Record question = in.getQuestion();
596 Name qname;
597 Name curname;
598 int qtype;
599 int qclass;
600 int cred;
601 int rcode = in.getHeader().getRcode();
602 boolean completed = false;
603 RRset [] answers, auth, addl;
604 SetResponse response = null;
605 boolean verbose = Options.check("verbosecache");
606 HashSet additionalNames;
607
608 if ((rcode != Rcode.NOERROR && rcode != Rcode.NXDOMAIN) ||
609 question == null)
610 return null;
611
612 qname = question.getName();
613 qtype = question.getType();
614 qclass = question.getDClass();
615
616 curname = qname;
617
618 additionalNames = new HashSet();
619
620 answers = in.getSectionRRsets(Section.ANSWER);
621 for (int i = 0; i < answers.length; i++) {
622 if (answers[i].getDClass() != qclass)
623 continue;
624 int type = answers[i].getType();
625 Name name = answers[i].getName();
626 cred = getCred(Section.ANSWER, isAuth);
627 if ((type == qtype || qtype == Type.ANY) &&
628 name.equals(curname))
629 {
630 addRRset(answers[i], cred);
631 completed = true;
632 if (curname == qname) {
633 if (response == null)
634 response = new SetResponse(
635 SetResponse.SUCCESSFUL);
636 response.addRRset(answers[i]);
637 }
638 markAdditional(answers[i], additionalNames);
639 } else if (type == Type.CNAME && name.equals(curname)) {
640 CNAMERecord cname;
641 addRRset(answers[i], cred);
642 if (curname == qname)
643 response = new SetResponse(SetResponse.CNAME,
644 answers[i]);
645 cname = (CNAMERecord) answers[i].first();
646 curname = cname.getTarget();
647 } else if (type == Type.DNAME && curname.subdomain(name)) {
648 DNAMERecord dname;
649 addRRset(answers[i], cred);
650 if (curname == qname)
651 response = new SetResponse(SetResponse.DNAME,
652 answers[i]);
653 dname = (DNAMERecord) answers[i].first();
654 try {
655 curname = curname.fromDNAME(dname);
656 }
657 catch (NameTooLongException e) {
658 break;
659 }
660 }
661 }
662
663 auth = in.getSectionRRsets(Section.AUTHORITY);
664 RRset soa = null, ns = null;
665 for (int i = 0; i < auth.length; i++) {
666 if (auth[i].getType() == Type.SOA &&
667 curname.subdomain(auth[i].getName()))
668 soa = auth[i];
669 else if (auth[i].getType() == Type.NS &&
670 curname.subdomain(auth[i].getName()))
671 ns = auth[i];
672 }
673 if (!completed) {
674 /* This is a negative response or a referral. */
675 int cachetype = (rcode == Rcode.NXDOMAIN) ? 0 : qtype;
676 if (rcode == Rcode.NXDOMAIN || soa != null || ns == null) {
677 /* Negative response */
678 cred = getCred(Section.AUTHORITY, isAuth);
679 SOARecord soarec = null;
680 if (soa != null)
681 soarec = (SOARecord) soa.first();
682 addNegative(curname, cachetype, soarec, cred);
683 if (response == null) {
684 int responseType;
685 if (rcode == Rcode.NXDOMAIN)
686 responseType = SetResponse.NXDOMAIN;
687 else
688 responseType = SetResponse.NXRRSET;
689 response = SetResponse.ofType(responseType);
690 }
691 /* DNSSEC records are not cached. */
692 } else {
693 /* Referral response */
694 cred = getCred(Section.AUTHORITY, isAuth);
695 addRRset(ns, cred);
696 markAdditional(ns, additionalNames);
697 if (response == null)
698 response = new SetResponse(
699 SetResponse.DELEGATION,
700 ns);
701 }
702 } else if (rcode == Rcode.NOERROR && ns != null) {
703 /* Cache the NS set from a positive response. */
704 cred = getCred(Section.AUTHORITY, isAuth);
705 addRRset(ns, cred);
706 markAdditional(ns, additionalNames);
707 }
708
709 addl = in.getSectionRRsets(Section.ADDITIONAL);
710 for (int i = 0; i < addl.length; i++) {
711 int type = addl[i].getType();
712 if (type != Type.A && type != Type.AAAA && type != Type.A6)
713 continue;
714 Name name = addl[i].getName();
715 if (!additionalNames.contains(name))
716 continue;
717 cred = getCred(Section.ADDITIONAL, isAuth);
718 addRRset(addl[i], cred);
719 }
720 if (verbose)
721 System.out.println("addMessage: " + response);
722 return (response);
723 }
724
725 /**
726 * Flushes an RRset from the cache
727 * @param name The name of the records to be flushed
728 * @param type The type of the records to be flushed
729 * @see RRset
730 */
731 public void
flushSet(Name name, int type)732 flushSet(Name name, int type) {
733 removeElement(name, type);
734 }
735
736 /**
737 * Flushes all RRsets with a given name from the cache
738 * @param name The name of the records to be flushed
739 * @see RRset
740 */
741 public void
flushName(Name name)742 flushName(Name name) {
743 removeName(name);
744 }
745
746 /**
747 * Sets the maximum length of time that a negative response will be stored
748 * in this Cache. A negative value disables this feature (that is, sets
749 * no limit).
750 */
751 public void
setMaxNCache(int seconds)752 setMaxNCache(int seconds) {
753 maxncache = seconds;
754 }
755
756 /**
757 * Gets the maximum length of time that a negative response will be stored
758 * in this Cache. A negative value indicates no limit.
759 */
760 public int
getMaxNCache()761 getMaxNCache() {
762 return maxncache;
763 }
764
765 /**
766 * Sets the maximum length of time that records will be stored in this
767 * Cache. A negative value disables this feature (that is, sets no limit).
768 */
769 public void
setMaxCache(int seconds)770 setMaxCache(int seconds) {
771 maxcache = seconds;
772 }
773
774 /**
775 * Gets the maximum length of time that records will be stored
776 * in this Cache. A negative value indicates no limit.
777 */
778 public int
getMaxCache()779 getMaxCache() {
780 return maxcache;
781 }
782
783 /**
784 * Gets the current number of entries in the Cache, where an entry consists
785 * of all records with a specific Name.
786 */
787 public int
getSize()788 getSize() {
789 return data.size();
790 }
791
792 /**
793 * Gets the maximum number of entries in the Cache, where an entry consists
794 * of all records with a specific Name. A negative value is treated as an
795 * infinite limit.
796 */
797 public int
getMaxEntries()798 getMaxEntries() {
799 return data.getMaxSize();
800 }
801
802 /**
803 * Sets the maximum number of entries in the Cache, where an entry consists
804 * of all records with a specific Name. A negative value is treated as an
805 * infinite limit.
806 *
807 * Note that setting this to a value lower than the current number
808 * of entries will not cause the Cache to shrink immediately.
809 *
810 * The default maximum number of entries is 50000.
811 *
812 * @param entries The maximum number of entries in the Cache.
813 */
814 public void
setMaxEntries(int entries)815 setMaxEntries(int entries) {
816 data.setMaxSize(entries);
817 }
818
819 /**
820 * Returns the DNS class of this cache.
821 */
822 public int
getDClass()823 getDClass() {
824 return dclass;
825 }
826
827 /**
828 * Returns the contents of the Cache as a string.
829 */
830 public String
toString()831 toString() {
832 StringBuffer sb = new StringBuffer();
833 synchronized (this) {
834 Iterator it = data.values().iterator();
835 while (it.hasNext()) {
836 Element [] elements = allElements(it.next());
837 for (int i = 0; i < elements.length; i++) {
838 sb.append(elements[i]);
839 sb.append("\n");
840 }
841 }
842 }
843 return sb.toString();
844 }
845
846 }
847