From 530c5b0fcbef330ea762071144a864e19b1c7595 Mon Sep 17 00:00:00 2001 From: Jonathan Hedley Date: Sun, 15 Aug 2021 13:38:54 +1000 Subject: [PATCH] Refactored fuzz tests to iterate all files in directory; run timeout tests --- src/main/java/org/jsoup/Jsoup.java | 29 ++- src/main/java/org/jsoup/helper/DataUtil.java | 30 ++- .../org/jsoup/integration/FuzzFixesIT.java | 61 +++++ .../org/jsoup/integration/FuzzFixesTest.java | 213 ++---------------- src/test/java/org/jsoup/parser/ParserIT.java | 2 + src/test/resources/fuzztests/36192.html.gz | Bin 0 -> 7089 bytes 6 files changed, 132 insertions(+), 203 deletions(-) create mode 100644 src/test/java/org/jsoup/integration/FuzzFixesIT.java create mode 100644 src/test/resources/fuzztests/36192.html.gz diff --git a/src/main/java/org/jsoup/Jsoup.java b/src/main/java/org/jsoup/Jsoup.java index a8f1f44726..81b7b724b9 100644 --- a/src/main/java/org/jsoup/Jsoup.java +++ b/src/main/java/org/jsoup/Jsoup.java @@ -108,7 +108,7 @@ public static Connection newSession() { /** Parse the contents of a file as HTML. - @param in file to load HTML from + @param file file to load HTML from. Supports gzipped files (ending in .z or .gz). @param charsetName (optional) character set of file contents. Set to {@code null} to determine from {@code http-equiv} meta tag, if present, or fall back to {@code UTF-8} (which is often safe to do). @param baseUri The URL where the HTML was retrieved from, to resolve relative links against. @@ -116,14 +116,14 @@ public static Connection newSession() { @throws IOException if the file could not be found, or read, or if the charsetName is invalid. */ - public static Document parse(File in, @Nullable String charsetName, String baseUri) throws IOException { - return DataUtil.load(in, charsetName, baseUri); + public static Document parse(File file, @Nullable String charsetName, String baseUri) throws IOException { + return DataUtil.load(file, charsetName, baseUri); } /** Parse the contents of a file as HTML. The location of the file is used as the base URI to qualify relative URLs. - @param in file to load HTML from + @param file file to load HTML from. Supports gzipped files (ending in .z or .gz). @param charsetName (optional) character set of file contents. Set to {@code null} to determine from {@code http-equiv} meta tag, if present, or fall back to {@code UTF-8} (which is often safe to do). @return sane HTML @@ -131,8 +131,25 @@ public static Document parse(File in, @Nullable String charsetName, String baseU @throws IOException if the file could not be found, or read, or if the charsetName is invalid. @see #parse(File, String, String) */ - public static Document parse(File in, @Nullable String charsetName) throws IOException { - return DataUtil.load(in, charsetName, in.getAbsolutePath()); + public static Document parse(File file, @Nullable String charsetName) throws IOException { + return DataUtil.load(file, charsetName, file.getAbsolutePath()); + } + + /** + Parse the contents of a file as HTML. + + @param file file to load HTML from. Supports gzipped files (ending in .z or .gz). + @param charsetName (optional) character set of file contents. Set to {@code null} to determine from {@code http-equiv} meta tag, if + present, or fall back to {@code UTF-8} (which is often safe to do). + @param baseUri The URL where the HTML was retrieved from, to resolve relative links against. + @param parser alternate {@link Parser#xmlParser() parser} to use. + @return sane HTML + + @throws IOException if the file could not be found, or read, or if the charsetName is invalid. + @since 1.14.2 + */ + public static Document parse(File file, @Nullable String charsetName, String baseUri, Parser parser) throws IOException { + return DataUtil.load(file, charsetName, baseUri, parser); } /** diff --git a/src/main/java/org/jsoup/helper/DataUtil.java b/src/main/java/org/jsoup/helper/DataUtil.java index 3b0a2b12ed..7100f52c58 100644 --- a/src/main/java/org/jsoup/helper/DataUtil.java +++ b/src/main/java/org/jsoup/helper/DataUtil.java @@ -49,20 +49,38 @@ public final class DataUtil { private DataUtil() {} + /** + * Loads and parses a file to a Document, with the HtmlParser. Files that are compressed with gzip (and end in {@code .gz} or {@code .z}) + * are supported in addition to uncompressed files. + * + * @param file file to load + * @param charsetName (optional) character set of input; specify {@code null} to attempt to autodetect. A BOM in + * the file will always override this setting. + * @param baseUri base URI of document, to resolve relative links against + * @return Document + * @throws IOException on IO error + */ + public static Document load(File file, @Nullable String charsetName, String baseUri) throws IOException { + return load(file, charsetName, baseUri, Parser.htmlParser()); + } + /** * Loads and parses a file to a Document. Files that are compressed with gzip (and end in {@code .gz} or {@code .z}) * are supported in addition to uncompressed files. * - * @param in file to load + * @param file file to load * @param charsetName (optional) character set of input; specify {@code null} to attempt to autodetect. A BOM in * the file will always override this setting. * @param baseUri base URI of document, to resolve relative links against + * @param parser alternate {@link Parser#xmlParser() parser} to use. + * @return Document * @throws IOException on IO error + * @since 1.14.2 */ - public static Document load(File in, @Nullable String charsetName, String baseUri) throws IOException { - InputStream stream = new FileInputStream(in); - String name = Normalizer.lowerCase(in.getName()); + public static Document load(File file, @Nullable String charsetName, String baseUri, Parser parser) throws IOException { + InputStream stream = new FileInputStream(file); + String name = Normalizer.lowerCase(file.getName()); if (name.endsWith(".gz") || name.endsWith(".z")) { // unfortunately file input streams don't support marks (why not?), so we will close and reopen after read boolean zipped; @@ -72,9 +90,9 @@ public static Document load(File in, @Nullable String charsetName, String baseUr stream.close(); } - stream = zipped ? new GZIPInputStream(new FileInputStream(in)) : new FileInputStream(in); + stream = zipped ? new GZIPInputStream(new FileInputStream(file)) : new FileInputStream(file); } - return parseInputStream(stream, charsetName, baseUri, Parser.htmlParser()); + return parseInputStream(stream, charsetName, baseUri, parser); } /** diff --git a/src/test/java/org/jsoup/integration/FuzzFixesIT.java b/src/test/java/org/jsoup/integration/FuzzFixesIT.java new file mode 100644 index 0000000000..a060de561b --- /dev/null +++ b/src/test/java/org/jsoup/integration/FuzzFixesIT.java @@ -0,0 +1,61 @@ +package org.jsoup.integration; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.parser.Parser; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.File; +import java.io.IOException; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + Tests fixes for issues raised by the OSS Fuzz project @ https://oss-fuzz.com/testcases?project=jsoup As some of these + are timeout tests - run each file 100 times and ensure under time. + */ +public class FuzzFixesIT { + static int numIters = 50; + static int timeout = 20; // external fuzzer is set to 60 for 100 runs + static File testDir = ParseTest.getFile("/fuzztests/"); + + private static Stream testFiles() { + File[] files = testDir.listFiles(); + assertNotNull(files); + assertTrue(files.length > 10); + + return Stream.of(files); + } + + @ParameterizedTest + @MethodSource("testFiles") + void testHtmlParse(File file) throws IOException { + long startTime = System.currentTimeMillis(); + long completeBy = startTime + timeout * 1000L; + + for (int i = 0; i < numIters; i++) { + Document doc = Jsoup.parse(file, "UTF-8", "https://example.com/"); + assertNotNull(doc); + if (System.currentTimeMillis() > completeBy) + Assertions.fail(String.format("Timeout: only completed %d iters of [%s] in %d seconds", i, file.getName(), timeout)); + } + } + + @ParameterizedTest + @MethodSource("testFiles") + void testXmlParse(File file) throws IOException { + long startTime = System.currentTimeMillis(); + long completeBy = startTime + timeout * 1000L; + + for (int i = 0; i < numIters; i++) { + Document doc = Jsoup.parse(file, "UTF-8", "https://example.com/", Parser.xmlParser()); + assertNotNull(doc); + if (System.currentTimeMillis() > completeBy) + Assertions.fail(String.format("Timeout: only completed %d iters of [%s] in %d seconds", i, file.getName(), timeout)); + } + } +} diff --git a/src/test/java/org/jsoup/integration/FuzzFixesTest.java b/src/test/java/org/jsoup/integration/FuzzFixesTest.java index 53a3bfd9b8..0fd668f256 100644 --- a/src/test/java/org/jsoup/integration/FuzzFixesTest.java +++ b/src/test/java/org/jsoup/integration/FuzzFixesTest.java @@ -4,18 +4,30 @@ import org.jsoup.nodes.Document; import org.jsoup.parser.Parser; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** - Tests fixes for issues raised by the OSS Fuzz project @ https://oss-fuzz.com/testcases?project=jsoup + Tests fixes for issues raised by the OSS Fuzz project @ https://oss-fuzz.com/testcases?project=jsoup. Contains inline + string cases causing exceptions. Timeout tests are in FuzzFixesIT. */ public class FuzzFixesTest { + private static Stream testFiles() { + File[] files = FuzzFixesIT.testDir.listFiles(); + assertNotNull(files); + assertTrue(files.length > 10); + + return Stream.of(files); + } + @Test public void blankAbsAttr() { // https://github.com/jhy/jsoup/issues/1541 @@ -24,61 +36,6 @@ public void blankAbsAttr() { assertNotNull(doc); } - @Test - public void resetInsertionMode() throws IOException { - // https://github.com/jhy/jsoup/issues/1538 - File in = ParseTest.getFile("/fuzztests/1538.html.gz"); // lots of escape chars etc. - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - } - - @Test - public void xmlDeclOverflow() throws IOException { - // https://github.com/jhy/jsoup/issues/1539 - File in = ParseTest.getFile("/fuzztests/1539.html.gz"); // lots of escape chars etc. - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void xmlDeclOverflowOOM() throws IOException { - // https://github.com/jhy/jsoup/issues/1569 - File in = ParseTest.getFile("/fuzztests/1569.html.gz"); - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void stackOverflowState14() throws IOException { - // https://github.com/jhy/jsoup/issues/1543 - File in = ParseTest.getFile("/fuzztests/1543.html.gz"); - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - } - - @Test - public void parseTimeout() throws IOException { - // https://github.com/jhy/jsoup/issues/1544 - File in = ParseTest.getFile("/fuzztests/1544.html.gz"); - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - } - - @Test - public void parseTimeout1580() throws IOException { - // https://github.com/jhy/jsoup/issues/1580 - // a shedload of NULLs in append tagname so was spinning in there. Fixed to eat and replace all the chars in one hit - File in = ParseTest.getFile("/fuzztests/1580.html.gz"); - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - } - @Test public void bookmark() { // https://github.com/jhy/jsoup/issues/1576 @@ -90,143 +47,17 @@ public void bookmark() { assertNotNull(xmlDoc); } - @Test - public void scope1579() { - // https://github.com/jhy/jsoup/issues/1579 - String html = " "; - Document doc = Jsoup.parse(html); - assertNotNull(doc); - - Document xmlDoc = Parser.xmlParser().parseInput(html, ""); - assertNotNull(xmlDoc); - } - - @Test - public void overflow1577() throws IOException { - // https://github.com/jhy/jsoup/issues/1577 - File in = ParseTest.getFile("/fuzztests/1577.html.gz"); - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void parseTimeout36150() throws IOException { - File in = ParseTest.getFile("/fuzztests/1580-attrname.html.gz"); - // pretty much 1MB of null chars in text head - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void parseTimeout1593() throws IOException { - // https://github.com/jhy/jsoup/issues/1593 - // had unbounded depth in the foster formatting element scan - now limited to <= 256 - // realworld HTML generally has only a few - File in = ParseTest.getFile("/fuzztests/1593.html.gz"); - - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void parseTimeout1595() throws IOException { - // https://github.com/jhy/jsoup/issues/1595 - // Time was getting soaked when setting a form attribute by searching up the node.root for ownerdocuments - File in = ParseTest.getFile("/fuzztests/1595.html.gz"); - - Document doc = Jsoup.parse(in, "UTF-8"); + @ParameterizedTest + @MethodSource("testFiles") + void testHtmlParse(File file) throws IOException { + Document doc = Jsoup.parse(file, "UTF-8", "https://example.com/"); assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); } - @Test - public void parseTimeout1596() throws IOException { - // https://github.com/jhy/jsoup/issues/1596 - // Timesink when the stack was thousands of items deep, and non-matching close tags sent - File in = ParseTest.getFile("/fuzztests/1596.html.gz"); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void parseTimeout1605() throws IOException { - // timesink with 600K of accumulating attribute name - File in = ParseTest.getFile("/fuzztests/1605.html.gz"); - - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void parseTimeout1606() throws IOException { - // https://github.com/jhy/jsoup/issues/1606 - // Timesink when closing missing empty tag (in XML comment processed as HTML) when thousands deep - File in = ParseTest.getFile("/fuzztests/1606.html.gz"); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void overflow1607() throws IOException { - // https://github.com/jhy/jsoup/issues/1607 - File in = ParseTest.getFile("/fuzztests/1607.html.gz"); - - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void oob() throws IOException { - // https://github.com/jhy/jsoup/issues/1611 - File in = ParseTest.getFile("/fuzztests/1611.html.gz"); - - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void unconsume() throws IOException { - // https://github.com/jhy/jsoup/issues/1612 - // I wasn't able to repro this with different ways of loading strings - think somehow the fuzzers input - // buffer is different and the bufferUp() happened at a different point. Regardless, did find an unsafe use - // of unconsume() after a buffer up in bogus comment, so cleaned that up. - File in = ParseTest.getFile("/fuzztests/1612.html.gz"); - - Document doc = Jsoup.parse(in, "UTF-8"); - assertNotNull(doc); - - Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser()); - assertNotNull(docXml); - } - - @Test - public void test36916() throws IOException { - // https://github.com/jhy/jsoup/issues/1613 - File in = ParseTest.getFile("/fuzztests/1613.html.gz"); - - Document doc = Jsoup.parse(in, "UTF-8"); + @ParameterizedTest + @MethodSource("testFiles") + void testXmlParse(File file) throws IOException { + Document doc = Jsoup.parse(file, "UTF-8", "https://example.com/", Parser.xmlParser()); assertNotNull(doc); } } diff --git a/src/test/java/org/jsoup/parser/ParserIT.java b/src/test/java/org/jsoup/parser/ParserIT.java index e5dee55b1d..54d757e77b 100644 --- a/src/test/java/org/jsoup/parser/ParserIT.java +++ b/src/test/java/org/jsoup/parser/ParserIT.java @@ -1,6 +1,7 @@ package org.jsoup.parser; import org.jsoup.nodes.Document; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -12,6 +13,7 @@ public class ParserIT { @Test + @Disabled // disabled by default now, as there more specific unconsume tests public void testIssue1251() { // https://github.com/jhy/jsoup/issues/1251 StringBuilder str = new StringBuilder("o)p(J{%sLP(mJ3BF2$@FS_aa^_b?nmVq4gefPVbXl-k}i~45VucyCE zWQnH6gZIAr2lp3d40v+pWnc}S8H0%r4bO$F$APZSY)NAmWUxi89h_PM?J9!|O0K<_ zd+zwE;dps+yql3ZGsA*k&>C!iy0L!3*wT1j*}w-DqaZk)R8#XYgxpL2j3CO0;*b5cG_as6r~K$CY*h}U!|LJn?fWlz;H%HTVkvq?{ouC`dzL$YLfxj<;mdm9Ali{ zkM*Ul1^;u`9}8DI*5y86udB-o#nsNdt*tG~{l!+%)4gHWqj{*Bf-T?KajHJ|RgviF z@aDyG(&Ps0LhJ@HUu~ZlUD#=#7K^=PeR4LvBqlz6m@}<*F?zUH?!UKo&zI-wb77nB z{&G*bU&D~z+-ehfHg$j9vb}(wc#nMzZtG}vsxE{-eA3Kw{Z4nw-JKcA9=8#0Y6pwf z3lC9W5V?f3wg=h0850FDKcUI`>}M(Et2fb!a+nlv1^l z4r_5F!G+sv0`Qe26*!7}3j=%mqNZef->aR=&S&WR>(>rY%Nh2%Q|VWFk`O^kql*cN z%j4>;m7}(@50hyQAC8*RB}bM<;e?lqW(^nHp_3tT;upE;n%0jCuP(!EFNk|OKk@N! zT#d$in8sB1mD$!U*UyPsy~ZEJPU^uADabRwdIgY}bSCVpsO$RJ-i+WUU#S0@=83Q+ z)CgwneXKTO8xc%*i<4OX(PFV}O&r-1;((0MnQb!MDqK;zNfk;rl5%?D%un8~vMjPJ z^w7^~1H;N+k;_9ZMs@Kcu_Q!Ikdt>Pk>f5wp}v{`Cu-O5Z2_4lKmA6|Z21>nmN@3z zmx>y)LriL|>o9!o0*-KKl06hK8yCTW#~yuQY<`s$mnNsn?ivauUJ+-rs)P9^0vYMtaB^?oC*$@dY+VG6Xla>D zdwNR*mzs$y1v5Br+f{PEY;Go^nO$O65eS!Z6NunFZfx(_34H1x(?Ao$s1TFy;yh09 zHPWUaaWg3x9smdhc!Zg`io@taPE=m@=JRj>2Y@;`!H^X>Gdp4m@%R{#4e|jxRou9< zgz%);)e3xwAwKoCwDfc0SgnrB25fwJVgpTAJ0E6hkz8x|t7RK)~%k7^6Y;K9&5{n3ZgI<+utmxOI z>b1vHY$Y^r-cXK4^iGk~3rFI+)lR|W4dla3+7jrV+jDs47A(h3lH3*ZpTB~f7bY<* zrY~(p?D^}RNuk{7J z$8qGNP;;pKL?~hzNa4Gy@_=t5G*6VH6K08Etrk5x&%qg~#gXH`1-k9c!%Jrns2Zqx zEt2)|9MjL>4h)@HIUV0S*`uq~gFZNt7w1pFk@B=GKhj=QSQ?#^q-247rp*c3O6nmnq&=**njN+QNggt?9l&-%t zacmwsIPf~fHqbRZM6=U6nK2~CA^YrL;MfOt|e?&lrb+@JnZ%aVA-`^tQmCCV9g-)O{e`N0i8(J5D@K22EWNy z(xB;O)r`Nvs;Qo@H*NXDAGODK>mGouU*kgLX%nXIA;$;;Yt&>AvE^u*v$LLMZmLp&(97= zP+&SFTt$Ez z(`za^<7-Qw1(=@DO6Iet%m>xSm&KR87R!ZfZ0@#TuWYJz#zyOzlcB4Zq1H!@Drdl> zy>^ZLr}{D?y!Ou0VWqJ zeu)K2IodTs0JhvJVGwR40QKHT03k+cbcH^@x@LY6p%s8l2_3-3s#)q#bu5kA(|SM@ z30N)iqpqA4WA)z##l<@+dG>{uM!9i+MC&&cPOI6+PpdOh)dajYgDs<{3HL)Bg1kGM z&0#!FQy436@y7XUF(}H=HQ^UEd=;{gH`MV!+L;W58y|(#y+Z3YnxECQJ1@DWF^2Cl z3|J^d6Pm}r(xT(-9Z&R>1YspUbZMt zvVlRi_p8kWZc_c(W`M~Q!y}Ak_2w3}qqLkVe#6nnxoDmeT0YpN9$uOUG(#MJsyVjM zan6K7o;a74|LB_CETgJ$60(7U|le-?SU4-hC1dyrJM{ek-Pg0 zri_jf_}LV5)D^o4;5dz>S8bnK*_7??QkcHXZRr9v+dVuM3T_s$GgW0A!d(Pg~Q z-kF~=84$M8dPAa=R%M=kPd)Vm6pV*_xb0Qs*in7x4>vj@)Y)o?XD^BzTCAS(Jp}lS zesQS`7l_ghAt9I`>tGMxb2RGa7*Aiak8`*AwjTwy`9?x}Wc#gzKg?=k`suuS)c$oo z`<}bA;C-}u013~vp9@Y&E@ZJ$KCqE{baR+w)0uZ{ft95AK;fpRv-S(>E`r?OZ_4RF z=9i|9B|paf-3N+E6hY-wuV-4Fd*N0$rvnnjfy&W9<)!s9svNQ(j~!MD<1rFv^Itl6 z5kyoFto49+j%T2umK6kwsHW1hssVn!d-8xd-+?$7OcF`AZ}wEC=4R&2O!)_2(e#JQ zAGE~|1Y?&phLjw0NWB}dR?E?ip(g*8dWQpy>6-&_oHndV4~sfaK>XhqfL6NXBNzlIu=(Z z+21&)iDyBJE)239X{&$MmcY{n6`T8ZnDk!1#_`5NN!g=7G(P`oJaqZfqv`oAMBP11 zZS5oh3mT(XX1i4yO|7@_XhQb=wNrTy7e?onS!_!H8rp2s1LoU112B}G1O%skX44eG zui)zd56CDd^MA;Z>&+(Wvt7cl>smmu1ys~q8DXk47t)DVQFtVOR zphiWH`>N||UFGPd+@n#!s=T|jhq0^Dk6pc-el*Sunf913SE$+vp)tOG$G>)8l`(;< zvD^BGzJnA5x5R0=UQa#)R3aCl{#0HozkV?^X7UL{0CWoCUyQmT0J`sx_$-V?M;K&) zPp!g#9mdx^S3_9;SBj(&?e?(T?SD5$MLg54ao_^*T*dq?T}=1RwJN|=S}cT?jgQtr zADXG&w&)`UGd$z>_C=n-h|{VfoSZXy`sHVx%=5#||=@X}G?KO>C|El0K}ur5~|fa*T3} zMBU|+*=1{PuHYiq!vx`6qnCM`ORP`k|L$X@AN^l8ZxKr}z@IviAA&KZ*!N_Sd8G8C zqGhhHE?KXhiyH2HF+#%K&mpPn%6n5nE%Rs=)~&T>4Ii2#+?d=;1eXT&4a*NSKd_4g zkbJx@2!O5m6nf66k|0-XopT64|}n0Lt?S#9+WhV*)Ro1ajqaU;9IfGB~82aktJYK%pO=!uWio9vzNhq^k%)cgt zI~dq!LnvTNuDI%MDZC9jX}!hCruG}77rj&0y~gWjT-2^IRZxL-Mw16Xu?WO6iX-HN z;&`EXNWgrPw4Ftn`ROHt&taCG!P6`zeLhOn>MWbzXyYRP=-dBA8!oqDH1GsvuSeV# z9~5Z@xifMIStNF~!ruKut6DH=*ndH4pqZ8RhG{hKdYDku*`*hjrL>!?K4!B86aLce}B$40~?+REZirHe-`A#^fF)2|d+dQk2;MkdVUXGyQu+XzVk&_AfkbTRlS~xT-N6VmNX~A+ z`5aPk$AO5b?mXe&wX*sZqJNgxN{aN%eqr^@WlOXfmAb1UV}u5yE%mHBLk%#i`29KH zy4)26$Z1UNf_`1P?Iu{`Z#{O}Z%4p1S6 z{nUMZyfS@HMyF1lEW6A4!^t+=&ys|tr^L2*igB4JE7*f|Z~tdA1kmiiEp$}lKj#sy zsKysmGtt~_UeE)WDi?E5yEjIV|M%W_?E0tCKl-My$el{eL57FDe>32R zTm0iwa?c%Ml2uQw>y#)MHKsymH0rK=TV~bCgFZm%%8$zT#pPjMK&0JohFkKd;cEY3 zxUYbb_e1hTqgz@gp+HO!E~;IDaKq4_C`^>zWo6UU4&#dimrIfclz@xIztsMuX=M z>e&gqXf*Yh@&MMKR>wF38vi3$$sYU_tcV{*+e89%XDWE7?cxfQ+H4ejH3ABh-m19n z?B#D3C`G-@vt_;>1zZd^|79Rw6E}y0Q*d&bY8XCG#P(-;kN!C_TO4fuebQlgGClsK zso1kWw0VoRr6|QSuSWP8Odml=VnT}qOkmQBlil{Q0)`qv&v&^ZU6jYOF4GraCG9Du z`!qku^~thlCoJEfTbYsk5d%N+5H}Jxj<)-?*{6IK&Zo&N$KQbGF2t z%tFWE^pREnZ6O<3DMoWGXE!?^74dfIS>z_@zZcG}54)K!kdM>E6Y91i%t2>-YYj_D z_c~Atcj>k&5^Y2xiI6LwJ)gZWcHN}05dxQOX#7^e!0<#5gqF;XTF%KF)LkUL&dJSs zcW%d4^&Na^dVkIt&UDNj?McSq4+Y4=!WG+RKW5SglRM)8w+u8|{ z1<*(G{WfDgsHWJmp6S$iFAS^?LdBll1>E1Vc-^ZuOcx^EW#jDiyWZrGrV8QADL1ftW|mZ!3Ki2HUm;O8Knc%JQ)$fSP0Kj-+_EmY#D zW26P6-p}gf09>sW{ip!cW5f%_O0vd|{WfX{b~BF_y%d2Gxr>QUh@W61_M#@mS?aQR z1%f^cYClCJ>?z*~-wV+H3==A-=!B!nVH^0_l(xl9DX{sqJYMcv9wEqGUS>3|` literal 0 HcmV?d00001