=fYR;%OS1w!)Wn=5$
zb;TGw@l)tbZF>B;VXu&z*|P-E0fJ$mJ64~>c=-jM=8B=tT1}m%O>b)3X5C4cHR9wY
z&uH-8styTJgRR?tZg^AI@XQ3va7TKr_R>LNGpH*$UPuV1Z`Vgv5*k+YFim_DBu0qQ
zp!ob1kISOzw^En9S+)0IJWIjcdi~yqEtE*bpllz2(K92sxVPcceX2HYXL%W$
z)_fGEMA}nODkf-jz+Y-|Y&8_&6YDYEC^$*@gr*X&AQm<=?340!IE32`N^wE(MbT>-
zY&^Zh!^E_|eylz>e6MGjEB3m+1W-Q&?_iA|>Ot_Ba%;=(0B~CK5UBmwRt`(YD1
z1`NL~j&C>r${ZhITsp+pi&e;7k4}%Eo;?j3G+Sy5L}-JK$(PJR>H?=kLYjLu3*j};
zV@zYB-SEF8q@AM3e)K_#Q|r0%^o1i|EpyPQ&*hg>~~1dN#Cb^n?syyO50!nZ}6PKxw(w%vB4JrlS41G#Lg%WWaF#E2KN
z%rB|wX9t!Ou%y~aE@eU*p_wlir{V^+N*tj9n6OFO>?>3G6=|NBB#fD?CaIw*Kmh_!
zKdOL_zIVjz1&k56Z)flFg>QGPnH#%5N0IeuV8rN6zaw!skP%EXSEFu@k%mSbP#IQD
zjDPPaLMWm3{9g0z^Imq{(TZ3s{tPgtQzW^Yx^J%#Ar4GR6MvCY+~?IZKa)9iBpeDe
z0nk}Pek7Ha)lz@_Bm%|5qwT6}$EmxSNoGh=4kEUEt@Nop4@2O-13TzT`({MTp|l6;
zhlO=s9+9)<4aZlXNPo|5=-OI21r2>>jw428buKYzvm4Kpd`QLjUuvB_O4N;qG5X{OlTOsM
zqsnfHMPln)#4kcqvbl`3X+_hXmsd__krrl%@_xpGGEYjS|H(uzveHsacK!Jg7};>H
zP)63Inn681f8JQYk%t?WweY9gf<~72;!M`2gxLhJc{Sa!Ohz0K!nH62}_2Eyx9aoA~U3zzs6ZOlo8pPw1*9L65oHMMXe~{#L?#4!a_<}NaN3v14rsg@#@o{C@uULj(CD61)x0@tn=PQWbc8
zb=D9wY11`<$4UZ?qvDx!|8EOpTYUVO@1H&Nf;cTVW*soFfHts}>I+@0OB-9=VS)sO
z`c8kr39Uk{glAjo0O`N2sxh-lDDsQtX!N(MOX1QInc<(kDB?P_%AOe9gFB8=kAlb{
z28)6Urb_$%a>H4B2|~AZdqxeuJMuhBHinJB)5Xv+-UQe07|mzRgqPU=zOMz#q)YBC
zXT?NR^i2@FiSj-)O{#)|8Bct`XVo{0S+v913Ptf~SiRBH%#o3wcTQMfP}sETh%$xb
z?5Z2Y=V&F^Xli&!^5qM>l#iuX2o-B~x1}A)1>QtW+}iVcLV?dnJk}i~1n0lfq0)
z1vh6ffs4fj78-e}k{k}u*hNF7p)IDvK7Afrk)D=65A!anhm8Dd@K3?xLv2kbOYi{|VxVmqVu&=nf^(Yje83ps9gj~m=zS@8+gkL~*`3Q*ygTzTEL+$UX=w|v
zm3Q#V=h?Fr+Xo0$eY`zWS~7UAHDczhh)%|P?p0{XNI!}>1}VPa-AbJBx_lR)6ZokV
zLdbW!V|IN!1_p0B?(bT1bxjo4ZXR9!LIcTNC-5CwLS1OLV-OAm~K|o
ziL2F_Y%pE}RAV6+=Zrvw+s_4Ydcvyo>!NQ@FUW;jn4^zON*HuM7fbhZbCwfL`Eg(^
zGY69InRFF4CX7=-Mp8N^3>wf}Ipa7;2(u!{G}|a>BQ(*fC4xn#O2;e&>1w_aPi=2w
zr1YUa)n|J+>l%SEXEUu=&3M@)R@p)&Q%HmS9(S;)ebj7V`r28Tak8$>^;xp)A}dB@d4uPL>^)m!lZ|gQsB@)ZaP%Im@8ycI-7p8RCYmQ|rn(2!
z#jhrt1pV)&Pqda$V
z8{T63@feE52}c%RcbaA*dY6_dDD$v8&nM@ChQhLXuXZHH?_f<}?1cL41YYv5N2MB^
zgA+UDYLS4YZSC+i$9nS`T^ppsxkJLy)rP}O_;z%>^m^zSEvd>J1xI6HP2Jhf317Be
zEoRvtGuUB4ZDfY{3r@yF=(oH@I>N=gGPdnJk||hOVoSX-xpgON1QtbO^NnPEmzR-{
z^8v2Pi^AyGsGwsh=DSJ4i~RYP?Ygpc%dl#Z)QZd2&b=)+0zbMvK*n-SFPui97FtEFP8pRLnW0BXp8O4et2~zs9aBu1Frlu
zYds^WwkZ0>3b#P?-^Mn;Pc(~%TVfIfRiGdmdCmuD@JwKu2QvH%V;RSSAd1iT=PBX}
zh^8S&a2prz2e@oOeI#-)b-L9mfk@DQqWWFll85jlyQ-N4I73~s_Y^Z=$wevfPWdXv
zju+2Z0r?VFYszyPu}ldqOw4DW9}7g!>#`B4B}tB0?oPPB4ssYT;#xD$C}JF_Ta!$3-ylqskYm3Cr?5GuOaXw8YM+9@c-3Z5f<38yt(bV;|ivslX~Y_l_)
zHq&eur}51T3^;YveNLd~8y=o$!5vB~aE*}z=Lh~t{}xp`hPU&BdgQ6f1Ns`}wS=2k
z^q7P`sZb&lHBLgn!<8a42Csf2nf0cCv#ZB1m!*>9qT-&+?MGhG@*}r!kmi%8bEFx(
zpK04AEl6tM@{8`?akxW226Wdq<-N2fM<94j=t9Fl&2be2eV9TO=ds|-(-V8+rSnW5
zlY>U7pOY?_nh_}@CUp7dbVyx7KA9Ci$aW2uCwUE6C!D?#9mO$~HkXWQkYVG6+H)tt
zic7^e-c0=tm;|ZolZyfd5kUUtnz~x#9fSbAv&0ubHh)-vR`@lVaP(wN_#6H2a@zQ*
zFTN^Zb1DMkOKSII@pe1ovRWWj7p^51q+(oXQNz!nt?R2LP^;~&qOn=ga3MN{dh2uI#(~R~7st_*-=du>Eeu>?TjqJ3NkLtqAJBc?y&As@L<^h_
z2YJA)50>N~MY#M{FjV>-T)y5gNvRFzuBABWb+WUAsQG5bo+fgZQgtz3$WH#Ia{4wE<_7|7
z>GEIfK9Q{45kY9m2hrm5O-)eukJHAS3$!(>;x>YU8b6dW#bBB^fNLY1AERSv3eHVm
z&s+C3QdEE@^#e&RjAhWry-p*VTO>>=Q*5{%uxCORSGUAlFtu6rq*iV|3+(!qbY)5C
zuP77wzk%OY9@xG%Kix{og$uME=jmRMIma_zuH{o--~uu7^bb}^CPkf;9PAx5hZc3T
z{6wuQi;Kb5iCznXEA&nwb#~WhCxQENxb_Q$%-D!liA2rj3hiVaPQ1kLl(ro;mq%^w_Q@i$bT8B`_i%0vDPN*Y8
ze!VTwNP!JdNHFpNQds0Rqo@VZf>l7FKC(KMSF6V}B`*t@Y9UtF*FXst&|`mcEkz#v
zVr1|vyl#UQhLKzG{mWGnTy!(i?3bsVvXL%djyl)cj;2%LVgT$7a@eG!p
zu(L}KTYndRf+CNrIn;*>qdt^unSOD|?Xy3D%~w;Q=R*J_{+dD^7K-qa~nX
zrY=b6Ydz9oGM0StO30=NDZOn8@h#j^b-++j-QoQ;#gKwz^8KEJtOLd)iPUyjr;IB4
zl)=sUWevnC>>g5GR6W{5=0~|RPbAN6OuN|lJ0=SSto0G%nY&Gi1iDEr(SG&ZDzX)d
zSCZkjl-T;vH|&?Ip1#JBx98F2+nTA6Sl#?F3<@cWXzzT5iO5w|CQf9+gxluR8bhF#
z&dkG9C9mSjT2_R4?`Bn3Pk4ta{DRmv7Wu+<``L=iXod{KT*iyzdXdtjlfl^XzRDDh`yN?vCPBRi?_?6jF%Y(P
zeyk|rdraq%K4F_{YO{Ma-5C~YfVgI!S2#UXZnjB)m|9q}yeaX*o-xI<{oD&gdm*5S~{xFHeqfpTlKkg(3t!Cl#)X?u2aO_ne
zdOUE2K0XPBVb6#HkTpzOj0yIeyCvfF*{n7+%wPD~u=23Y8H5Hu4B>~bdYuGARDtR-HOn{ptSTQ#CS?<5j+)v2Shlnj$!ForMl7=J>*)WA5vn$
znhJxEe=kCg7~@IDgV}|j=o{V};@tr;?+CfhMW?eNoA`#Wu6UGq|EfhcGtJ1v7d53o
zWB4gY&<>>&Sudn?w6DjOQBJZWzILjuI>Ie|vw&gEB^QTZao0mPKQ`@l-)8Sf;CdV*Qft@PWsw*~R
zrJ}9eg6sDIV)@B69w`RNWwfa^J70=1`wWy9%)n}4AIri^tLe$Fsi}Yrk$Xv=py|t7S*!1Tp
zvfMM0lTMzst`5mygZCfYogxllxUyZD(NetBpfY0Kx})>kSFt_)H80Pu6;doywcG{m
z=;a#GZH@*Vl4n;A2-&rmvbZci_g!xm{WeL0JV}aBQsYTfnG;Cg;)|V8&Wy5GP|Yzs
z7)Z>s4*M2VS~PEOgEU0~xV_wB
zDX629icpCrSk`ecI3k-y!O-I?GCAEY;dtnC%9$trDRa
z(zbIl8Q2qVN4BIu*V`UcehF0BRp@Lbx$wtYB$1@7O`-0}QMg|4F?--KDcLtid=P$d
zntsLa70}6&5;J@+NxvrchXikvH3+`1DE*RAVK8O1xkgk*
zS_}2;FCVpu&m_n_yHzEeH0*|+x{vdo5eOKz1$PX_kfYr3$-qQCin^{bU+dtW1`jde
zShX#%Cli99x;lQwU(BqkPfoK2j@>~8Uo4Xa{2u&>}`6@z5jYhGY4X@v4P
z>7dA0R|T@h2NOn@q!;bPNSE)we5o>JFuY=~`tjhkeQyD8HSLn~t=$f5Ld5LxAxW@=(m#UJ4F!
zaVHsL>vG@VSL7z5*PR3FmUxKk&b^?kBbvGL5hxfnj+f{N3&eH~iwYIGQ3pR+rwpqP
z-Vmp_a+#McR_!GP&*EBrVw=BNJWkaXyI$;PHVaOxXO?|q_T;-TQ$Csbjrpla=o%w=
zAL|Zd$_+O|oD)Rap|YLzcU#iC!Uhgi^H`wa{{u(UQ_0hiH
z&W~q+;yok?i!QK6h+a;cdyeg1
zYATOapPe~}dR0}LRMkji1%^TDk-Cm7qytNJdjm=cxqe25BK7mcRjOREhlCj_?h7iv
zrh~jHvg#HMl!BB|Xw!7c(T?bV`C&
zY=1lMjA-kLX<2)%hqt;^_%gBc#KAkPQ}b0Yv@$u=R
zFWp)%Q@d6Dnv_|7$p?;fZNEbn=?s!V?0Dy@A77BQp+dje>QvC}-nHTZ-;?ip-F
z=gZ#3{K|9jbfL$fo#kjL9G472%O-ewGD29(Sfm1lZMc{9;EQRNw)~M>gq&A3#y@zdUYMk@Q2HDogT`=vt6?WcVP*Wf@};c5-O
zaps`7cT*?&V^!(8M^p?|xKc4XA-W>_tn`?@
z6{zkOHOy4Fx!82XyL-$UsK|*Xy2wISpohUcK~ojHYP1xVXdo0{OG4&0`lq|F`$tW4
zATM;yb@|mDT|oO6wo-;iCN>>>cQl|AEDN)uGB~1rfQssa1VmJNFOkd5Sty0+z_A*Z
zz76kaFpQZRIJL0=J#S}z);rUkPbc!h8_$-n!iQgPtI4V$+CnvATj05MOq0ojVFrM+8O`vjHS!3x7}UBE!}MmIeDuxMF6xA)8Ulize$
zkW`@XO|NX_SFEILAG8}wdbEh;2{Z1Z{YV{(5SkxLW?^vq2~&RQt@8L0atU*g=-0C3
zIg8c}W-&Fm%ji(2+nS$of5itFHCOm#20TqkG|q63<#ifFhdwsTJ{KM9_wQMVOx8#c
zDdCwYuo>^uwf6m3q^imL!E)j7z=NLX4}-z7hm6uIotR`(qHZSY=_#*J3;5*&s|cm?
zpvN8Dpi@Fe2Sj@j$=nReP+9i#giQZ%R3KH{%J@Rt6gkd5SJhquJ5a@Ej-{lYY|ufY
zoBev)aLLA=Ynwrk)wm01Is2kD>|z2`IE>dlgDTCU9_kdNk;%hHq$rLKm?`NCgW~
zQz@B`BS>aH-e|&uq70v7a&~HhE65yIT}KDGTiNz%F2*2;>PFRtbI_a_st-)`G#Ztr
zN|rMcq`eejChDIt$}e4D#!yn-?lFq3wo=bTiTDakqWa173PYseiZ;Kt#XE9|Eqa=?
z4;u}(XbO%R`jlQ9|LnAQIC-=*X+!+#)rTSz^<6^DI29OdTw0<_K=^iH6xfFFSMol5
zzmU}1P@mh}fX`gKG8SqoCJYPfbQ-OjLl0PUe>!cHMLyIm%BY_}a8^hQKm6xXw?h|b
zI%$bKPw}=!jPIJ+*jDTCv~ACH!H}r7>YGBZU=TWYkTvRqR9O^mX!YsJ)s3&=9uNr#
zm-0gkydZ~zAd@N)=AhPpDX8zY9@yHTjRY<2sl>dRJnzAKAhE&?3ZjuN>M4#f*pFCofw
zBe*?b9KsKoA%)(qRvGwthuGzK#lb#oQ)pwk_Lvt|O8wf3!Cq~4-GEIaeK~o}7_D4Z
zdC}$d4!lkx{Q)AkJ-{Pa`sak3K7}ozJ=0RHgh`il+)-h&C@E7FB_wDA`LB+hivaeo
za6hZagu>@JJuH{}zniN&WIz|5!Vlf8+${;fhj3$j39x5CMmFgDwkH;6E|tnM=0FW9
zik|);ckDoRIQV<(%Q|qPc9H0qUsc&%qwk{w{!4=v4a2J^R=`Y3CQ)G@3{NW1#k&vH
zk_GN7qBTC(v>F+@U$`Anr`|-k+B>$dCBD)4s%VI)UXai2EKLkO7MGUW$+51I^~BuqW15Lb6`g229^WyZ*u@Nc=qnhzNkJB;
zAP68iWL@B2enbJ;BsU_a9nD!2W=RzoO6`I+KQcR%x
zcHu4%oyum3!cN~x>FFjmm5>ns13%+5D=Mr1LaFtV6)Y2dLO)~PGxCnq?T)JN0ruh&
z55)hIMDp!|U<4_}V09w{Dlv4yo1C@3HBN~3XjY(G-7YGd5ETI-OLphPt_S}?d48mn
zT;7Sv*HIVeDU-{cul9F1+7sI;N5s<_Ydtf28pMQ%5?`*hq7rg#v$&?}g~50d6WUWj
z3~LUYgl!vNtKnmjp3b4=cY0;4*2eI+L;J+$h=e$xTMonFCYM2kvgFOw(PFaVncOWQ
zoIev;aGM5n+T5!L>#Jf|$Q3P{(B$UJFN0A9y8Cazwz<-+B9s&ESxE7qzZkSDfBUS)
zZOaUz!DF)G^s(P9{_&BKO^|)BH4pg;Mdi?++^^6U^0Z=Mbz0ba;g}GoDvzcYi5K#K
zmoWBi4M8XA=RpC%5}fjE@}`JOJnC^9^QT~lGXaja@|$J7lwYNF-);^EPQ~f~7HO
z=p{$EU2B&fZ=($boi}W-?X@1(GQ~N0xrHY4?&@0!Ti6hVaj0=cjxc`T7-X5tB#7D7
zqQ}rG*#!=SU}40!1c)MWskRd+ypg_oJ{h?}@9{J@w*pyXvUS;@SZ+DrQ<{4|S$DSD
znQ`D!wsP+89Fn-yG2@WdiplO7DZ#th+IXN^-w($_@Vb$Vgd%D6FM1xDR$Uk@znEm{Kb|1RtzyJR-hNw*b<=TH=d$%iDs|V;!%$AA
zD?lrf0kyFA(P(DZaPJKdxT{$NeRoAyG{>;IK_f-JQYPx8=!D=}uz#hIPTN@lGeU{y
zaaim+;p7Vg&(X-<+;^grk)lmf&X=X);Z8$5JFV&$iV^pX%n$?Tx}U2j0e@#&Y5$_f
zW#J^lQ^9M0whcQ0pA=l#^4@=u^wSa<;j8jx!>oL|)Tc~SC`vGklcUmGsTYA~r4EOL
zv?^!RjR3(~IJp&U2F`)f?od-!u{S?1N&Va-apz#N)o+wUi3EI5k>h=Uzf9e`&jf7^
zunyrP(S$2&NPx!a8dFUY`SF=LST*Devs=lhZb5T+s-~3KCV6=>qt6ie?b)$U`J&
z4tA86+-L2TzH?Z)MqH-T0+lBdw_xVPNf4n^^-r#dzY~H|bfHU2AcgAnR(YR9uz&R0
zvwL=SKA*O^1@bHM=1U>x+!sNCWJzr?2`0Ma0@nhG&eMN)n?Qj0Vk+Ru(Nv`)3o;DB
z9TdAiZEPtC_~pm9AutJWT(}?E(k&nJ+Y$Gd2#Ke^;d`=cf_9aTI(Z-9>Y2-CC$*|4
z4VOf&-bEg*Ajq0Aiod0UsI8>lCborK?8)DxFjU#?h(DR0Vc(VrZLYVZBnGbyx1`ol
zE%QT#h*oh1bQNvn*p^9mfvL6Z2_|p8NPh`BTc
z2&zI(f($4&;XKrdDnu=yso%g$d#&eE6zCmOuf4DAzW4Q>P36#po<8yt4&CFYtFKmM
z8}*4z*q@1q`q%W~6?P%F+J~8cmDx%o*C5-QkAk8>Mmnq3jMhW#pEr(pehYSfCvU^~
z0YQv>J93%#!`YjnOF|5w8v3$Hc
zOUfJwd{8Sle)ZU?t1CJp<|cITWRT}nd-faitMp~wH4bSC$Nc=7bUHa}=xl#d_^
znpQzRKTHO_te0%5Zglh5l>&LFBTH8-xvhPFTi?p?iUUnIM%KActi;o2B$Q|`u!Fe7DKw$bbGjJh8FFrOjM
zlXHuJIL2Ob=X@*i(_P>vqC$!jGNv`Iof9uzWVK|&g&kt?0!!Zuz5$RrpC5xONT6lS
z;ro*(4vFhDs5`5Bn8Z$lae<(}Z}!~Q6wW}G()G*;t)X9-z4g~8!_hQ`!TKF}$}2$l
zS2H9IT^1afv@%ixb1I|fDhH4Y_KY`poB9(!LtIJvNcvh=pt?Q)1;8k&CDnKiTqFeQ
zRI18|-_Vj-`rgZ!4MZII&AnJ5SFA$GTh=;>_Gbi8|8`uzde-nMn5$l81$OcfWH0rc
z2`dVP)w(}%Jk^YLqw*=i<3^-Kojw$)0eNORwgx`#h>
zcPiD2eLuBDBOTITVEz`lo0whsY+qoA+D6D~0H%snyDu}iWBv-ILsoQ+x90?ll7P5K
zUF3L?bzXe@IVAQIGvw2ZwMw1vL@NMDi(m>74<}lol`ASTbV^M>RB_o0qBs)P_MnnyL
zyV)RSyHejq7JCB{9xbm}hlFXnpLPPt9b*PtJH~Ln=6UHkuj$rZW(xRQ9{GBU7guH!
z9P>~X0(R4S7%18j*&4YUlR4~V+uDZjhTihng#S0DbQTwLQIP_j3iGfV!rElOU&Su^
z2gADp^aVQa12nlFA31KEYdXXZNlPOK?Pp$Ffvw@Idno6Wp?qKhPRmMIT1O!1855bm
zT+sOQTxSq%8D!D;Xwg)mQ?-_7+&Wb)UDAdRiDLG^9Cl?jz6i;Pl!7J%lMG4Ye*OH@
zhRHJcn!UH>8-gi%OsQ#R&JT>DEzo9U%NWMkvJ??Y*2tD6LYCXumuiy9z9fl|B_vz6WC=a)_r1^a-uL_IJ@2RI
z`gWajUFU!P%k}a4B_B^_-RT?B_^K`s+n06c<7L`$s
zGLpWPYKOUc!`j)7Z?{`_;}jXz)GLBF(qQ@{m+`Vv4dH~~F61Nw7(OeFb%eM@NulI$
zrgc8_uS~&yFtl6Mldrn^wLCbbu63`hq#GnSbx3(IU%*h
zJ9w(=Fr1%p*&*KV9SgFk>TEbnC9K!4an~P`{^t5sIK-6({;@l|y@P&@n}~S(7N5I3
z{cR-2dg9{K`K;Vo-42AG`{b9T4B|LxZhAxaTr<~=&;NEQHUtDc^fKX_OQmGWH^
zW-ntFVgeiQB52WG=*kV#-V0|y6;xUco9N`pdn=EJ+6FgQ!}zx|82V47AQGuMu{7*`
z%w;CN(W+3(M@jjVvRD|@bir#hErN=#*@P!`{INi7$H3uD2&
zCG@*`0u1#viy-q^hw$+8a~PRWLJQDphqH&b_-XnuO(9y&|R$2n9J%aqKRQg)_9
z#wuThm@7G-OlukN(NNk@hggiw;&``|PUdNK6ht+>RQ7)TExzH!A=;719KPIC^33Ot
zyQ4;n9`95S-6A6LZ=Bx#75p?_zy_te(knZVOJ2jl26WL;eT%=Gyuei^YEcMxx363M
zfd$JSYK+TU9vFV}Uel@+Te#mvdpO$ETa0SPONp>vSPZ*vCr7{-|C3nUc_XXUVPg9N7^_;Ks6>{f3E@MeUp+zF{
zx__P1T{T|^>!}m#4x4S`2Tt{2uv!rNZ
z@Xk({sC-r?!Ha1yr`q32u&4lPx$~K+xZY1E`-#rkq}upJZSm#L{qzsAP0}^%A5T`F
z?Pht$y~^egse(;wKZMm#FDU~%l!wR#HzOTVU^dN=h6}}(ReQaqS
zjH*VrTwO8ASjrS}guUwCW3g#_qNFHm2oEs94VR?6-pJhYjjF5#zn|3W}(jOIq}TJ0uZ6FyZZQGM|}PtUY$Yor+Mlg$}^u2Or;e3M7Khr7i%FS##K
z0n6yy(GGR3Ba}AlFb`p{biAC|%ZEMQ_n+V_hu>$OgNYA;c-uG!ML@_nQ5-kwbi*9n{`w=qV%a7v0W%(|Jt`*YBA9OA
z;mc6|%i^9!olIB%pf{sVU;GKuR!O{>r|CCWyP@L0Kg4wU>Lp5}X|~5nTR|KTPO0yJ
z_FHqkJ$d!KWyBsO9gyWWfZtB=2SjuBrKQ*{z;(1LNG#Z9EZ?;f%Lu9Mae!zasATz4
zQGV!rK9v{jrppzXhr&{}-qi6&rLA9++YgA=^UTg^B=7Hy?&9nqWNOv>*=eU#i_&7r
zi{{ohES~t3;0{3u%Nb8+IQvq_qBQH<$@c|0T5-T%9&EmbQ`ny=pC@Nz^zu#o-75jLv>5j_Eduuxr8q<*quN-p%)}1yCgCyE!HaMxeQAHgFH_>QW1%Kv
z&G%a*qt~q>Ce=z`f>Xjg_L@V^(1~o>yJ>N!+i84cZj)-+NORQ}qy}{0d5%R^7a57#
z_9SJ#Y@cjx$$LsX)?jUFlNA0}NLUIE#LDn7m=v
zO9NMGv6XW5QZTK*Id{zSb!EI2PFxP(M}EGjo{ikmQQ%J;=~}*S0^18P`KeZW$|vrZ
zneDqDFt+|hZ(}iX_nzi#m4?t1-ivq3JXkoh@*9!9;T9p%ABXh+IwCcEAko~C*K>br
z!7+03!VD0SWCF<9r}9<@c!7j;$m&n%wKERbm8q{~l?BJqx629yM7UiG)+5pF?c9sk
zc36*X*yUUP3}HBe#*xFr39>!OOUl;%>jaj!y!cMP`nD+IbKcJWLQ~YjjdlIFD!GQa
zR&kjkT#sw4Qk>4Eq3DdvYWB3LXJ44^$iTzVFf3IKWlr*zQ#ZgO{k60t0NR=^M`RtG
zKH?sDSZsvxDK_t{%!%BMQXUDG-7Vgo8XSjPwx{
z4>fcU#?KoHAP_(Sik611UU(lXZy>@nC=iDZKvJN7s6b<=H3@-$sVO@v*cg(qBqbLW
z{88+G#`;UD1GxhMYI+7nB#1xC3}|B!q@;rOMdN(T02C0w8m4GL^fZA&U{G@?)?Eb-
zF^1!V6aW?&Q{zA^#KjbaKw7|6T|7+mC=@d*14UCisx?)`@2^b%ZU9pLi|hM02`EnW
zA^v|6z<+Ck+L~nWzw38YJ8=q`NYeA7l6;ROK;WP2zfr#b!2tggY(R>tjlYLW5G24B
z2lTZw#1b(O6{z_?`g2tOBjph1V|1hy!bxQ6Q9dLesEsPYm?Y=qt88UwV~8YJVu=6j
z>fdku)m>FZ6C)zeSiLR85^>XeeF@@2w7mS$Y05>QUYQ3T8pTs|C6FtC*os
zazG~{(S)Rc$Dq(&K?-P3q#A@0fcG{FG=PBMK}Tri(L(_IeLnuz_Gn!G22g7MU$D&o
Hz%%~>(YA3k
literal 0
HcmV?d00001
diff --git a/tests/snapshots/snapshots__encrypted__agile-sha512-docx--errors.docx.snap b/tests/snapshots/snapshots__encrypted__agile-sha512-docx--errors.docx.snap
new file mode 100644
index 00000000..bd8df238
--- /dev/null
+++ b/tests/snapshots/snapshots__encrypted__agile-sha512-docx--errors.docx.snap
@@ -0,0 +1,5 @@
+---
+source: tests/snapshots.rs
+expression: output
+---
+ERROR: document is encrypted
diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs
index 5a710a5b..1145fb61 100644
--- a/wasm/src/lib.rs
+++ b/wasm/src/lib.rs
@@ -99,8 +99,17 @@ pub fn format_from_path(path: &str) -> Option {
///
/// Throws an `Error` carrying a `ConvertErrorCode` on `code`.
#[wasm_bindgen(js_name = toMarkdownBytes)]
-pub fn to_markdown_bytes(bytes: &[u8], format: Option) -> Result {
- anydoc::to_markdown_bytes(bytes, format.map(anydoc::Format::from)).map_err(convert_error)
+pub fn to_markdown_bytes(
+ bytes: &[u8],
+ format: Option,
+ password: Option,
+) -> Result {
+ anydoc::to_markdown_bytes_with_password(
+ bytes,
+ format.map(anydoc::Format::from),
+ password.as_deref(),
+ )
+ .map_err(convert_error)
}
/// Parse an in-memory document into the document model, which also carries
From 10b633d761ceb0c680640dbf40211b254f061c15 Mon Sep 17 00:00:00 2001
From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com>
Date: Sun, 23 Aug 2026 14:59:58 +0000
Subject: [PATCH 3/5] fix(docx): per-instance override restarts without
sentinel collision
Cubic review follow-ups on #129, both real:
- last_num is now Option: a document may legally carry
numId=18446744073709551615 (xsd:int clamps were only applied to start
values), and a u64::MAX sentinel would have swallowed the instance
switch that triggers an override restart.
- Overridden levels restart on their first USE under the entering
instance (fresh_overrides) instead of eagerly marking pending bits at
the switch. Eager marking let a following plain instance consume a
restart it never earned: n1(1), n2[startOverride](unused at L0),
n3 plain restarted to 1 instead of continuing at 2.
Regression test walks nested lists and asserts the continuation.
---
src/formats/docx/mod.rs | 45 +++++++++++++++++++++++++++++++++++
src/formats/docx/numbering.rs | 34 +++++++++++++-------------
2 files changed, 61 insertions(+), 18 deletions(-)
diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs
index 02c2e801..7911cabf 100644
--- a/src/formats/docx/mod.rs
+++ b/src/formats/docx/mod.rs
@@ -232,6 +232,51 @@ mod tests {
);
}
+ #[test]
+ fn override_restart_does_not_leak_into_the_next_instance() {
+ // Entering an overriding instance schedules ITS levels' restarts; a
+ // following plain instance of the same abstract must keep counting
+ // instead of consuming a restart it never earned. Also exercises a
+ // numId that would collide with a u64::MAX sentinel if one existed.
+ let document = r#"
+
+ one
+ sub
+ two
+ "#;
+ let numbering = r#"
+
+
+
+
+
+
+
+
+
+ "#;
+ let bytes =
+ docx_parts(&[("word/document.xml", document), ("word/numbering.xml", numbering)]);
+ let doc = parse(&bytes).unwrap();
+ let mut starts: Vec> = Vec::new();
+ fn walk_lists(blocks: &[Block], out: &mut Vec >) {
+ for b in blocks {
+ if let Block::List(list) = b {
+ out.push(list.ordered().then_some(list.start));
+ for item in &list.items {
+ walk_lists(&item.blocks, out);
+ }
+ }
+ }
+ }
+ walk_lists(&doc.blocks, &mut starts);
+ assert_eq!(
+ starts,
+ [Some(1), Some(1), Some(2)],
+ "sub-list starts at a; the outer sequence continues at two across the overriding instance instead of restarting"
+ );
+ }
+
#[test]
fn huge_numbering_start_values_cannot_overflow() {
// H2: w:start is ST_DecimalNumber (xsd:int); out-of-range values are
diff --git a/src/formats/docx/numbering.rs b/src/formats/docx/numbering.rs
index bb892cd4..61637a7d 100644
--- a/src/formats/docx/numbering.rs
+++ b/src/formats/docx/numbering.rs
@@ -263,8 +263,15 @@ struct InstanceState {
/// The instance currently driving this abstract (`u64::MAX` = none yet;
/// real `numId`s never reach the counters as `u64::MAX`). Switching
/// instances inside one logical list restarts only the levels the
- /// entering instance overrides (#96).
- last_num: u64,
+ /// entering instance overrides (#96). `None` until the list's first
+ /// paragraph; `Some` never collides with a real id because it is
+ /// compared as an `Option`, not against a sentinel value.
+ last_num: Option,
+ /// The overridden levels of the currently active instance. Each restarts
+ /// on its first USE under this instance — not eagerly at the switch, so a
+ /// following plain instance cannot consume a restart that was never
+ /// earned (cubic review of #129).
+ fresh_overrides: [bool; LEVELS],
}
impl Counters {
@@ -273,24 +280,15 @@ impl Counters {
/// reproducible from the marker kind alone, the composite label.
pub fn next(&mut self, num_id: u64, ilvl: usize, instance: &Instance) -> (u64, Option) {
let ilvl = ilvl.min(LEVELS - 1);
- let state = self
- .state
- .entry(instance.abstract_key)
- .or_insert_with(|| InstanceState { last_num: u64::MAX, ..Default::default() });
- if state.last_num != num_id {
- // First paragraph of this list, or an instance switch within it:
- // overridden levels restart, everything else keeps counting.
- if state.last_num != u64::MAX {
- for (l, overridden) in instance.overrides.iter().enumerate() {
- if *overridden {
- state.restart_pending[l] = true;
- }
- }
- }
- state.last_num = num_id;
+ let state = self.state.entry(instance.abstract_key).or_default();
+ if state.last_num != Some(num_id) {
+ // First paragraph of this list, or an instance switch within it.
+ state.fresh_overrides = instance.overrides;
+ state.last_num = Some(num_id);
}
let def = &instance.levels[ilvl];
- if !state.initialized[ilvl] || state.restart_pending[ilvl] {
+ let override_restart = std::mem::take(&mut state.fresh_overrides[ilvl]);
+ if !state.initialized[ilvl] || state.restart_pending[ilvl] || override_restart {
state.value[ilvl] = def.start;
state.initialized[ilvl] = true;
state.restart_pending[ilvl] = false;
From bcad3b7a183bd0aa8bf8df1d503cf5c45ff8c05b Mon Sep 17 00:00:00 2001
From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com>
Date: Sun, 23 Aug 2026 20:04:32 +0000
Subject: [PATCH 4/5] fix: review follow-ups for encrypted OOXML support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cubic review round on #130, all addressed:
- CLI: a path conversion with a password but no --format dropped the
password entirely; such conversions now go through toMarkdownBytes with
content detection. -p/--password without a value is a usage error
instead of silently falling back to the environment variable.
- CLI: the asset pass re-read the still-encrypted container after a
successful password-protected Markdown conversion and died with
Encrypted. The container is now decrypted once, up front, so Markdown
and assets see the same plaintext.
- Library: to_markdown_with_password no longer fails with Unsupported
before decrypting when an encrypted file has no recognizable extension;
unresolved formats flow through to the byte-level entry point.
- crypto: the decrypted payload is bounded by MAX_TOTAL_BYTES before any
part is read, and is validated by actually opening it as an archive —
a wrong password whose noise happens to start with PK now still ends
in Encrypted instead of Malformed.
- is_encrypted_ooxml / decrypt_ooxml are re-exported for embedders.
- Python binding doc: removed the duplicated summary line.
- Carries the #129 per-instance override-restart fix so the two branches
do not conflict.
---
examples/convert.rs | 23 ++++++++++++++++++++---
node/cli.js | 12 +++++++-----
python/src/lib.rs | 6 ++----
src/lib.rs | 23 +++++++++++++++++------
src/package/crypto.rs | 25 ++++++++++++++++++++++---
5 files changed, 68 insertions(+), 21 deletions(-)
diff --git a/examples/convert.rs b/examples/convert.rs
index 4b7244f7..ce6c1cfd 100644
--- a/examples/convert.rs
+++ b/examples/convert.rs
@@ -39,7 +39,13 @@ fn main() -> ExitCode {
}
"-p" | "--password" => {
i += 1;
- password = args.get(i).map(String::from);
+ match args.get(i) {
+ Some(pw) => password = Some(pw.clone()),
+ None => {
+ eprintln!("error: --password requires a value");
+ return ExitCode::FAILURE;
+ }
+ }
}
other => input = Some(PathBuf::from(other)),
}
@@ -81,8 +87,19 @@ fn run(
}
};
+ // Decrypt once so the asset pass below sees the same plaintext instead
+ // of re-reading the still-encrypted container (#130 review).
+ let decrypted;
+ let bytes: &[u8] = match password.as_deref() {
+ Some(pw) if !pw.is_empty() && anydoc::is_encrypted_ooxml(&bytes) => {
+ decrypted = anydoc::decrypt_ooxml(bytes.to_vec(), pw)?;
+ &decrypted
+ }
+ _ => &bytes,
+ };
+
let start = std::time::Instant::now();
- let markdown = anydoc::to_markdown_bytes_with_password(&bytes, format, password.as_deref())?;
+ let markdown = anydoc::to_markdown_bytes(bytes, format)?;
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
eprintln!("converted {} in {}", input.display(), millis(elapsed));
@@ -97,7 +114,7 @@ fn run(
// Images and embedded objects live on the document model, not in the
// Markdown, so they need a second pass to write out.
if let Some(dir) = assets {
- let document = anydoc::to_document(&bytes, format)?;
+ let document = anydoc::to_document(bytes, format)?;
std::fs::create_dir_all(dir)?;
let stem = input.file_stem().unwrap_or_default().to_string_lossy();
for asset in &document.assets {
diff --git a/node/cli.js b/node/cli.js
index 1e0288db..256a1f21 100644
--- a/node/cli.js
+++ b/node/cli.js
@@ -129,7 +129,7 @@ async function main() {
// Loaded after argument handling so --help and --version work even where
// no native binding is available.
- const { formatFromExtension, toMarkdown, toMarkdownBytes } = require('./index.js')
+ const { formatFromBytes, formatFromExtension, toMarkdown, toMarkdownBytes } = require('./index.js')
let format
if (args.format !== null) {
@@ -141,10 +141,12 @@ async function main() {
let markdown
try {
- if (args.input === '-') {
- markdown = await toMarkdownBytes(await readStdin(), format, args.password)
- } else if (format !== undefined) {
- markdown = await toMarkdownBytes(await readFile(args.input), format, args.password)
+ // A password only reaches the byte-level entry points; a path without
+ // --format would otherwise drop it on the floor (#130 review).
+ if (args.input === '-' || format !== undefined || args.password !== null) {
+ const bytes = await (args.input === '-' ? readStdin() : readFile(args.input))
+ const resolved = format ?? formatFromBytes(bytes)
+ markdown = await toMarkdownBytes(bytes, resolved, args.password)
} else {
markdown = await toMarkdown(args.input)
}
diff --git a/python/src/lib.rs b/python/src/lib.rs
index 9f462398..d858faa3 100644
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -157,10 +157,8 @@ fn to_markdown(py: Python<'_>, path: PathBuf) -> PyResult {
/// Convert an in-memory document to Markdown. Without a format, it is
/// detected from the content, which signature-less formats (CSV) have to name
/// explicitly.
-/// Convert an in-memory document to Markdown. Without a format, it is
-/// detected from the content, which signature-less formats (CSV) have to name
-/// explicitly. A non-None `password` decrypts a password-protected OOXML
-/// package first; wrong passwords still raise `EncryptedError`.
+/// A non-None `password` decrypts a password-protected OOXML package first;
+/// wrong passwords still raise `EncryptedError`.
#[pyfunction]
#[pyo3(signature = (data, format=None, password=None))]
fn to_markdown_bytes(
diff --git a/src/lib.rs b/src/lib.rs
index 02aca2c8..601ee902 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -115,12 +115,10 @@ pub fn to_markdown_with_password(
) -> Result {
let path = path.as_ref();
let bytes = std::fs::read(path)?;
- let Some(format) = Format::from_bytes(&bytes).or_else(|| Format::from_path(path)) else {
- return Err(ConvertError::Unsupported(format!(
- "unrecognized file content and extension: {}",
- path.display()
- )));
- };
+ // Detection is allowed to fail here: an encrypted OOXML container has no
+ // recognizable signature until after decryption, so hand the unresolved
+ // format down and let the byte-level entry point decide (#130 review).
+ let format = Format::from_bytes(&bytes).or_else(|| Format::from_path(path));
to_markdown_bytes_with_password(&bytes, format, password)
}
@@ -164,6 +162,19 @@ pub fn to_markdown_bytes_with_password(
Ok(document_to_markdown(&to_document(bytes, format)?))
}
+/// True when `bytes` are a password-protected OOXML package.
+pub fn is_encrypted_ooxml(bytes: &[u8]) -> bool {
+ package::archive::is_encrypted_ooxml(bytes)
+}
+
+/// Decrypt a password-protected OOXML package into its plaintext zip bytes.
+///
+/// Wrong passwords and unsupported schemes end in [`ConvertError::Encrypted`];
+/// see [`package::crypto::decrypt_ooxml`] for details.
+pub fn decrypt_ooxml(bytes: Vec, password: &str) -> Result, ConvertError> {
+ package::crypto::decrypt_ooxml(bytes, password)
+}
+
/// Parse an in-memory document into the document model. Pass a [`Format`] to
/// select the parser, or `None` to detect it from the content.
///
diff --git a/src/package/crypto.rs b/src/package/crypto.rs
index 54e1be5c..7d7d1820 100644
--- a/src/package/crypto.rs
+++ b/src/package/crypto.rs
@@ -19,17 +19,36 @@ pub fn decrypt_ooxml(bytes: Vec, password: &str) -> Result, ConvertE
log::debug!("OOXML decryption failed: {e}");
ConvertError::Encrypted
})?;
+ // The same archive budget that governs plaintext packages must bound the
+ // decrypted one too, or a small encrypted file could inflate past every
+ // limit before any part is read.
+ let total: u64 = plain.len() as u64;
+ if total > crate::package::limits::MAX_TOTAL_BYTES {
+ return Err(ConvertError::ResourceLimit {
+ limit: "max_total_bytes",
+ detail: format!(
+ "decrypted OOXML package is {total} bytes, over the {} byte budget",
+ crate::package::limits::MAX_TOTAL_BYTES
+ ),
+ });
+ }
// office-crypto does not check the EncryptionInfo password verifier, so a
// wrong password still "succeeds" — into noise. The decrypted payload is
- // always the OOXML zip itself (the 8-byte size header is stripped), so its
- // signature is the cheapest reliable wrong-password test.
- if !plain.starts_with(b"PK") {
+ // always an OOXML zip (the 8-byte size header is stripped), and the
+ // signature alone is not proof, so validate it with the same archive
+ // reader every package goes through next.
+ if !plain.starts_with(b"PK") || zip_check_broken(&plain) {
log::debug!("OOXML decryption produced a non-zip payload (wrong password?)");
return Err(ConvertError::Encrypted);
}
Ok(plain)
}
+/// Cheap structural probe: can the shared zip reader actually open this?
+fn zip_check_broken(plain: &[u8]) -> bool {
+ zip::ZipArchive::new(std::io::Cursor::new(plain)).map(|z| z.len()).is_err()
+}
+
#[cfg(test)]
mod tests {
use super::*;
From 7a4cac6ae4ae79330a1409b390d0d4fdf62c8346 Mon Sep 17 00:00:00 2001
From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com>
Date: Sun, 23 Aug 2026 20:47:41 +0000
Subject: [PATCH 5/5] fix: cubic review round three on encrypted OOXML
- crypto: wrong-password validation now runs before the size policy, so
oversized noise still ends in Encrypted rather than ResourceLimit; the
decrypted-in-memory budget becomes its own named constant with a note
on why it is stricter than the plaintext part-read budget.
- cli.js: a password-bearing path without --format keeps the extension
fallback (formatFromPath) so signature-less CSV behaves exactly as
without a password.
- convert.rs: format detection moved after decryption, mirroring
to_markdown_with_password - an encrypted file with an unrecognized
extension no longer dies with Unsupported pre-decrypt.
- numbering.rs: dropped the stale u64::MAX sentinel sentence from the
last_num doc comment.
---
examples/convert.rs | 32 +++++++++++++++++---------------
node/cli.js | 12 ++++++++++--
src/formats/docx/numbering.rs | 9 +++------
src/package/crypto.rs | 30 ++++++++++++++++--------------
4 files changed, 46 insertions(+), 37 deletions(-)
diff --git a/examples/convert.rs b/examples/convert.rs
index ce6c1cfd..d1d36642 100644
--- a/examples/convert.rs
+++ b/examples/convert.rs
@@ -73,11 +73,24 @@ fn run(
password: Option,
) -> Result<(), ConvertError> {
let password = password.or_else(|| std::env::var(PASSWORD_ENV).ok());
- let bytes = std::fs::read(input)?;
- // Without -f the format comes from the file content, with the extension as
- // the fallback.
+ let raw = std::fs::read(input)?;
+
+ // Decrypt before detection: an encrypted OOXML container has no
+ // recognizable signature until after decryption, mirroring the library's
+ // to_markdown_with_password semantics (#130 review).
+ let decrypted;
+ let bytes: &[u8] = match password.as_deref() {
+ Some(pw) if !pw.is_empty() && anydoc::is_encrypted_ooxml(&raw) => {
+ decrypted = anydoc::decrypt_ooxml(raw.clone(), pw)?;
+ &decrypted
+ }
+ _ => &raw,
+ };
+
+ // Without -f the format comes from the (now plaintext) content, with the
+ // extension as the fallback.
let format =
- match format.or_else(|| Format::from_bytes(&bytes)).or_else(|| Format::from_path(input)) {
+ match format.or_else(|| Format::from_bytes(bytes)).or_else(|| Format::from_path(input)) {
Some(format) => format,
None => {
return Err(ConvertError::Unsupported(format!(
@@ -87,17 +100,6 @@ fn run(
}
};
- // Decrypt once so the asset pass below sees the same plaintext instead
- // of re-reading the still-encrypted container (#130 review).
- let decrypted;
- let bytes: &[u8] = match password.as_deref() {
- Some(pw) if !pw.is_empty() && anydoc::is_encrypted_ooxml(&bytes) => {
- decrypted = anydoc::decrypt_ooxml(bytes.to_vec(), pw)?;
- &decrypted
- }
- _ => &bytes,
- };
-
let start = std::time::Instant::now();
let markdown = anydoc::to_markdown_bytes(bytes, format)?;
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
diff --git a/node/cli.js b/node/cli.js
index 256a1f21..21a8afa2 100644
--- a/node/cli.js
+++ b/node/cli.js
@@ -129,7 +129,13 @@ async function main() {
// Loaded after argument handling so --help and --version work even where
// no native binding is available.
- const { formatFromBytes, formatFromExtension, toMarkdown, toMarkdownBytes } = require('./index.js')
+ const {
+ formatFromBytes,
+ formatFromExtension,
+ formatFromPath,
+ toMarkdown,
+ toMarkdownBytes,
+} = require('./index.js')
let format
if (args.format !== null) {
@@ -145,7 +151,9 @@ async function main() {
// --format would otherwise drop it on the floor (#130 review).
if (args.input === '-' || format !== undefined || args.password !== null) {
const bytes = await (args.input === '-' ? readStdin() : readFile(args.input))
- const resolved = format ?? formatFromBytes(bytes)
+ // CSV has no content signature, so the path extension stays the last
+ // fallback exactly as the no-password flow treats it (#130 review).
+ const resolved = format ?? formatFromBytes(bytes) ?? formatFromPath(args.input)
markdown = await toMarkdownBytes(bytes, resolved, args.password)
} else {
markdown = await toMarkdown(args.input)
diff --git a/src/formats/docx/numbering.rs b/src/formats/docx/numbering.rs
index 61637a7d..58f6c278 100644
--- a/src/formats/docx/numbering.rs
+++ b/src/formats/docx/numbering.rs
@@ -260,12 +260,9 @@ struct InstanceState {
value: [u64; LEVELS],
initialized: [bool; LEVELS],
restart_pending: [bool; LEVELS],
- /// The instance currently driving this abstract (`u64::MAX` = none yet;
- /// real `numId`s never reach the counters as `u64::MAX`). Switching
- /// instances inside one logical list restarts only the levels the
- /// entering instance overrides (#96). `None` until the list's first
- /// paragraph; `Some` never collides with a real id because it is
- /// compared as an `Option`, not against a sentinel value.
+ /// The instance currently driving this abstract. Switching instances
+ /// inside one logical list restarts only the levels the entering
+ /// instance overrides (#96). `None` until the list's first paragraph.
last_num: Option,
/// The overridden levels of the currently active instance. Each restarts
/// on its first USE under this instance — not eagerly at the switch, so a
diff --git a/src/package/crypto.rs b/src/package/crypto.rs
index 7d7d1820..e1b42356 100644
--- a/src/package/crypto.rs
+++ b/src/package/crypto.rs
@@ -19,28 +19,30 @@ pub fn decrypt_ooxml(bytes: Vec, password: &str) -> Result, ConvertE
log::debug!("OOXML decryption failed: {e}");
ConvertError::Encrypted
})?;
- // The same archive budget that governs plaintext packages must bound the
- // decrypted one too, or a small encrypted file could inflate past every
- // limit before any part is read.
- let total: u64 = plain.len() as u64;
- if total > crate::package::limits::MAX_TOTAL_BYTES {
- return Err(ConvertError::ResourceLimit {
- limit: "max_total_bytes",
- detail: format!(
- "decrypted OOXML package is {total} bytes, over the {} byte budget",
- crate::package::limits::MAX_TOTAL_BYTES
- ),
- });
- }
// office-crypto does not check the EncryptionInfo password verifier, so a
// wrong password still "succeeds" — into noise. The decrypted payload is
// always an OOXML zip (the 8-byte size header is stripped), and the
// signature alone is not proof, so validate it with the same archive
- // reader every package goes through next.
+ // reader every package goes through next. Validation runs BEFORE the
+ // size policy so wrong-password noise keeps the documented Encrypted
+ // result even when it happens to be huge (cubic review of #130).
if !plain.starts_with(b"PK") || zip_check_broken(&plain) {
log::debug!("OOXML decryption produced a non-zip payload (wrong password?)");
return Err(ConvertError::Encrypted);
}
+ // Decryption materialises the WHOLE package in memory where the
+ // plaintext path streams parts lazily, so it gets its own explicit
+ // budget rather than borrowing MAX_TOTAL_BYTES' part-read semantics.
+ const DECRYPTED_PACKAGE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
+ let total: u64 = plain.len() as u64;
+ if total > DECRYPTED_PACKAGE_BUDGET_BYTES {
+ return Err(ConvertError::ResourceLimit {
+ limit: "decrypted_package_bytes",
+ detail: format!(
+ "decrypted OOXML package is {total} bytes, over the {DECRYPTED_PACKAGE_BUDGET_BYTES} byte budget"
+ ),
+ });
+ }
Ok(plain)
}