From ca038d39f70ea60ca4eb1c22a47e9b3bfcfe51a1 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Wed, 13 May 2015 20:16:03 +0000 Subject: [PATCH] Proper custom pkg index support, some tests - The rebar package index files have been moved off the default path and will require a new `rebar3 update` - Caching of downloaded packages automatically takes place in a path relative to the CDN used - The cache path is not shared with hex as we now write and modify data in there arbitrarily - Basic tests plus the working set for more of them is included --- src/rebar.hrl | 1 + src/rebar_packages.erl | 18 +++- src/rebar_pkg_resource.erl | 26 ++--- src/rebar_prv_update.erl | 6 +- test/rebar_pkg_SUITE.erl | 100 ++++++++++++++++++ .../badindexchk-1.0.0.tar | Bin 0 -> 10240 bytes test/rebar_pkg_SUITE_data/badpkg-1.0.0.tar | Bin 0 -> 10240 bytes test/rebar_pkg_SUITE_data/goodpkg-1.0.0.tar | Bin 0 -> 10240 bytes 8 files changed, 125 insertions(+), 26 deletions(-) create mode 100644 test/rebar_pkg_SUITE.erl create mode 100644 test/rebar_pkg_SUITE_data/badindexchk-1.0.0.tar create mode 100644 test/rebar_pkg_SUITE_data/badpkg-1.0.0.tar create mode 100644 test/rebar_pkg_SUITE_data/goodpkg-1.0.0.tar diff --git a/src/rebar.hrl b/src/rebar.hrl index 1f051d7e..4540b1ab 100644 --- a/src/rebar.hrl +++ b/src/rebar.hrl @@ -22,6 +22,7 @@ -define(DEFAULT_TEST_DEPS_DIR, "test/lib"). -define(DEFAULT_RELEASE_DIR, "rel"). -define(DEFAULT_CONFIG_FILE, "rebar.config"). +-define(DEFAULT_CDN, "https://s3.amazonaws.com/s3.hex.pm/tarballs"). -define(LOCK_FILE, "rebar.lock"). -ifdef(namespaced_types). diff --git a/src/rebar_packages.erl b/src/rebar_packages.erl index a3443288..e21f1fd3 100644 --- a/src/rebar_packages.erl +++ b/src/rebar_packages.erl @@ -2,6 +2,7 @@ -export([get_packages/1 ,registry/1 + ,package_dir/1 ,check_registry/3 ,registry_checksum/2 ,find_highest_matching/3]). @@ -16,8 +17,7 @@ -spec get_packages(rebar_state:t()) -> {rebar_dict(), rebar_digraph()}. get_packages(State) -> - RebarDir = rebar_dir:global_cache_dir(State), - RegistryDir = filename:join(RebarDir, "packages"), + RegistryDir = package_dir(State), DictFile = filename:join(RegistryDir, "dict"), Edges = filename:join(RegistryDir, "edges"), Vertices = filename:join(RegistryDir, "vertices"), @@ -43,8 +43,7 @@ get_packages(State) -> end. registry(State) -> - Dir = rebar_dir:global_cache_dir(State), - RegistryDir = filename:join(Dir, "packages"), + RegistryDir = package_dir(State), HexFile = filename:join(RegistryDir, "registry"), case ets:file2tab(HexFile) of {ok, T} -> @@ -54,6 +53,17 @@ registry(State) -> error end. +package_dir(State) -> + CacheDir = rebar_dir:global_cache_dir(State), + CDN = rebar_state:get(State, rebar_packages_cdn, ?DEFAULT_CDN), + {ok, {_, _, Host, _, Path, _}} = http_uri:parse(CDN), + CDNHostPath = lists:reverse(string:tokens(Host, ".")), + CDNPath = tl(filename:split(Path)), + PackageDir = filename:join([CacheDir, "hex"] ++ CDNHostPath ++ CDNPath ++ ["packages"]), + ok = filelib:ensure_dir(filename:join(PackageDir, "placeholder")), + PackageDir. + + check_registry(Pkg, Vsn, State) -> case rebar_state:registry(State) of {ok, T} -> diff --git a/src/rebar_pkg_resource.erl b/src/rebar_pkg_resource.erl index f6bb29b1..59ce0dc9 100644 --- a/src/rebar_pkg_resource.erl +++ b/src/rebar_pkg_resource.erl @@ -11,8 +11,6 @@ -include("rebar.hrl"). --define(DEFAULT_CDN, "https://s3.amazonaws.com/s3.hex.pm/tarballs"). - lock(_AppDir, Source) -> Source. @@ -27,7 +25,7 @@ needs_update(Dir, {pkg, _Name, Vsn}) -> download(TmpDir, Pkg={pkg, Name, Vsn}, State) -> CDN = rebar_state:get(State, rebar_packages_cdn, ?DEFAULT_CDN), - PackageDir = hex_package_dir(CDN, State), + PackageDir = rebar_packages:package_dir(State), Package = binary_to_list(<>), CachePath = filename:join(PackageDir, Package), Url = string:join([CDN, Package], "/"), @@ -53,10 +51,13 @@ serve_from_cache(TmpDir, CachePath, Pkg, State) -> ok = erl_tar:extract({binary, Contents}, [{cwd, TmpDir}, compressed]), {ok, true}; {_Bin, Chk, Chk} -> + ?DEBUG("Checksums: registry: ~p, pkg: ~p", [Chk, _Bin]), {failed_extract, CachePath}; {Chk, _Reg, Chk} -> + ?DEBUG("Checksums: registry: ~p, pkg: ~p", [_Reg, Chk]), {bad_registry_checksum, CachePath}; {_Bin, _Reg, _Tar} -> + ?DEBUG("Checksums: registry: ~p, pkg: ~p, meta: ~p", [_Reg, _Bin, _Tar]), {bad_checksum, CachePath} end. @@ -67,7 +68,7 @@ serve_from_download(TmpDir, CachePath, Package, ETag, Binary, State) -> ETag -> serve_from_cache(TmpDir, CachePath, Package, State); FileETag -> - ?DEBUG("Download ETag ~s doesn't match returned ETag ~s", [ETag, FileETag]), + ?DEBUG("Downloaded file ~s ETag ~s doesn't match returned ETag ~s", [CachePath, ETag, FileETag]), {bad_download, CachePath} end. @@ -84,28 +85,17 @@ checksums(Pkg, Files, Contents, Version, Meta, State) -> Blob = <>, <> = crypto:hash(sha256, Blob), BinChecksum = list_to_binary(string:to_upper(lists:flatten(io_lib:format("~64.16.0b", [X])))), - RegistryChecksum = rebar_packages:registry_sum(Pkg, State), + RegistryChecksum = rebar_packages:registry_checksum(Pkg, State), {"CHECKSUM", TarChecksum} = lists:keyfind("CHECKSUM", 1, Files), {BinChecksum, RegistryChecksum, TarChecksum}. make_vsn(_) -> {error, "Replacing version of type pkg not supported."}. -%% Use the shared hex package directory unless a non-default package repo is used -hex_package_dir(?DEFAULT_CDN, _) -> - filename:join([rebar_dir:home_dir(), ".hex", "packages"]); -hex_package_dir(CDN, State) -> - CacheDir = rebar_dir:global_cache_dir(State), - {ok, {_, _, Host, _, _, _}} = http_uri:parse(CDN), - CDNPath = filename:join(lists:reverse(string:tokens(Host, "."))), - PackageDir = filename:join([CacheDir, "hex", CDNPath, "packages"]), - ok = filelib:ensure_dir(filename:join(PackageDir, "placeholder")), - PackageDir. - request(Url, ETag) -> case httpc:request(get, {Url, [{"if-none-match", ETag} || ETag =/= false]}, - [{relaxed, true}], - [{body_format, binary}]) of + [{relaxed, true}], + [{body_format, binary}]) of {ok, {{_Version, 200, _Reason}, Headers, Body}} -> ?DEBUG("Successfully downloaded ~s", [Url]), {"etag", ETag1} = lists:keyfind("etag", 1, Headers), diff --git a/src/rebar_prv_update.erl b/src/rebar_prv_update.erl index 942b3868..dfb719aa 100644 --- a/src/rebar_prv_update.erl +++ b/src/rebar_prv_update.erl @@ -35,8 +35,7 @@ init(State) -> do(State) -> ?INFO("Updating package index...", []), try - Dir = rebar_dir:global_cache_dir(State), - RegistryDir = filename:join(Dir, "packages"), + RegistryDir = rebar_packages:package_dir(State), filelib:ensure_dir(filename:join(RegistryDir, "dummy")), HexFile = filename:join(RegistryDir, "registry"), TmpDir = ec_file:insecure_mkdtemp(), @@ -64,8 +63,7 @@ format_error(package_index_write) -> "Failed to write package index.". write_registry(Dict, {digraph, Edges, Vertices, Neighbors, _}, State) -> - Dir = rebar_dir:global_cache_dir(State), - RegistryDir = filename:join(Dir, "packages"), + RegistryDir = rebar_packages:package_dir(State), filelib:ensure_dir(filename:join(RegistryDir, "dummy")), ets:tab2file(Edges, filename:join(RegistryDir, "edges")), ets:tab2file(Vertices, filename:join(RegistryDir, "vertices")), diff --git a/test/rebar_pkg_SUITE.erl b/test/rebar_pkg_SUITE.erl new file mode 100644 index 00000000..19c4bd0f --- /dev/null +++ b/test/rebar_pkg_SUITE.erl @@ -0,0 +1,100 @@ +%% Test suite for the rebar pkg index caching and decompression +%% mechanisms. +-module(rebar_pkg_SUITE). +-compile(export_all). +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-define(bad_etag, "abcdef"). +-define(good_etag, "22e1d7387c9085a462340088a2a8ba67"). +-define(bad_checksum, <<"D576B442A68C7B92BACDE1EFE9C6E54D8D6C74BDB71D8175B9D3C6EC8C7B62A7">>). +-define(good_checksum, <<"1C6CE379D191FBAB41B7905075E0BF87CBBE23C77CECE775C5A0B786B2244C35">>). + +all() -> [good_uncached]. + + +init_per_suite(Config) -> + application:start(meck), + Config. + +end_per_suite(_Config) -> + application:stop(meck). + +init_per_testcase(good_uncached=Name, Config0) -> + Config = [{good_cache, false}, + {pkg, {<<"goodpkg">>, <<"1.0.0">>}} + | Config0], + mock_config(Name, Config); +init_per_testcase(good_cached=Name, Config0) -> + Pkg = {<<"goodpkg">>, <<"1.0.0">>}, + Config1 = [{good_cache, true}, + {pkg, Pkg} + | Config0], + Config = mock_config(Name, Config1), + copy_to_cache(Pkg, Config), + Config. + + +end_per_testcase(_, Config) -> + unmock_config(Config), + Config. + +mock_config(Name, Config) -> + Priv = ?config(priv_dir, Config), + CacheRoot = filename:join([Priv, "cache", atom_to_list(Name)]), + TmpDir = filename:join([Priv, "tmp", atom_to_list(Name)]), + T = ets:new(fake_registry, [public]), + ets:insert_new(T, [ + {{<<"badindexchk">>,<<"1.0.0">>}, [[], ?bad_checksum]}, + {{<<"goodpkg">>,<<"1.0.0">>}, [[], ?good_checksum]}, + {{<<"badpkg">>,<<"1.0.0">>}, [[], ?good_checksum]} + ]), + CacheDir = filename:join([CacheRoot, "hex", "com", "test", "packages"]), + filelib:ensure_dir(filename:join([CacheDir, "registry"])), + ok = ets:tab2file(T, filename:join([CacheDir, "registry"])), + %% The state returns us a fake registry + meck:new(rebar_state, [passthrough]), + meck:expect(rebar_state, registry, + fun(_State) -> {ok, fake_registry} end), + meck:expect(rebar_state, get, + fun(_State, rebar_packages_cdn, _Default) -> + "http://test.com/" + end), + meck:new(rebar_dir, [passthrough]), + meck:expect(rebar_dir, global_cache_dir, fun(_) -> CacheRoot end), + %% Cache fetches are mocked -- we assume the server and clients are + %% correctly used. + GoodCache = ?config(good_cache, Config), + {Pkg,Vsn} = ?config(pkg, Config), + PkgFile = <>, + {ok, PkgContents} = file:read_file(filename:join(?config(data_dir, Config), PkgFile)), + meck:new(httpc, [passthrough, unsticky]), + meck:expect(httpc, request, + fun(get, {_Url, _Opts}, _, _) when GoodCache -> + {ok, {{Vsn, 304, <<"Not Modified">>}, [{"etag", ?good_etag}], <<>>}}; + (get, {_Url, _Opts}, _, _) -> + {ok, {{Vsn, 200, <<"OK">>}, [{"etag", ?good_etag}], PkgContents}} + end), + [{cache_root, CacheRoot}, + {cache_dir, CacheDir}, + {tmp_dir, TmpDir}, + {mock_table, T} | Config]. + +unmock_config(Config) -> + meck:unload(), + ets:delete(?config(mock_table, Config)). + +copy_to_cache({Pkg,Vsn}, Config) -> + Name = <>, + Source = filename:join(?config(data_dir, Config), Name), + Dest = filename:join(?config(cache_dir, Config), Name), + ec_file:copy(Source, Dest). + +good_uncached(Config) -> + Tmp = ?config(tmp_dir, Config), + {Pkg,Vsn} = ?config(pkg, Config), + State = ?config(state, Config), + ?assertEqual({ok, true}, + rebar_pkg_resource:download(Tmp, {pkg, Pkg, Vsn}, State)), + Cache = ?config(cache_dir, Config), + ?assert(filelib:is_regular(filename:join(Cache, <>))). diff --git a/test/rebar_pkg_SUITE_data/badindexchk-1.0.0.tar b/test/rebar_pkg_SUITE_data/badindexchk-1.0.0.tar new file mode 100644 index 0000000000000000000000000000000000000000..e5b963f4fead7c9b249dca3cf94644562a01b881 GIT binary patch literal 10240 zcmeH~c~DbH9>;@#94ZPC5wc=%I0HkyoR5^qkq|I)s36CHa!5ELh(H1Y$_7SA(16N# z!5}!s0|EgR9h`tj76k*0!XQjQ774-ta%w;hS(3%wEo*CPW}T`jrfRlc|I_dNy8HL* z{&ao&*G=9@A-j5cyTewn0Lz6yz`>R)G>Aw4cm1IWKmz~_jlpAZAR5GBv9M(W0Smy8 zzzWXkqw53)GbA2~goTI2(>@x1;p0>7zcA#_U_z4#WC|7}I-!YZXObfchbDnU01tq8 z3P5tU0m&p11%oAnAelm@fFPcXcLYeF4S|Hg;BaIt{?D*RXpX;5AoNHKGc1P5uwGVy zbwtXqcl;eKA2j|y^p7C`xPR-PK)`?m!m|D`cr*sm|L;Kln}=@Ys>5c(yu4w`6Mlz} z^bj^Yi;$lp-(E%SFRg-a7rg|!!w;^i9HhoqBF$HQ!P~<7s>CzCa@z>2FSp89G8P^j z+n&&=+{H^8thIe|Zs$|peOuk<61Ck~rmw~Y*KU>H^@y6TZgec`rC&*`nTV-LZj1ib z|LLR>v#6bq-xQ{6k#<7gx5+-I$3(FU9q?1oTl#f@-w+nvcYj@>G-hsQYMS+;yIIjh zog0UJy_h)2m~Wg`h`m!Rxuww}_xfV)Lb*6X_Ee2)Yx z8a-a3NSqs|mC1X`XQ>Ait?3V~URt8-%B1FY?8LjBbdm6t#@dDRWtxBeo@PBgf8ZZn zQ_-nwS3W;jY>`JaF%OJSXoYh(TruMb&gWU9j2Kxml}#pUxnv7H)X>cclx$}$Cx4&5 zU4Wref|_T3+5UaT=T7FGv}xBg*kGZh>YHDJnkn8R3pDO3ndE6IY*19M1*D89jk45E z@#LH3X8ZQRWjc26N>qpeH99RBWH$c^tW^C#$9o?CfrO`Pq=LM3AuCz=1}|e{0$sPD zQATTl<2D!PiodJ08&>jv5ga_B#J*_NR<^WVsikd?>$U+(<21_OY#1-I-g^_tMO8SR zzqpl1IQhdauou+M?8V~A@{tFXc zqS>xF>+UqIZ;r0(KTGOE1TBsq++5$rSlzK+l&ID<#`8p8VPE-l%Wt za#9UcqzfYFP|j6M$7D~8II;~kG+Wq|Ce&N!peju39D#jY^wvf=Yg~0B(@%NR%$9+b*-J?8JK~wB4wzSI=`3_37%SwMbgO@%7mLWaUQ;hb7nJa8>)X0adnu z>VTjHbE?qUsIwLuLu){dy#8#V(YvK48(Pt^?ANs{(9F`l-)Y7s+&lM#`pb0z&(x%doVOx+P;Q!V9)*C-*!Crukdp?gZ8#+Q_e$k zn_LRu@JbKu6p9-o^lt>WZRv$4c6>*BG0_?9mMZdTdA#Sxtra6x;=m%s*c^-h%51x` zoI4$)$B_)zMTo-XzK1PQnro)!!b2qsw|aY@Dd=i7qf32QsrI=-x$r0d^0d|I2wVAu zJ30-Xv5~{`8HfRSXld+X{JauJG?2=txR-_DeRrH{je8=BQ>J^IzIo&kiN9Ss^tmr_ zw>zR4<#?l7Xz*-$u`vY|qpoKWR7BDk@p^q}XiULw?X>rO9e8-WRN--Saj}(tH`!mJ zU`RVFAD{LS+BcTZaP@-E=)dV(!!P~nfj{AN%uB0G`w68e#9fME!EpVws7Oz!}Nru0EVr{GX{TNzU-Ctpj$5QIaD?Jo~7kC^hlv0Or=^q6jLaH{yHo# z!>}X^&)&Uu8;62)mSVDGLS5GdpV<21ZD#z-@FiWM&)(z+6RrU1T0ZFW_Wa*Rr^JI* zJM|dpCd`2RW0&{IFO5nA8mi@~ht@lHG#4GrnG&$9X3MAQ%nCg|953GTCRu}%UGEUQ zJs=BbbPJc&UBOaoQCs~OW9ig9W>B&Bnsbf;{!qt!^&2%7pj7}f|IJ7BigY|2#tfl_ zFhi`DF9PB8h!u(PiTn=$*pK-ii^gF-;@#94ZPC5m+@ioPnX<%T0+u@(36?RFGppIV2nrL?8hHWdkE5Xh3DW zU=SSffN-hk-~>dnC>UTA24MoSNDu~)Qv-6yk}TBjT3b^y>r_oKRkQW_pMLN6>;853 zr|a9lZfF-3a&q@@L9Ac_mI{G@Lo8Jw5fA=%z3c)&07QToJO)Pu2spx$9|Qz^bjzod%Ask2~KoAuVP)HB~2Y_U% zgFOL<2XSPGN+E$(c#urSKp++ek%$0fMa1DCGNArvSR_2hUnLN9G?En@$zobAslYNc z@mJgZ9>=oA|A+oD1OWFh{SycnB7v}^e+(YP!216^%zta&Z9G-POt8BLLUG*t$kA@X zhUcNO)6kt&w7%j>cT?%99UJ3TqYe%aL|Z=}tR zA--OS8(_{gOvyzaiRQfG2%$?|A#c7+6e@kD!m~Dh*jm#mYjgcE9#cBn8<;e*dYTq7 zRwR#`9ix}Xy31y02jwj(k1Sr9gS90RQyWg)y$*&@@LFx{{Dl(rzkW}*oSHlM51z5` z^!2NsA1XA=ZHYEurc0GJ8L4DJW;*D>TcK#_t{QI6eq9^i zt@g1hZaF0f_8Xo*m3@lTrmnZaOhef#rwBb=xL4|D*jY4Dpe`q&Y3}nVsg)$$Tr<&) zZ<3Ya)r*vB*}N}OvhuCgYEFeX{HKUw)r0LH3iuDj1=CH&o=E5w|cNI263O+9b0>%|Mmn>RK7I!E#x9)Y?-cN0qLi?Bu;iZ=QZlQSSa{CLH zwpkHQ{ji(ZL)1*`%yHBuMyMZ97;cP7-EUN!czZF;Oe}D$HGFGLRueNE-shCwCpM=? z$9;JzdS1Ae+N|niRkD>e;>BCl^ySk;4~gM5@#(fv1M%+Zm!{1(L;KDtZ-^i2yEyJH zoavmk>`K=7=GeNvbCh0v|Anzbo9kMctJ~KL<5W6FIX0s%l$Q0PYg;7WL!aI03IC=x zGf7WLGB0!p;$FkFPjp9$!del7GkJ~40-bes%92Eb=+5*slEt)M!?1s0$}x@4MrOJ3 z;j`I~>%xO3ucVb%#!k#X;oKePmfA)~Y->QW$CNj+ycIW1Z^_LZ7E(sTG>461AyrOg z{|P;#cjX60!o@@=b*nPBYbWPHMTYB{TSp&L_&X$npL<#D zanWx=lW$fD^qx;GG$f)URdvk#^C@b>?r$y+j>L{zGuj~3f~ zYjC`oThBh5jf4JLi;?M4fwt4UXH;F`b`$;;p}r4zG7;Z^}QEIa$iKm?@jAHOX^bK3+UzjnaC1o1Q^r zn{PVK;5IJ3tDLRUtg`wE#@xPXRIhyBb%#tn{NeVws<$d^KqD7n`dd%xm1%b*m=#D5 zWCdC-T?9fHp(`@TC-Of4U_a)6EQrG_=YJd?59j|C*^HkBrJ?9YBTZPSpum`5)UmKY z7K#L0-T=lzex5wXkfe>@%w>wiTz<=?@v yu*&}k%;@#94ZPC5wc=%I0HkyoR5^qkq|I)s36CHa!5ELh(H1Y$_7SA(16N# z!5}!s0|EgR9h`tj76k*0!XQjQ774-ta%w;hS(3%wEo*CPW}T`jrfRlc|I_dNy8HL* z{&ao&*G=9@A-j5cyTewn0Lz6yz`>R)G>Aw4cm1IWKmz~_jlpAZAR5GBv9M(W0Smy8 zzzWXkqw53)GbA2~goTI2(>@x1;p0>7zcA#_U_z4#WC|7}I-!YZXObfchbDnU01tq8 z3P5tU0m&p11%oAnAelm@fFPcXcLYeF4S|Hg;BaIt{?D*RXpX;5AoNHKGc1P5uwGVy zbwtXqcl;eKA2j|y^p7C`xPR-PK)`?m!m|D`cr*sm|L;Kln}=@Ys>5c(yu4w`6Mlz} z^bj^Yi;$lp-(E%SFRg-a7rg|!!w;^i9HhoqBF$HQ!P~<7s>CzCa@z>2FSp89G8P^j z+n&&=+{H^8thIe|Zs$|peOuk<61Ck~rmw~Y*KU>H^@y6TZgec`rC&*`nTV-LZj1ib z|LLR>v#6bq-xQ{6k#<7gx5+-I$3(FU9q?1oTl#f@-w+nvcYj@>G-hsQYMS+;yIIjh zog0UJy_h)2m~Wg`h`m!Rxuww}_xfV)Lb*6X_Ee2)Yx z8a-a3NSqs|mC1X`XQ>Ait?3V~URt8-%B1FY?8LjBbdm6t#@dDRWtxBeo@PBgf8ZZn zQ_-nwS3W;jY>`JaF%OJSXoYh(TruMb&gWU9j2Kxml}#pUxnv7H)X>cclx$}$Cx4&5 zU4Wref|_T3+5UaT=T7FGv}xBg*kGZh>YHDJnkn8R3pDO3ndE6IY*19M1*D89jk45E z@#LH3X8ZQRWjc26N>qpeH99RBWH$c^tW^C#$9o?CfrO`Pq=LM3AuCz=1}|e{0$sPD zQATTl<2D!PiodJ08&>jv5ga_B#J*_NR<^WVsikd?>$U+(<21_OY#1-I-g^_tMO8SR zzqpl1IQhdauou+M?8V~A@{tFXc zqS>xF>+UqIZ;r0(KTGOE1TBsq++5$rSlzK+l&ID<#`8p8VPE-l%Wt za#9UcqzfYFP|j6M$7D~8II;~kG+Wq|Ce&N!peju39D#jY^wvf=Yg~0B(@%NR%$9+b*-J?8JK~wB4wzSI=`3_37%SwMbgO@%7mLWaUQ;hb7nJa8>)X0adnu z>VTjHbE?qUsIwLuLu){dy#8#V(YvK48(Pt^?ANs{(9F`l-)Y7s+&lM#`pb0z&(x%doVOx+P;Q!V9)*C-*!Crukdp?gZ8#+Q_e$k zn_LRu@JbKu6p9-o^lt>WZRv$4c6>*BG0_?9mMZdTdA#Sxtra6x;=m%s*c^-h%51x` zoI4$)$B_)zMTo-XzK1PQnro)!!b2qsw|aY@Dd=i7qf32QsrI=-x$r0d^0d|I2wVAu zJ30-Xv5~{`8HfRSXld+X{JauJG?2=txR-_DeRrH{je8=BQ>J^IzIo&kiN9Ss^tmr_ zw>zR4<#?l7Xz*-$u`vY|qpoKWR7BDk@p^q}XiULw?X>rO9e8-WRN--Saj}(tH`!mJ zU`RVFAD{LS+BcTZaP@-E=)dV(!!P~nfj{AN%uB0G`w68e#9fME!EpVws7Oz!}Nru0EVr{GX{TNzU-Ctpj$5QIaD?Jo~7kC^hlv0Or=^q6jLaH{yHo# z!>}X^&)&Uu8;62)mSVDGLS5GdpV<21ZD#z-@FiWM&)(z+6RrU1T0ZFW_Wa*Rr^JI* zJM|dpCd`2RW0&{IFO5nA8mi@~ht@lHG#4GrnG&$9X3MAQ%nCg|953GTCRu}%UGEUQ zJs=BbbPJc&UBOaoQCs~OW9ig9W>B&Bnsbf;{!qt!^&2%7pj7}f|IJ7BigY|2#tfl_ zFhi`DF9PB8h!u(PiTn=$*pK-ii^gF-