From c9ef5aaf4c984db61f842ba4d9e230ce54c20b5f Mon Sep 17 00:00:00 2001 From: yuyr Date: Wed, 8 Apr 2026 16:27:46 +0800 Subject: [PATCH] =?UTF-8?q?20260407=20&=2020260408=20=E5=9F=BA=E4=BA=8Ecir?= =?UTF-8?q?=20=E4=B8=89=E6=96=B9replay=E5=AF=B9=E9=BD=90=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=B8=94materialize=20=E4=BD=BF=E7=94=A8hard=20link=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/cir/README.md | 56 + .../cir-local-link-sync.cpython-312.pyc | Bin 0 -> 7109 bytes .../cir-rsync-wrappercpython-312.pyc | Bin 0 -> 6370 bytes .../json_to_vaps_csv.cpython-312.pyc | Bin 0 -> 3122 bytes scripts/cir/cir-local-link-sync.py | 136 + scripts/cir/cir-rsync-wrapper | 127 + scripts/cir/json_to_vaps_csv.py | 50 + scripts/cir/run_cir_replay_matrix.sh | 286 ++ scripts/cir/run_cir_replay_ours.sh | 150 + scripts/cir/run_cir_replay_routinator.sh | 209 + scripts/cir/run_cir_replay_rpki_client.sh | 179 + scripts/coverage.sh | 2 +- specs/cir.excalidraw | 4034 +++++++++++++++++ src/bin/cir_extract_inputs.rs | 79 + src/bin/cir_materialize.rs | 77 + src/bin/repository_view_stats.rs | 108 + src/ccr/mod.rs | 12 +- src/cir/decode.rs | 161 + src/cir/encode.rs | 147 + src/cir/export.rs | 285 ++ src/cir/materialize.rs | 388 ++ src/cir/mod.rs | 335 ++ src/cir/model.rs | 120 + src/cir/static_pool.rs | 376 ++ src/cli.rs | 275 +- src/data_model/mod.rs | 1 + src/fetch/rsync_system.rs | 37 +- src/lib.rs | 1 + src/sync/repo.rs | 70 +- src/validation/from_tal.rs | 155 + src/validation/run_tree_from_tal.rs | 112 + src/validation/tree_runner.rs | 116 +- tests/test_cir_matrix_m9.rs | 151 + tests/test_cir_peer_replay_m8.rs | 182 + tests/test_cir_wrapper_m6.rs | 190 + tests/test_cli_run_offline_m18.rs | 167 + 36 files changed, 8743 insertions(+), 31 deletions(-) create mode 100644 scripts/cir/README.md create mode 100644 scripts/cir/__pycache__/cir-local-link-sync.cpython-312.pyc create mode 100644 scripts/cir/__pycache__/cir-rsync-wrappercpython-312.pyc create mode 100644 scripts/cir/__pycache__/json_to_vaps_csv.cpython-312.pyc create mode 100755 scripts/cir/cir-local-link-sync.py create mode 100755 scripts/cir/cir-rsync-wrapper create mode 100755 scripts/cir/json_to_vaps_csv.py create mode 100755 scripts/cir/run_cir_replay_matrix.sh create mode 100755 scripts/cir/run_cir_replay_ours.sh create mode 100755 scripts/cir/run_cir_replay_routinator.sh create mode 100755 scripts/cir/run_cir_replay_rpki_client.sh create mode 100644 specs/cir.excalidraw create mode 100644 src/bin/cir_extract_inputs.rs create mode 100644 src/bin/cir_materialize.rs create mode 100644 src/bin/repository_view_stats.rs create mode 100644 src/cir/decode.rs create mode 100644 src/cir/encode.rs create mode 100644 src/cir/export.rs create mode 100644 src/cir/materialize.rs create mode 100644 src/cir/mod.rs create mode 100644 src/cir/model.rs create mode 100644 src/cir/static_pool.rs create mode 100644 tests/test_cir_matrix_m9.rs create mode 100644 tests/test_cir_peer_replay_m8.rs create mode 100644 tests/test_cir_wrapper_m6.rs diff --git a/scripts/cir/README.md b/scripts/cir/README.md new file mode 100644 index 0000000..c049e00 --- /dev/null +++ b/scripts/cir/README.md @@ -0,0 +1,56 @@ +# CIR Scripts + +## `cir-rsync-wrapper` + +一个用于 CIR 黑盒 replay 的 rsync wrapper。 + +### 环境变量 + +- `REAL_RSYNC_BIN` + - 真实 rsync 二进制路径 + - 默认优先 `/usr/bin/rsync` +- `CIR_MIRROR_ROOT` + - 本地镜像树根目录 + - 当命令行中出现 `rsync://...` source 时必需 + +### 语义 + +- 仅改写 `rsync://host/path` 类型参数 +- 其它参数原样透传给真实 rsync +- 改写目标: + - `rsync://example.net/repo/a.roa` + - → + - `/example.net/repo/a.roa` + +### 兼容目标 + +- Routinator `--rsync-command` +- `rpki-client -e rsync_prog` + +## 其它脚本 + +- `run_cir_replay_ours.sh` +- `run_cir_replay_routinator.sh` +- `run_cir_replay_rpki_client.sh` +- `run_cir_replay_matrix.sh` + +## `cir-local-link-sync.py` + +当 `CIR_LOCAL_LINK_MODE=1` 且 wrapper 检测到 source 已经被改写为本地 mirror 路径时, +wrapper 不再调用真实 `rsync`,而是调用这个 helper 完成: + +- `hardlink` 优先的本地树同步 +- 失败时回退到 copy +- 支持 `--delete` + +`run_cir_replay_matrix.sh` 会顺序执行: + +- `ours` +- Routinator +- `rpki-client` + +并汇总生成: + +- `summary.json` +- `summary.md` +- `detail.md` diff --git a/scripts/cir/__pycache__/cir-local-link-sync.cpython-312.pyc b/scripts/cir/__pycache__/cir-local-link-sync.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe8cca39a2f7be7a7be83c7e7fbd74b8a560c8b5 GIT binary patch literal 7109 zcmX@j%ge>Uz`*c-)z!>eeg=leAPx+JWf&M3K7V3hV3^L3!jQt4!w?0b8KW4%e5NQS zFwGproWhX8l*5wC8pX=UkiwkGn!=LGn!=jOmd2LC*1{6Sp27~|r!c0mq;RybL~*2W z!ueb+EK!_DdbrZqQn*`KqPSCdQn^xiQ@K)DQ+ZNZQ+ZRlQd!fOBpDbOpd?!gUkghV zA6OlKDp!g?Dq9Lf9Y0hBM~YwzOO!wgYbt*lQ;JXvOO#-$P>OK21_MJ8FB3zmFcU+n z;A*I!S2IEwO^lU{nj$Yj96wFQTPy*IB^kGvi;I$PF{c!l++r(AEh#O^OJ)KofMON~ z1_owOXdMBE7AHduLl)FDs0uIzv#FM;h7qqka}84#$V{*<1X073#fIR)SZPc(%ve=3 zV^r=vXcK{-Z8dFERz1&JjY$;hz`N&z7J`4Tu4 z;7s+JFRsi5#mz0Y%;NZzOi-%GC@sm%xy4qL zTT+yodW)?z46!C%52v2-bYHog6YCI?qi$Hl=2&BRe9E~@)`TMOq zt$S=2h%A?1D1SxK=mrPh4R-GK>c;Ap+8H6B2)rVzcZY+opSP2DLdsPR=?5G<{oI}0 zy}X}6F2BW6l%HRM5;vfH0}AcWyNHP!#!7|~kZO=&3@MB)IAS7&2^NAb46u+&VQyha zVOhhv8mixgAy$}yp_Y-8p@uOF9v@(X!30bnBSViU3qvI+U&7-GmM?ph;Q5lx55-#` zON>DUj}rp}gEs>s!*qrkhFEcsh4_n@V#Xek8pdgi(-|2VdL%&>uVpT0ECR)XCS#E( z0|SF5(=8@FgIkOR#h`LR0TOY)IBn8X^HM7citMUX;tPsO^HSqea|=o;LGfR#X9Lc# z#ddmdRdS$cm0)0Cc){?af#H(44FHl8buYg?Bz;GonOV;dNXq!<_&LO>Q~ zr&iu#FG?*-EhRq)3bHU_4j9G|SfU~d_Zz+r^jBA)ygS^SW zz`zK~6nKibVgdY_rbwa$RQ!W&tYN5Ogqd5!Sjk)hFV1QhVF`ngfs>(x7p$CtfguZ2 z6u~%98rc*^hDsJ?h7wTO1(sD|C`zeis$t3kuST!yEFu?KL}rAnaJj%Da)rh420K@KRby34 z&4l9Vbrb8Zh^yb>7MoB!!}$ug!VO{RI~*btg0FB$eqdr{6}rR0)z99^K7ko5bc0{~ z#~p5=PYg_~LN~bi`Yk&x=Y-CWo*BJHb-ngV?JcF-Yc|##2|XTrF!oAFyXph8}YkhP?e?cQIvQDFABOiEyto zaqdNmX?B?V2)VzCn}MO0qXy(t4uXDhWnsvRM6v}QrnQ_kjO52%u|N%H7QU1rP{ULM zN)V!49^30NqB2c5{7JFuKd|GBs>Mib~)SSeU%(B$@lKfj7iN)~+ z`Nf$Pw^;HLb5k|Bid;ZV09OzJ%51mTa}x^+GV{`J@q)^#_~iV&5>SbYXer!cNlGkE zy~SLVnsbX2oXJ5=>0(g79n{f+U`S(|6YPNa+{A*SUe}gh2=IO>T)B!eSjRHzZ`{bIs&hU_6`u69W^k8{-6)8^Yq#Z6?}GwY%Zy zI>CL0%XJx@i!wSZLN3b~U6--GC}VwD#`cnk-A7iC@(&ygBH|q`(9Rp0dCa`VNXFff zlABR7TWdkdb#?QL>gGFGF00#Kl(L=Rd_&J@g8K~T`93p!X8TTFE^^Dv37Q`^Gwcet$_mcw8WtBdEUsu+-B3{OaG&5j-D9H1B_7!a zYMLGH6FjH;P4v6SqkM;(e*#Ch-3;>!+)5W%l)&x2TkN2IE~vC+OG(X1ElEuVr4(3Z zhnKZtOz34TqQMfw#K2I?T+33!OjM(ZB@0iZ$%UbxHJK@cA(<(boq?g2wT8Kd6_kf3 zG4*h=Fx0ZuvZgTCvez&brPMIv>yZ?NAT_R-v*67sn8z3xQdnSRND6BULki;>Hl$vU z2DsGVz?*A9oe+*1wlc7{H;;TFV0qnOfcwcnMy^Q^N~uM%MDx@TG8o#!HIW zYB-_p;OtSV;Y;C!igH0kxq5VKxS?$B9%B}U5_qX!0}rJd&Kj;7?i%(Kt~sp149QH4 z49QHjtmO=v+F;Y8Qix8!C2#ij+YntATPoqVH3r&cMJ>rC3~_{CrXs0IZn0*Tq~;cbTWdus zARG80Wn~CRLnz1;@Gujo$6w?J5(@;2rB)QACYPk96h(o=Ky7$57a=tj*@{w&OLIz! zK?x92aKcGQ3CfQaPDKd}3=AN9iuEDosD$DI^%drqC5$h!h&Fh1q~BoSY4>aNn_;p- zrNQqCi~a*G!}U5Vbyn+Lj zl3!EX>#>*<3#k)V{vz`#(8YjlP=g(Za*Q6@3sQpb)cNop9d zm8>bq+(b$EHugdn1zOok=_D@|*e5ak{dLkcIh4rmH@ zCPNL=62?Awi;Iz=l1r1vugW^OGA~&nQ30II6`VbT6ml~`gSZNy9IsGXoSB!dkdauF z0_q$UE0kxX<|!297Z+zH<)rFqGT!1&Nreo{X6ENrap>wo^6@RM;*$KL)cBI3($p$V z7jU(wP?A`bo?4;+&i@K|`6UVkMXAN9c_j*&c?!k(rA5i9noPIY5|c|Z^Yd=8WTfU4 zfQCju;0p!Be6x(VDU8?};W7<|j=vsxMR8Sw(wi-g$ zDnOi?Vp|Pm)E0rt*Od&KT(>w9i_!}ci;7ck@i`Wym*%GCl>~seMYnhoQ&K=3Ca~Bo zF0djHzZjh9i_$>FNjiuCmp!ZnMVWaex7gD1i*gf7G?~HO-Qv`uTP%r1>BYC$i%WBJ z6N@TQJEAPPiJ5stMGOoK<)GqqGPwVFhgbAEugXPUl?#S>S9tRq+-|UNU1t%$$Ra+2 zYlY1PyUYtL;#XL*5TYDcSQHUFwks_1H@F2Mt(wc+at$s|_+)3K%+H>gy~1R@VeT%iE zv>+$-7E4NIatXK^xW$~AR{{xGP;lJhh>r)ghvVaKaRpZvm!#&pR%DhGWq_;%4Ss-2 z<6j&$x%nxjIjMFC%vA2aaZX5hKazJM}M^cwW@= z`V5l)V4=*&HY4~0gEAxA3S%&}g9S_-5IK;1!uUYOLAlQ$$qzn^yo|gZ$sikeKPWJ; g@N`sNVwSwgB{DH&a@b`qnF}m3AD9_fq`>h50CS(q&j0`b literal 0 HcmV?d00001 diff --git a/scripts/cir/__pycache__/cir-rsync-wrappercpython-312.pyc b/scripts/cir/__pycache__/cir-rsync-wrappercpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8144ac102fdaf78321f089eb0f6932ab7093dfdb GIT binary patch literal 6370 zcmX@j%ge>Uz`*c-)z!?;d<+baK^z!ngEBt9VPIgG&XB^8!kEJl1)&+E7{Pp|D5eyK z6s8>JT$U&nMvxqH4r?x36dNN$3QH<`8dC~u3riG73R^0B3VSMh3S$*J14M=`g`vCaJ8^Rai_4P@}x1PaJR5T@uH~XOJxU{4bqjylETx%62*@s z&zs7V!ji(6#+Jg@!V)Er!jdYO#+1U}!V)EvDx4yat--)h#LL8xD#FB&DzqBv$<>Sy zMiXNtqo&|X5XVoG@fJ%!Vo8Q3<1LQTqMU-nqTZLNG}W{5?3)eVbw4e zbC-a^1*{;22@&BX@T6J8028fcs$t546$M}&5F&*++nj-+n5Tv*ivuEpOcwJY*}zi6 zFq>g6l07v{S>R#^SqB3{7B4Cv#7kj?yMq~#NRcvwAV?J|hGhoqF00~ZV5nuOVJ>2= zVX0w;@JfVXCe*-_e=TbbOO_~196`fOFVd)C$r4A1!&q>6i5k`{PzeQ-fYJyb6|>i{ zrm$A=GBDJ#*02UM)G**zUnEA*L{?N2HQD@rae&jUmA-yeAeKDp>=_jA>lqZ}9~2+t z?;irnq(!NPrI|&kDGKEosd);Cd61lHrLV70oL^d$oC;D?P?TDnnpaW;iuo!vEaqnB zl_ln6!g6_Nkf)WxFGhV$-dkM7C5c5P#pRhL8AYH%rwEjUZ?P38XQbw)-r~r}FD}VT z%uT(;Qjl1Zaf>adxTGkv08$9B7o`^G=ai-1VlFNzDgu>kx7ad^<5Mz=ZgHgMrGU-U zWGfP5U|=W$Ma(TWaEYB#B*(zOaEmo3KRGey7AKgBFD@x61|@0*Fen1W|1IvK)bgUt zlGOObqVytl1_lO@NyXp5dHe=9U%y?a-3+ZO+^XQ5eUn@44xhvgp7_tqO#ECQ7??P@ zzOXUyiA_lEj%{$e!Oq)W+gLk;{Sv$Ug0kiHi|X(2NnDV2JfM6)^n#@OWj>D!93D5g z1^cZ#t*>*-UgVaYk$jn3slnwAkH7@!%RI8zc~md*s9xq#U!k^Mf2ICK9!pRm!F@wO zbOy^ro(|SK0>TqqCv!D;-eBixuW77lshgoXUuUMy9K8*a*A1O78af|vzGCQcgF|pa z@I?-Z4$cp33_PM2SVTUsGH~(s%XG?2NWQ`;wZQVavgt)-(+$a2l&uhoZ*YrTU=b+- zrT$wiiACwf$;ibDDBM8B$L9t{P@zLnZHH9Dr7*QH)G)X(z%md#<1?i*)H0_q*Rs^G zlz@s|a7IEDP}xij3`JEn3^gpE+*Zq4!&Kx_!K><4UF?P!RjAWGs?qU|?9ubc;#P0G!{7L4HtB0OzA$oHpsH zd8rizMRrwkX_+}CsYRe1Rjg+N&U(dmdT@C?kh?%ezF_##z;H?2c|-9G=J|Xx`4&`N zmoU00?)>vJ$oWmuvW!I_8>%>UbrVxkic*VpPyddF)lnf=Q zdGTN!`K2Yd7&9S02jvEEAp-IJEp8+)7g;kfFz7HaFchBymm+tBM5b#^)S9k0QSXAH z(Pbgy2Jbr};upo8ccfeqcfKs*+~9jdRJ_6Y1_x(@;|(tEp5zHC-I-mwH~54m7Bg)-#G1cwZJaxWZw0gM+gpu#>HW@q;1*HxI-y-MSx?75#%)#t_V)$>MiQ7?k>b{GAOUx-vEe0hcXjF=W z6Ks4=W?puDZhlH?d}>}|Qch}0kt+iO0|*yuLZX$2uivZFtKYBF?}D_!2Bph9rVXw) zxCKC^d$;Wc8M6!AW*1n@ARz`aAM6tb1_qED!6D|r$iUFfGMyoVA%$@woh&tsWei1ZHOwi@;L?k^M;6ii>R?G@Okru^sA0q` zTfurDO%UcD^G+75eG}yJkr`T=GWYn^Fky?IPIv=~k)Z~z8jn91ds3172yY~&FoVl= zZbXTn!VW7Tn;2`DQaC_guLeBW1YG14fyz#l0u|Io1_^$4V_;yI$_Ot~ zB|t@LClj_JwT3Z;p^AY4ybk? z8CZi*`3kojia3Yxbq=YE98xokuW-mCN>E9p67-7{0|$49^<{SP8H$(Kr9n+Y$?(g3 z5f?ZjZg7iDDC)GI;k|-srvC+Q?F%g0;9{f5f`Nend)Kc9+zP8x!i_)v~6r)G(AW6baU_V(XM4MlBc_YFM##%W9ZvnA72<3nN1bsMr8o znKml2lDTa4iH*?BLc5q&EdBDQih}tS z1?+{wk$Rag?E**I9bUmH8r^Z% zc~vj+s$NjDyUc6f;C2VnH=AQTqinYI1updl=Ns&T6GASsOWuIAX(m`*;gGw*!rt!H z=rw_D21`fX6&ATWENtx_jUGM0{c)XfGlH+OD1tK@N{a?m;($W*GpPACodJJOCxtPE zL5iW40WpYzvqyu}>p|qnS|&tYSHpzp7cdeS&qMSkV7VSsB%QH_X%W1q!N^d_tjXdB zF6dFJHc%A+uG%Q?D-?m6z2F+H2-M0)?jS&FF!9{P%sedBRXHeG8iEKT1_p+g^`JVD zqX;z82JVWM6@lv!P{SIWpumZ?CO*(?4U7@oXjN1@CHw5QBF=~ zk{)=_0z51N$)X@{fx9HPSaLFpOTc}KTP#WW`8nVc;1+91X+cgZq#yugv0KcUc_m=q zf$Rq5{rGrr;joec>>_X&{Nk|5%}*)KNwq5~WME(bH8P9OFfcHDU}j`we9XXkn}PQ> z1J_*!iMtGnAK17UV;N^Ke`WyDAEbmCMJEJ*U=U^$T~NFtWMLJUxgq&8NaTZ#7$g6L zlo`eoGe0nhG4d~PSs}d8^D~I~!H9{`e1XVk1|~-H4_s1=Itx@56fe~Lz#zq_vw?L( z@($w-85{XOgSa28xEYlegsf0r82N#Ln^9>;379&;@)^YYAj8JU-;w;8fsK*>gAfA? fPe;`yX33jeA`?RUz`(Hi$(77rRtAR0APx+(Kp2dlGZ+{crZc24q%h_% zA(e}XA(dk_!~%xZj1Wc>VDU2nYFl9Ax6|))UG6geeGW*?PatwaS#K6Fy$$E>qxTNS7YjH_YX2C7i z(t?82qFY?WC5c5P#pRhL8Ml}-^GY1&q@PuspBG<}A77SOP#m9JT&7o0d5bqMzbH2`C$lOwKCw8jNPvNXft!JW zq1csyfuVun4mV#%X}8sNZt08M(wDhquX8J3!oCSs<6gR6%KY z05jAuV2gkh#uTO!HmG8THO#A_5y*&3JqsT7oG^0`G~9m15|F#W5?MT83Wca)%;G~4 z0CTbg!4wLS!n%eHmMCC!EmJx}EprW1mN1G^FeimQonbXJWLy|xXEQL=vXqE|r5G3( zYFM!9;Xu~Y&zj7X!H~=p%gew}%UU81*UJjCe-cvXA8p`E|`6_Y&FbT zk}&lM8p#Zn6mF0Lm`N!-S<(n~FjfuR$0f3GG3+kqP2q#t4l^@L9;OFD!{Q}N5g`U+ z!Q|4IVJ0v#^vq>psA0}hhN(f&Dg0m`Lkj~&h8o5!6@)B|m8Aw_A?Pek1RKUmVM<|I zBLMROPSdnu>JfB`UBmCleN zya-;vFf!C|WEsQc5OfVvIHiEfYIu5MWGFF(sly&FFc&Z~REi`sF@n=jIfF8TAww}^ zITIs8Bm*M@BSVi$1#=`rIiseiUlqHqZf0IVX-SAC(=Cpo)WXutqSTaIEG3l%sa2f1 zy2-_5y7{FgRjj2YX}T7gjJG&a^OEyZGV{`Ju_hK5Bo^P|EGWt^%S=fvDn=>YL8Y@W zsC1XZDBV*SLFF#Yslg1IOnyb6!g?iR5hyEbGTve=E(Rq4g(7|i28Lf8HaUs8NhyhT zRbsh`nR$9PIr+(nImLE*FmX^KE(VpA4GeRXXCy9QT);FVb+%RmLlG#$Yck$q&Q7ho z#gSZET#}!gTJ($2`W913;w=X34mQK$U{IcdR2ML3*D$0ogG6f>QkXz=8gnp%CW~JY zsAvHD<|W7nMIb4V1B&v?k-Ss{%9OZ#14`P(pq$abFsFEd@q)sIW-Am|C@rv>UB7{O zgX2cF9n3o%cd}iOvxj+*=@xrIVo_>dN%1X?)QZgFlKA}WTa4wJOhurA=@xrlYI#m( zUaBT@6^}DC3=|xL{cdpvz@n~-C#0yfxJ1D*FF7N>2>02C$Md<~JMa8MN_#BJU zOLJ56N&-OKqFX$PDJk)ZP_ZIVO@50DtP&(xe2WECx7}jR$xlovzQqboU$;1mQWI0+ zK~>@{=JeE(TWrPoMJ1^z;5zOWTVg>$YF^4MmaP2Dydn+;28JR~=?^lZJhkW+3rOuP z_T=KS`25n6TWk>D-eS$oPRT5~#gbo;nsvD!#>9 zPz0)>a*QP)K^FN$hzFux$G zd0Eu@GM7z*^9^;)2A3NG!qeF&vd>^&Aaq$kWSzvToMD4PG`gH-_ivqfr1@x~Am|hexy)0nJ}1OIIXuGCYQ*>kjY_}xnwS|$b4XCV37isg#gZLle7Q; literal 0 HcmV?d00001 diff --git a/scripts/cir/cir-local-link-sync.py b/scripts/cir/cir-local-link-sync.py new file mode 100755 index 0000000..eee4d89 --- /dev/null +++ b/scripts/cir/cir-local-link-sync.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +import argparse +import errno +import os +import shutil +from pathlib import Path + + +def _same_inode(src: Path, dst: Path) -> bool: + try: + src_stat = src.stat() + dst_stat = dst.stat() + except FileNotFoundError: + return False + return (src_stat.st_dev, src_stat.st_ino) == (dst_stat.st_dev, dst_stat.st_ino) + + +def _remove_path(path: Path) -> None: + if not path.exists() and not path.is_symlink(): + return + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + + +def _prune_empty_dirs(root: Path) -> None: + if not root.exists(): + return + for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True): + try: + path.rmdir() + except OSError: + pass + + +def _link_or_copy(src: Path, dst: Path) -> str: + dst.parent.mkdir(parents=True, exist_ok=True) + if dst.exists() or dst.is_symlink(): + if _same_inode(src, dst): + return "reused" + _remove_path(dst) + try: + os.link(src, dst) + return "linked" + except OSError as err: + if err.errno not in (errno.EXDEV, errno.EPERM, errno.EMLINK, errno.ENOTSUP, errno.EACCES): + raise + shutil.copy2(src, dst) + return "copied" + + +def _file_map(src_arg: str, dest_arg: str) -> tuple[Path, dict[str, Path]]: + src = Path(src_arg.rstrip(os.sep)) + if not src.exists(): + raise FileNotFoundError(src) + mapping: dict[str, Path] = {} + if src.is_dir(): + copy_contents = src_arg.endswith(os.sep) + if copy_contents: + root = src + for path in root.rglob("*"): + if path.is_file(): + mapping[path.relative_to(root).as_posix()] = path + else: + root = src + base = src.name + for path in root.rglob("*"): + if path.is_file(): + rel = Path(base) / path.relative_to(root) + mapping[rel.as_posix()] = path + else: + dest_path = Path(dest_arg) + if dest_arg.endswith(os.sep) or dest_path.is_dir(): + mapping[src.name] = src + else: + mapping[dest_path.name] = src + return Path(dest_arg), mapping + + +def sync_local_tree(src_arg: str, dst_arg: str, delete: bool) -> dict[str, int]: + dst_root, mapping = _file_map(src_arg, dst_arg) + dst_root.mkdir(parents=True, exist_ok=True) + + expected = {dst_root / rel for rel in mapping.keys()} + + deleted = 0 + if delete and dst_root.exists(): + for path in sorted(dst_root.rglob("*"), key=lambda p: len(p.parts), reverse=True): + if path.is_dir(): + continue + if path not in expected: + _remove_path(path) + deleted += 1 + _prune_empty_dirs(dst_root) + + linked = 0 + copied = 0 + reused = 0 + for rel, src in mapping.items(): + dst = dst_root / rel + result = _link_or_copy(src, dst) + if result == "linked": + linked += 1 + elif result == "copied": + copied += 1 + else: + reused += 1 + + return { + "files": len(mapping), + "linked": linked, + "copied": copied, + "reused": reused, + "deleted": deleted, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Sync a local CIR mirror tree using hardlinks when possible.") + parser.add_argument("--delete", action="store_true", help="Delete target files not present in source") + parser.add_argument("source") + parser.add_argument("dest") + args = parser.parse_args() + + summary = sync_local_tree(args.source, args.dest, args.delete) + print( + "local-link-sync files={files} linked={linked} copied={copied} reused={reused} deleted={deleted}".format( + **summary + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cir/cir-rsync-wrapper b/scripts/cir/cir-rsync-wrapper new file mode 100755 index 0000000..add91f7 --- /dev/null +++ b/scripts/cir/cir-rsync-wrapper @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +import os +import shutil +import sys +from pathlib import Path +from urllib.parse import urlparse + + +def real_rsync_bin() -> str: + env = os.environ.get("REAL_RSYNC_BIN") + if env: + return env + default = "/usr/bin/rsync" + if Path(default).exists(): + return default + found = shutil.which("rsync") + if found: + return found + raise SystemExit("cir-rsync-wrapper: REAL_RSYNC_BIN is not set and rsync was not found") + + +def rewrite_arg(arg: str, mirror_root: str | None) -> str: + if not arg.startswith("rsync://"): + return arg + if not mirror_root: + raise SystemExit( + "cir-rsync-wrapper: CIR_MIRROR_ROOT is required when an rsync:// source is present" + ) + parsed = urlparse(arg) + if parsed.scheme != "rsync" or not parsed.hostname: + raise SystemExit(f"cir-rsync-wrapper: invalid rsync URI: {arg}") + path = parsed.path.lstrip("/") + local = Path(mirror_root).resolve() / parsed.hostname + if path: + local = local / path + local_str = str(local) + if local.exists() and local.is_dir() and not local_str.endswith("/"): + local_str += "/" + elif arg.endswith("/") and not local_str.endswith("/"): + local_str += "/" + return local_str + + +def filter_args(args: list[str]) -> list[str]: + mirror_root = os.environ.get("CIR_MIRROR_ROOT") + rewritten_any = any(arg.startswith("rsync://") for arg in args) + out: list[str] = [] + i = 0 + while i < len(args): + arg = args[i] + if rewritten_any: + if arg == "--address": + i += 2 + continue + if arg.startswith("--address="): + i += 1 + continue + if arg == "--contimeout": + i += 2 + continue + if arg.startswith("--contimeout="): + i += 1 + continue + out.append(rewrite_arg(arg, mirror_root)) + i += 1 + return out + + +def local_link_mode_enabled() -> bool: + value = os.environ.get("CIR_LOCAL_LINK_MODE", "") + return value.lower() in {"1", "true", "yes", "on"} + + +def extract_source_and_dest(args: list[str]) -> tuple[str, str]: + expects_value = { + "--timeout", + "--min-size", + "--max-size", + "--include", + "--exclude", + "--compare-dest", + } + positionals: list[str] = [] + i = 0 + while i < len(args): + arg = args[i] + if arg in expects_value: + i += 2 + continue + if any(arg.startswith(prefix + "=") for prefix in expects_value): + i += 1 + continue + if arg.startswith("-"): + i += 1 + continue + positionals.append(arg) + i += 1 + if len(positionals) < 2: + raise SystemExit("cir-rsync-wrapper: expected source and destination arguments") + return positionals[-2], positionals[-1] + + +def maybe_exec_local_link_sync(args: list[str], rewritten_any: bool) -> None: + if not rewritten_any or not local_link_mode_enabled(): + return + source, dest = extract_source_and_dest(args) + if source.startswith("rsync://"): + raise SystemExit("cir-rsync-wrapper: expected rewritten local source for CIR_LOCAL_LINK_MODE") + helper = Path(__file__).with_name("cir-local-link-sync.py") + cmd = [sys.executable, str(helper)] + if "--delete" in args: + cmd.append("--delete") + cmd.extend([source, dest]) + os.execv(sys.executable, cmd) + + +def main() -> int: + args = sys.argv[1:] + rewritten_any = any(arg.startswith("rsync://") for arg in args) + rewritten = filter_args(args) + maybe_exec_local_link_sync(rewritten, rewritten_any) + os.execv(real_rsync_bin(), [real_rsync_bin(), *rewritten]) + return 127 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cir/json_to_vaps_csv.py b/scripts/cir/json_to_vaps_csv.py new file mode 100755 index 0000000..e184e97 --- /dev/null +++ b/scripts/cir/json_to_vaps_csv.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import csv +import json +from pathlib import Path + + +def normalize_asn(value: str | int) -> str: + text = str(value).strip().upper() + if text.startswith("AS"): + text = text[2:] + return f"AS{int(text)}" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--csv-out", required=True, type=Path) + args = parser.parse_args() + + obj = json.loads(args.input.read_text(encoding="utf-8")) + rows: list[tuple[str, str, str]] = [] + for aspa in obj.get("aspas", []): + providers = sorted( + {normalize_asn(item) for item in aspa.get("providers", [])}, + key=lambda s: int(s[2:]), + ) + rows.append( + ( + normalize_asn(aspa["customer"]), + ";".join(providers), + str(aspa.get("ta", "")).strip().lower(), + ) + ) + rows.sort(key=lambda row: (int(row[0][2:]), row[1], row[2])) + + args.csv_out.parent.mkdir(parents=True, exist_ok=True) + with args.csv_out.open("w", encoding="utf-8", newline="") as fh: + writer = csv.writer(fh) + writer.writerow(["Customer ASN", "Providers", "Trust Anchor"]) + writer.writerows(rows) + print(args.csv_out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cir/run_cir_replay_matrix.sh b/scripts/cir/run_cir_replay_matrix.sh new file mode 100755 index 0000000..35994d1 --- /dev/null +++ b/scripts/cir/run_cir_replay_matrix.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/cir/run_cir_replay_matrix.sh \ + --cir \ + --static-root \ + --out-dir \ + --reference-ccr \ + --rpki-client-build-dir \ + [--keep-db] \ + [--rpki-bin ] \ + [--routinator-root ] \ + [--routinator-bin ] \ + [--real-rsync-bin ] +EOF +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +CIR="" +STATIC_ROOT="" +OUT_DIR="" +REFERENCE_CCR="" +RPKI_CLIENT_BUILD_DIR="" +KEEP_DB=0 +RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}" +ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}" +ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}" +REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}" +OURS_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_ours.sh" +ROUTINATOR_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_routinator.sh" +RPKI_CLIENT_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_rpki_client.sh" + +while [[ $# -gt 0 ]]; do + case "$1" in + --cir) CIR="$2"; shift 2 ;; + --static-root) STATIC_ROOT="$2"; shift 2 ;; + --out-dir) OUT_DIR="$2"; shift 2 ;; + --reference-ccr) REFERENCE_CCR="$2"; shift 2 ;; + --rpki-client-build-dir) RPKI_CLIENT_BUILD_DIR="$2"; shift 2 ;; + --keep-db) KEEP_DB=1; shift ;; + --rpki-bin) RPKI_BIN="$2"; shift 2 ;; + --routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;; + --routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;; + --real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +[[ -n "$CIR" && -n "$STATIC_ROOT" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" && -n "$RPKI_CLIENT_BUILD_DIR" ]] || { + usage >&2 + exit 2 +} + +mkdir -p "$OUT_DIR" + +run_with_timing() { + local summary_path="$1" + local timing_path="$2" + shift 2 + local start end status + start="$(python3 - <<'PY' +import time +print(time.perf_counter_ns()) +PY +)" + if "$@"; then + status=0 + else + status=$? + fi + end="$(python3 - <<'PY' +import time +print(time.perf_counter_ns()) +PY +)" + python3 - <<'PY' "$summary_path" "$timing_path" "$status" "$start" "$end" +import json, sys +summary_path, timing_path, status, start, end = sys.argv[1:] +duration_ms = max(0, (int(end) - int(start)) // 1_000_000) +data = {"exitCode": int(status), "durationMs": duration_ms} +try: + with open(summary_path, "r", encoding="utf-8") as f: + data["compare"] = json.load(f) +except FileNotFoundError: + data["compare"] = None +with open(timing_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) +PY + return "$status" +} + +OURS_OUT="$OUT_DIR/ours" +ROUTINATOR_OUT="$OUT_DIR/routinator" +RPKI_CLIENT_OUT="$OUT_DIR/rpki-client" +mkdir -p "$OURS_OUT" "$ROUTINATOR_OUT" "$RPKI_CLIENT_OUT" + +ours_cmd=( + "$OURS_SCRIPT" + --cir "$CIR" + --static-root "$STATIC_ROOT" + --out-dir "$OURS_OUT" + --reference-ccr "$REFERENCE_CCR" + --rpki-bin "$RPKI_BIN" + --real-rsync-bin "$REAL_RSYNC_BIN" +) +routinator_cmd=( + "$ROUTINATOR_SCRIPT" + --cir "$CIR" + --static-root "$STATIC_ROOT" + --out-dir "$ROUTINATOR_OUT" + --reference-ccr "$REFERENCE_CCR" + --routinator-root "$ROUTINATOR_ROOT" + --routinator-bin "$ROUTINATOR_BIN" + --real-rsync-bin "$REAL_RSYNC_BIN" +) +rpki_client_cmd=( + "$RPKI_CLIENT_SCRIPT" + --cir "$CIR" + --static-root "$STATIC_ROOT" + --out-dir "$RPKI_CLIENT_OUT" + --reference-ccr "$REFERENCE_CCR" + --build-dir "$RPKI_CLIENT_BUILD_DIR" + --real-rsync-bin "$REAL_RSYNC_BIN" +) + +if [[ "$KEEP_DB" -eq 1 ]]; then + ours_cmd+=(--keep-db) + routinator_cmd+=(--keep-db) + rpki_client_cmd+=(--keep-db) +fi + +ours_status=0 +routinator_status=0 +rpki_client_status=0 +if run_with_timing "$OURS_OUT/compare-summary.json" "$OURS_OUT/timing.json" "${ours_cmd[@]}"; then + : +else + ours_status=$? +fi +if run_with_timing "$ROUTINATOR_OUT/compare-summary.json" "$ROUTINATOR_OUT/timing.json" "${routinator_cmd[@]}"; then + : +else + routinator_status=$? +fi +if run_with_timing "$RPKI_CLIENT_OUT/compare-summary.json" "$RPKI_CLIENT_OUT/timing.json" "${rpki_client_cmd[@]}"; then + : +else + rpki_client_status=$? +fi + +SUMMARY_JSON="$OUT_DIR/summary.json" +SUMMARY_MD="$OUT_DIR/summary.md" +DETAIL_MD="$OUT_DIR/detail.md" + +python3 - <<'PY' \ + "$CIR" \ + "$STATIC_ROOT" \ + "$REFERENCE_CCR" \ + "$OURS_OUT" \ + "$ROUTINATOR_OUT" \ + "$RPKI_CLIENT_OUT" \ + "$SUMMARY_JSON" \ + "$SUMMARY_MD" \ + "$DETAIL_MD" +import json +import sys +from pathlib import Path + +cir_path, static_root, reference_ccr, ours_out, routinator_out, rpki_client_out, summary_json, summary_md, detail_md = sys.argv[1:] + +participants = [] +all_match = True +for name, out_dir in [ + ("ours", ours_out), + ("routinator", routinator_out), + ("rpki-client", rpki_client_out), +]: + out = Path(out_dir) + timing = json.loads((out / "timing.json").read_text(encoding="utf-8")) + compare = timing.get("compare") or {} + vrps = compare.get("vrps") or {} + vaps = compare.get("vaps") or {} + participant = { + "name": name, + "outDir": str(out), + "tmpRoot": str(out / ".tmp"), + "mirrorPath": str(out / ".tmp" / "mirror"), + "timingPath": str(out / "timing.json"), + "summaryPath": str(out / "compare-summary.json"), + "exitCode": timing["exitCode"], + "durationMs": timing["durationMs"], + "vrps": vrps, + "vaps": vaps, + "match": bool(vrps.get("match")) and bool(vaps.get("match")) and timing["exitCode"] == 0, + "logPaths": [str(path) for path in sorted(out.glob("*.log"))], + } + participants.append(participant) + all_match = all_match and participant["match"] + +summary = { + "cirPath": cir_path, + "staticRoot": static_root, + "referenceCcr": reference_ccr, + "participants": participants, + "allMatch": all_match, +} +Path(summary_json).write_text(json.dumps(summary, indent=2), encoding="utf-8") + +lines = [ + "# CIR Replay Matrix Summary", + "", + f"- `cir`: `{cir_path}`", + f"- `static_root`: `{static_root}`", + f"- `reference_ccr`: `{reference_ccr}`", + f"- `all_match`: `{all_match}`", + "", + "| Participant | Exit | Duration (ms) | VRP actual/ref | VRP match | VAP actual/ref | VAP match | Log |", + "| --- | ---: | ---: | --- | --- | --- | --- | --- |", +] +for participant in participants: + vrps = participant["vrps"] or {} + vaps = participant["vaps"] or {} + log_path = participant["logPaths"][0] if participant["logPaths"] else "" + lines.append( + "| {name} | {exit_code} | {duration_ms} | {vrp_actual}/{vrp_ref} | {vrp_match} | {vap_actual}/{vap_ref} | {vap_match} | `{log_path}` |".format( + name=participant["name"], + exit_code=participant["exitCode"], + duration_ms=participant["durationMs"], + vrp_actual=vrps.get("actual", "-"), + vrp_ref=vrps.get("reference", "-"), + vrp_match=vrps.get("match", False), + vap_actual=vaps.get("actual", "-"), + vap_ref=vaps.get("reference", "-"), + vap_match=vaps.get("match", False), + log_path=log_path, + ) + ) +Path(summary_md).write_text("\n".join(lines) + "\n", encoding="utf-8") + +detail_lines = [ + "# CIR Replay Matrix Detail", + "", +] +for participant in participants: + vrps = participant["vrps"] or {} + vaps = participant["vaps"] or {} + detail_lines.extend([ + f"## {participant['name']}", + f"- `exit_code`: `{participant['exitCode']}`", + f"- `duration_ms`: `{participant['durationMs']}`", + f"- `out_dir`: `{participant['outDir']}`", + f"- `tmp_root`: `{participant['tmpRoot']}`", + f"- `mirror_path`: `{participant['mirrorPath']}`", + f"- `summary_path`: `{participant['summaryPath']}`", + f"- `timing_path`: `{participant['timingPath']}`", + f"- `log_paths`: `{', '.join(participant['logPaths'])}`", + f"- `vrps`: `actual={vrps.get('actual', '-')}` `reference={vrps.get('reference', '-')}` `match={vrps.get('match', False)}`", + f"- `vaps`: `actual={vaps.get('actual', '-')}` `reference={vaps.get('reference', '-')}` `match={vaps.get('match', False)}`", + f"- `vrps.only_in_actual`: `{vrps.get('only_in_actual', [])}`", + f"- `vrps.only_in_reference`: `{vrps.get('only_in_reference', [])}`", + f"- `vaps.only_in_actual`: `{vaps.get('only_in_actual', [])}`", + f"- `vaps.only_in_reference`: `{vaps.get('only_in_reference', [])}`", + "", + ]) +Path(detail_md).write_text("\n".join(detail_lines), encoding="utf-8") +PY + +if [[ "$ours_status" -ne 0 || "$routinator_status" -ne 0 || "$rpki_client_status" -ne 0 ]]; then + exit 1 +fi + +all_match="$(python3 - <<'PY' "$SUMMARY_JSON" +import json,sys +print("true" if json.load(open(sys.argv[1]))["allMatch"] else "false") +PY +)" +if [[ "$all_match" != "true" ]]; then + exit 1 +fi + +echo "done: $OUT_DIR" diff --git a/scripts/cir/run_cir_replay_ours.sh b/scripts/cir/run_cir_replay_ours.sh new file mode 100755 index 0000000..b33a89c --- /dev/null +++ b/scripts/cir/run_cir_replay_ours.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/cir/run_cir_replay_ours.sh \ + --cir \ + --static-root \ + --out-dir \ + --reference-ccr \ + [--keep-db] \ + [--rpki-bin ] \ + [--real-rsync-bin ] +EOF +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +CIR="" +STATIC_ROOT="" +OUT_DIR="" +REFERENCE_CCR="" +KEEP_DB=0 +RPKI_BIN="$ROOT_DIR/target/release/rpki" +CIR_MATERIALIZE_BIN="$ROOT_DIR/target/release/cir_materialize" +CIR_EXTRACT_INPUTS_BIN="$ROOT_DIR/target/release/cir_extract_inputs" +CCR_TO_COMPARE_VIEWS_BIN="$ROOT_DIR/target/release/ccr_to_compare_views" +REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}" +WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper" + +while [[ $# -gt 0 ]]; do + case "$1" in + --cir) CIR="$2"; shift 2 ;; + --static-root) STATIC_ROOT="$2"; shift 2 ;; + --out-dir) OUT_DIR="$2"; shift 2 ;; + --reference-ccr) REFERENCE_CCR="$2"; shift 2 ;; + --keep-db) KEEP_DB=1; shift ;; + --rpki-bin) RPKI_BIN="$2"; shift 2 ;; + --real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +[[ -n "$CIR" && -n "$STATIC_ROOT" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" ]] || { + usage >&2 + exit 2 +} + +mkdir -p "$OUT_DIR" + +if [[ ! -x "$RPKI_BIN" || ! -x "$CIR_MATERIALIZE_BIN" || ! -x "$CIR_EXTRACT_INPUTS_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then + ( + cd "$ROOT_DIR" + cargo build --release --bin rpki --bin cir_materialize --bin cir_extract_inputs --bin ccr_to_compare_views + ) +fi + +TMP_ROOT="$OUT_DIR/.tmp" +TALS_DIR="$TMP_ROOT/tals" +META_JSON="$TMP_ROOT/meta.json" +MIRROR_ROOT="$TMP_ROOT/mirror" +DB_DIR="$TMP_ROOT/work-db" +ACTUAL_CCR="$OUT_DIR/actual.ccr" +ACTUAL_REPORT="$OUT_DIR/report.json" +ACTUAL_VRPS="$OUT_DIR/actual-vrps.csv" +ACTUAL_VAPS="$OUT_DIR/actual-vaps.csv" +REF_VRPS="$OUT_DIR/reference-vrps.csv" +REF_VAPS="$OUT_DIR/reference-vaps.csv" +COMPARE_JSON="$OUT_DIR/compare-summary.json" +RUN_LOG="$OUT_DIR/run.log" + +rm -rf "$TMP_ROOT" +mkdir -p "$TMP_ROOT" + +"$CIR_EXTRACT_INPUTS_BIN" --cir "$CIR" --tals-dir "$TALS_DIR" --meta-json "$META_JSON" +materialize_cmd=("$CIR_MATERIALIZE_BIN" --cir "$CIR" --static-root "$STATIC_ROOT" --mirror-root "$MIRROR_ROOT") +if [[ "$KEEP_DB" -eq 1 ]]; then + materialize_cmd+=(--keep-db) +fi +"${materialize_cmd[@]}" + +VALIDATION_TIME="$(python3 - <<'PY' "$META_JSON" +import json,sys +print(json.load(open(sys.argv[1]))["validationTime"]) +PY +)" +FIRST_TAL="$(python3 - <<'PY' "$META_JSON" +import json,sys +print(json.load(open(sys.argv[1]))["talFiles"][0]["path"]) +PY +)" + +export CIR_MIRROR_ROOT="$(python3 - <<'PY' "$MIRROR_ROOT" +from pathlib import Path +import sys +print(Path(sys.argv[1]).resolve()) +PY +)" +export REAL_RSYNC_BIN="$REAL_RSYNC_BIN" +export CIR_LOCAL_LINK_MODE=1 + +"$RPKI_BIN" \ + --db "$DB_DIR" \ + --tal-path "$FIRST_TAL" \ + --disable-rrdp \ + --rsync-command "$WRAPPER" \ + --validation-time "$VALIDATION_TIME" \ + --ccr-out "$ACTUAL_CCR" \ + --report-json "$ACTUAL_REPORT" \ + >"$RUN_LOG" 2>&1 + +"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$ACTUAL_CCR" --vrps-out "$ACTUAL_VRPS" --vaps-out "$ACTUAL_VAPS" --trust-anchor unknown +"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$REFERENCE_CCR" --vrps-out "$REF_VRPS" --vaps-out "$REF_VAPS" --trust-anchor unknown + +python3 - <<'PY' "$ACTUAL_VRPS" "$REF_VRPS" "$ACTUAL_VAPS" "$REF_VAPS" "$COMPARE_JSON" +import csv, json, sys +def rows(path): + with open(path, newline="") as f: + return list(csv.reader(f))[1:] +actual_vrps = {tuple(r) for r in rows(sys.argv[1])} +ref_vrps = {tuple(r) for r in rows(sys.argv[2])} +actual_vaps = {tuple(r) for r in rows(sys.argv[3])} +ref_vaps = {tuple(r) for r in rows(sys.argv[4])} +summary = { + "vrps": { + "actual": len(actual_vrps), + "reference": len(ref_vrps), + "only_in_actual": sorted(actual_vrps - ref_vrps)[:20], + "only_in_reference": sorted(ref_vrps - actual_vrps)[:20], + "match": actual_vrps == ref_vrps, + }, + "vaps": { + "actual": len(actual_vaps), + "reference": len(ref_vaps), + "only_in_actual": sorted(actual_vaps - ref_vaps)[:20], + "only_in_reference": sorted(ref_vaps - actual_vaps)[:20], + "match": actual_vaps == ref_vaps, + } +} +with open(sys.argv[5], "w") as f: + json.dump(summary, f, indent=2) +PY + +if [[ "$KEEP_DB" -ne 1 ]]; then + rm -rf "$TMP_ROOT" +fi + +echo "done: $OUT_DIR" diff --git a/scripts/cir/run_cir_replay_routinator.sh b/scripts/cir/run_cir_replay_routinator.sh new file mode 100755 index 0000000..dafe009 --- /dev/null +++ b/scripts/cir/run_cir_replay_routinator.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/cir/run_cir_replay_routinator.sh \ + --cir \ + --static-root \ + --out-dir \ + --reference-ccr \ + [--keep-db] \ + [--routinator-root ] \ + [--routinator-bin ] \ + [--real-rsync-bin ] +EOF +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RPKI_DEV_ROOT="${RPKI_DEV_ROOT:-$ROOT_DIR}" + +CIR="" +STATIC_ROOT="" +OUT_DIR="" +REFERENCE_CCR="" +KEEP_DB=0 +ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}" +ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}" +REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}" +CIR_MATERIALIZE_BIN="$ROOT_DIR/target/release/cir_materialize" +CIR_EXTRACT_INPUTS_BIN="$ROOT_DIR/target/release/cir_extract_inputs" +CCR_TO_COMPARE_VIEWS_BIN="$ROOT_DIR/target/release/ccr_to_compare_views" +WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper" +JSON_TO_VAPS="$ROOT_DIR/scripts/cir/json_to_vaps_csv.py" +FAKETIME_LIB="${FAKETIME_LIB:-$ROOT_DIR/target/tools/faketime_pkg/extracted/libfaketime/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --cir) CIR="$2"; shift 2 ;; + --static-root) STATIC_ROOT="$2"; shift 2 ;; + --out-dir) OUT_DIR="$2"; shift 2 ;; + --reference-ccr) REFERENCE_CCR="$2"; shift 2 ;; + --keep-db) KEEP_DB=1; shift ;; + --routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;; + --routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;; + --real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +[[ -n "$CIR" && -n "$STATIC_ROOT" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" ]] || { + usage >&2 + exit 2 +} + +mkdir -p "$OUT_DIR" +if [[ ! -x "$CIR_MATERIALIZE_BIN" || ! -x "$CIR_EXTRACT_INPUTS_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then + ( + cd "$ROOT_DIR" + cargo build --release --bin cir_materialize --bin cir_extract_inputs --bin ccr_to_compare_views + ) +fi + +TMP_ROOT="$OUT_DIR/.tmp" +TALS_DIR="$TMP_ROOT/tals" +META_JSON="$TMP_ROOT/meta.json" +MIRROR_ROOT="$TMP_ROOT/mirror" +WORK_REPO="$TMP_ROOT/repository" +RUN_LOG="$OUT_DIR/routinator.log" +ACTUAL_VRPS="$OUT_DIR/actual-vrps.csv" +ACTUAL_VAPS_JSON="$OUT_DIR/actual-vaps.json" +ACTUAL_VAPS="$OUT_DIR/actual-vaps.csv" +REF_VRPS="$OUT_DIR/reference-vrps.csv" +REF_VAPS="$OUT_DIR/reference-vaps.csv" +SUMMARY_JSON="$OUT_DIR/compare-summary.json" + +rm -rf "$TMP_ROOT" +mkdir -p "$TMP_ROOT" + +"$CIR_EXTRACT_INPUTS_BIN" --cir "$CIR" --tals-dir "$TALS_DIR" --meta-json "$META_JSON" +python3 - <<'PY' "$TALS_DIR" +from pathlib import Path +import sys +for tal in Path(sys.argv[1]).glob("*.tal"): + lines = tal.read_text(encoding="utf-8").splitlines() + rsync_uris = [line for line in lines if line.startswith("rsync://")] + base64_lines = [] + seen_sep = False + for line in lines: + if seen_sep: + if line.strip(): + base64_lines.append(line) + elif line.strip() == "": + seen_sep = True + tal.write_text("\n".join(rsync_uris) + "\n\n" + "\n".join(base64_lines) + "\n", encoding="utf-8") +PY +materialize_cmd=("$CIR_MATERIALIZE_BIN" --cir "$CIR" --static-root "$STATIC_ROOT" --mirror-root "$MIRROR_ROOT") +if [[ "$KEEP_DB" -eq 1 ]]; then + materialize_cmd+=(--keep-db) +fi +"${materialize_cmd[@]}" + +VALIDATION_TIME="$(python3 - <<'PY' "$META_JSON" +import json,sys +print(json.load(open(sys.argv[1]))["validationTime"]) +PY +)" +FIRST_TAL="$(python3 - <<'PY' "$META_JSON" +import json,sys +print(json.load(open(sys.argv[1]))["talFiles"][0]["path"]) +PY +)" +TA_NAME="$(basename "$FIRST_TAL" .tal)" +FAKE_EPOCH="$(python3 - <<'PY' "$VALIDATION_TIME" +from datetime import datetime, timezone +import sys +dt = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc) +print(int(dt.timestamp())) +PY +)" + +export CIR_MIRROR_ROOT="$(python3 - <<'PY' "$MIRROR_ROOT" +from pathlib import Path +import sys +print(Path(sys.argv[1]).resolve()) +PY +)" +export REAL_RSYNC_BIN="$REAL_RSYNC_BIN" +export CIR_LOCAL_LINK_MODE=1 +env \ + LD_PRELOAD="$FAKETIME_LIB" \ + FAKETIME_FMT=%s \ + FAKETIME="$FAKE_EPOCH" \ + FAKETIME_DONT_FAKE_MONOTONIC=1 \ + "$ROUTINATOR_BIN" \ + --repository-dir "$WORK_REPO" \ + --disable-rrdp \ + --rsync-command "$WRAPPER" \ + --no-rir-tals \ + --extra-tals-dir "$TALS_DIR" \ + --enable-aspa \ + update --complete >"$RUN_LOG" 2>&1 + +env \ + LD_PRELOAD="$FAKETIME_LIB" \ + FAKETIME_FMT=%s \ + FAKETIME="$FAKE_EPOCH" \ + FAKETIME_DONT_FAKE_MONOTONIC=1 \ + "$ROUTINATOR_BIN" \ + --repository-dir "$WORK_REPO" \ + --disable-rrdp \ + --rsync-command "$WRAPPER" \ + --no-rir-tals \ + --extra-tals-dir "$TALS_DIR" \ + --enable-aspa \ + vrps --noupdate -o "$ACTUAL_VRPS" >>"$RUN_LOG" 2>&1 + +env \ + LD_PRELOAD="$FAKETIME_LIB" \ + FAKETIME_FMT=%s \ + FAKETIME="$FAKE_EPOCH" \ + FAKETIME_DONT_FAKE_MONOTONIC=1 \ + "$ROUTINATOR_BIN" \ + --repository-dir "$WORK_REPO" \ + --disable-rrdp \ + --rsync-command "$WRAPPER" \ + --no-rir-tals \ + --extra-tals-dir "$TALS_DIR" \ + --enable-aspa \ + vrps --noupdate --format json -o "$ACTUAL_VAPS_JSON" >>"$RUN_LOG" 2>&1 + +python3 "$JSON_TO_VAPS" --input "$ACTUAL_VAPS_JSON" --csv-out "$ACTUAL_VAPS" +"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$REFERENCE_CCR" --vrps-out "$REF_VRPS" --vaps-out "$REF_VAPS" --trust-anchor "$TA_NAME" + +python3 - <<'PY' "$ACTUAL_VRPS" "$REF_VRPS" "$ACTUAL_VAPS" "$REF_VAPS" "$SUMMARY_JSON" +import csv, json, sys +def rows(path): + with open(path, newline="") as f: + return list(csv.reader(f))[1:] +actual_vrps = {tuple(r) for r in rows(sys.argv[1])} +ref_vrps = {tuple(r) for r in rows(sys.argv[2])} +actual_vaps = {tuple(r) for r in rows(sys.argv[3])} +ref_vaps = {tuple(r) for r in rows(sys.argv[4])} +summary = { + "vrps": { + "actual": len(actual_vrps), + "reference": len(ref_vrps), + "match": actual_vrps == ref_vrps, + "only_in_actual": sorted(actual_vrps - ref_vrps)[:20], + "only_in_reference": sorted(ref_vrps - actual_vrps)[:20], + }, + "vaps": { + "actual": len(actual_vaps), + "reference": len(ref_vaps), + "match": actual_vaps == ref_vaps, + "only_in_actual": sorted(actual_vaps - ref_vaps)[:20], + "only_in_reference": sorted(ref_vaps - actual_vaps)[:20], + } +} +with open(sys.argv[5], "w") as f: + json.dump(summary, f, indent=2) +PY + +if [[ "$KEEP_DB" -ne 1 ]]; then + rm -rf "$TMP_ROOT" +fi + +echo "done: $OUT_DIR" diff --git a/scripts/cir/run_cir_replay_rpki_client.sh b/scripts/cir/run_cir_replay_rpki_client.sh new file mode 100755 index 0000000..1e22517 --- /dev/null +++ b/scripts/cir/run_cir_replay_rpki_client.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/cir/run_cir_replay_rpki_client.sh \ + --cir \ + --static-root \ + --out-dir \ + --reference-ccr \ + --build-dir \ + [--keep-db] \ + [--real-rsync-bin ] +EOF +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CIR="" +STATIC_ROOT="" +OUT_DIR="" +REFERENCE_CCR="" +BUILD_DIR="" +KEEP_DB=0 +REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}" +CIR_MATERIALIZE_BIN="$ROOT_DIR/target/release/cir_materialize" +CIR_EXTRACT_INPUTS_BIN="$ROOT_DIR/target/release/cir_extract_inputs" +CCR_TO_COMPARE_VIEWS_BIN="$ROOT_DIR/target/release/ccr_to_compare_views" +WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper" + +while [[ $# -gt 0 ]]; do + case "$1" in + --cir) CIR="$2"; shift 2 ;; + --static-root) STATIC_ROOT="$2"; shift 2 ;; + --out-dir) OUT_DIR="$2"; shift 2 ;; + --reference-ccr) REFERENCE_CCR="$2"; shift 2 ;; + --build-dir) BUILD_DIR="$2"; shift 2 ;; + --keep-db) KEEP_DB=1; shift ;; + --real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +[[ -n "$CIR" && -n "$STATIC_ROOT" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" && -n "$BUILD_DIR" ]] || { + usage >&2 + exit 2 +} + +mkdir -p "$OUT_DIR" +if [[ ! -x "$CIR_MATERIALIZE_BIN" || ! -x "$CIR_EXTRACT_INPUTS_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then + ( + cd "$ROOT_DIR" + cargo build --release --bin cir_materialize --bin cir_extract_inputs --bin ccr_to_compare_views + ) +fi + +TMP_ROOT="$OUT_DIR/.tmp" +TALS_DIR="$TMP_ROOT/tals" +META_JSON="$TMP_ROOT/meta.json" +MIRROR_ROOT="$TMP_ROOT/mirror" +CACHE_DIR="$TMP_ROOT/cache" +OUT_CCR_DIR="$TMP_ROOT/out" +RUN_LOG="$OUT_DIR/rpki-client.log" +ACTUAL_VRPS="$OUT_DIR/actual-vrps.csv" +ACTUAL_VAPS="$OUT_DIR/actual-vaps.csv" +ACTUAL_VAPS_META="$OUT_DIR/actual-vaps-meta.json" +ACTUAL_VRPS_META="$OUT_DIR/actual-vrps-meta.json" +REF_VRPS="$OUT_DIR/reference-vrps.csv" +REF_VAPS="$OUT_DIR/reference-vaps.csv" +SUMMARY_JSON="$OUT_DIR/compare-summary.json" + +rm -rf "$TMP_ROOT" +mkdir -p "$TMP_ROOT" + +"$CIR_EXTRACT_INPUTS_BIN" --cir "$CIR" --tals-dir "$TALS_DIR" --meta-json "$META_JSON" +python3 - <<'PY' "$TALS_DIR" +from pathlib import Path +import sys +for tal in Path(sys.argv[1]).glob("*.tal"): + lines = tal.read_text(encoding="utf-8").splitlines() + rsync_uris = [line for line in lines if line.startswith("rsync://")] + base64_lines = [] + seen_sep = False + for line in lines: + if seen_sep: + if line.strip(): + base64_lines.append(line) + elif line.strip() == "": + seen_sep = True + tal.write_text("\n".join(rsync_uris) + "\n\n" + "\n".join(base64_lines) + "\n", encoding="utf-8") +PY +materialize_cmd=("$CIR_MATERIALIZE_BIN" --cir "$CIR" --static-root "$STATIC_ROOT" --mirror-root "$MIRROR_ROOT") +if [[ "$KEEP_DB" -eq 1 ]]; then + materialize_cmd+=(--keep-db) +fi +"${materialize_cmd[@]}" + +VALIDATION_EPOCH="$(python3 - <<'PY' "$META_JSON" +from datetime import datetime, timezone +import json, sys +vt = json.load(open(sys.argv[1]))["validationTime"] +dt = datetime.fromisoformat(vt.replace("Z", "+00:00")).astimezone(timezone.utc) +print(int(dt.timestamp())) +PY +)" +FIRST_TAL="$(python3 - <<'PY' "$META_JSON" +import json,sys +print(json.load(open(sys.argv[1]))["talFiles"][0]["path"]) +PY +)" +TA_NAME="$(basename "$FIRST_TAL" .tal)" + +export CIR_MIRROR_ROOT="$(python3 - <<'PY' "$MIRROR_ROOT" +from pathlib import Path +import sys +print(Path(sys.argv[1]).resolve()) +PY +)" +export REAL_RSYNC_BIN="$REAL_RSYNC_BIN" +export CIR_LOCAL_LINK_MODE=1 + +mkdir -p "$CACHE_DIR" "$OUT_CCR_DIR" +"$BUILD_DIR/src/rpki-client" \ + -R \ + -e "$WRAPPER" \ + -P "$VALIDATION_EPOCH" \ + -t "$FIRST_TAL" \ + -d "$CACHE_DIR" \ + "$OUT_CCR_DIR" >"$RUN_LOG" 2>&1 + +"$BUILD_DIR/tests/rpki-ccr-vrps" \ + --input "$OUT_CCR_DIR/rpki.ccr" \ + --ta "$TA_NAME" \ + --csv-out "$ACTUAL_VRPS" \ + --meta-out "$ACTUAL_VRPS_META" + +"$BUILD_DIR/tests/rpki-ccr-vaps" \ + --input "$OUT_CCR_DIR/rpki.ccr" \ + --ta "$TA_NAME" \ + --csv-out "$ACTUAL_VAPS" \ + --meta-out "$ACTUAL_VAPS_META" + +"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$REFERENCE_CCR" --vrps-out "$REF_VRPS" --vaps-out "$REF_VAPS" --trust-anchor "$TA_NAME" + +python3 - <<'PY' "$ACTUAL_VRPS" "$REF_VRPS" "$ACTUAL_VAPS" "$REF_VAPS" "$SUMMARY_JSON" +import csv, json, sys +def rows(path): + with open(path, newline="") as f: + return list(csv.reader(f))[1:] +actual_vrps = {tuple(r) for r in rows(sys.argv[1])} +ref_vrps = {tuple(r) for r in rows(sys.argv[2])} +actual_vaps = {tuple(r) for r in rows(sys.argv[3])} +ref_vaps = {tuple(r) for r in rows(sys.argv[4])} +summary = { + "vrps": { + "actual": len(actual_vrps), + "reference": len(ref_vrps), + "match": actual_vrps == ref_vrps, + "only_in_actual": sorted(actual_vrps - ref_vrps)[:20], + "only_in_reference": sorted(ref_vrps - actual_vrps)[:20], + }, + "vaps": { + "actual": len(actual_vaps), + "reference": len(ref_vaps), + "match": actual_vaps == ref_vaps, + "only_in_actual": sorted(actual_vaps - ref_vaps)[:20], + "only_in_reference": sorted(ref_vaps - actual_vaps)[:20], + } +} +with open(sys.argv[5], "w") as f: + json.dump(summary, f, indent=2) +PY + +if [[ "$KEEP_DB" -ne 1 ]]; then + rm -rf "$TMP_ROOT" +fi + +echo "done: $OUT_DIR" diff --git a/scripts/coverage.sh b/scripts/coverage.sh index e12b5fc..0183418 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -14,7 +14,7 @@ cleanup() { } trap cleanup EXIT -IGNORE_REGEX='src/bin/replay_bundle_capture\.rs|src/bin/replay_bundle_capture_delta\.rs|src/bin/replay_bundle_capture_sequence\.rs|src/bundle/live_capture\.rs' +IGNORE_REGEX='src/bin/replay_bundle_capture\.rs|src/bin/replay_bundle_capture_delta\.rs|src/bin/replay_bundle_capture_sequence\.rs|src/bin/replay_bundle_record\.rs|src/bin/replay_bundle_refresh_sequence_outputs\.rs|src/bin/measure_sequence_replay\.rs|src/bin/repository_view_stats\.rs|src/bin/trace_arin_missing_vrps\.rs|src/bin/db_stats\.rs|src/bin/rrdp_state_dump\.rs|src/bin/ccr_dump\.rs|src/bin/ccr_verify\.rs|src/bin/ccr_to_routinator_csv\.rs|src/bin/ccr_to_compare_views\.rs|src/bin/cir_materialize\.rs|src/bin/cir_extract_inputs\.rs|src/bundle/live_capture\.rs|src/bundle/record_io\.rs|src/progress_log\.rs' # Preserve colored output even though we post-process output by running under a pseudo-TTY. # We run tests only once, then generate both CLI text + HTML reports without rerunning tests. diff --git a/specs/cir.excalidraw b/specs/cir.excalidraw new file mode 100644 index 0000000..76180c6 --- /dev/null +++ b/specs/cir.excalidraw @@ -0,0 +1,4034 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor", + "elements": [ + { + "id": "9Hfy_5qe0BNkvBNovBJEt", + "type": "rectangle", + "x": 305.8571472167969, + "y": 177.14288330078125, + "width": 115.85714721679688, + "height": 70.28572082519531, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a0", + "roundness": { + "type": 3 + }, + "seed": 405163998, + "version": 101, + "versionNonce": 1315548766, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "ccHr6GIh61HBXLbAvWTRK" + }, + { + "id": "4cfLqaGOYcDNp-8-qatAy", + "type": "arrow" + } + ], + "updated": 1775549106317, + "link": null, + "locked": false + }, + { + "id": "ccHr6GIh61HBXLbAvWTRK", + "type": "text", + "x": 338.63574981689453, + "y": 199.7857437133789, + "width": 50.29994201660156, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a1", + "roundness": null, + "seed": 175087682, + "version": 9, + "versionNonce": 337261342, + "isDeleted": false, + "boundElements": [], + "updated": 1775549053611, + "link": null, + "locked": false, + "text": "rsync", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "9Hfy_5qe0BNkvBNovBJEt", + "originalText": "rsync", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "bsldBjJQoZqKNT7_eZfR9", + "type": "rectangle", + "x": 445.3571319580078, + "y": 177.42858123779297, + "width": 115.85714721679688, + "height": 70.28572082519531, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a2", + "roundness": { + "type": 3 + }, + "seed": 2117121054, + "version": 135, + "versionNonce": 798650562, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "EiBkLzYiguXoUKxO2dFGD" + }, + { + "id": "D7wcYbReGVsdKS6zekwV_", + "type": "arrow" + } + ], + "updated": 1775549111165, + "link": null, + "locked": false + }, + { + "id": "EiBkLzYiguXoUKxO2dFGD", + "type": "text", + "x": 483.62572479248047, + "y": 200.07144165039062, + "width": 39.31996154785156, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a3", + "roundness": null, + "seed": 612401246, + "version": 47, + "versionNonce": 1406984962, + "isDeleted": false, + "boundElements": [], + "updated": 1775549061768, + "link": null, + "locked": false, + "text": "rrdp", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "bsldBjJQoZqKNT7_eZfR9", + "originalText": "rrdp", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "FUj6N3igANwoKU6Hg4TPs", + "type": "rectangle", + "x": 604.7859649658203, + "y": 178.5714340209961, + "width": 115.85714721679688, + "height": 70.28572082519531, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a4", + "roundness": { + "type": 3 + }, + "seed": 1400853214, + "version": 222, + "versionNonce": 1532184898, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "04FWbybGj0TD2x5ZgAY72" + }, + { + "id": "U3b3OUVAc2HWH_Ov9ub1z", + "type": "arrow" + } + ], + "updated": 1775549116239, + "link": null, + "locked": false + }, + { + "id": "04FWbybGj0TD2x5ZgAY72", + "type": "text", + "x": 627.8945693969727, + "y": 188.71429443359375, + "width": 69.63993835449219, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a5", + "roundness": null, + "seed": 2081244958, + "version": 159, + "versionNonce": 539498178, + "isDeleted": false, + "boundElements": [], + "updated": 1775550006076, + "link": null, + "locked": false, + "text": "erik, ...,\netc", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "FUj6N3igANwoKU6Hg4TPs", + "originalText": "erik, ..., etc", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "Q_91dvy9LwvYOonrHkccV", + "type": "rectangle", + "x": 295.5003204345703, + "y": 313.4287338256836, + "width": 279.71441650390625, + "height": 70.28572082519531, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a6", + "roundness": { + "type": 3 + }, + "seed": 2006147138, + "version": 230, + "versionNonce": 1602924528, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "SjvoELUvaD0VFhw2JsMth" + }, + { + "id": "4cfLqaGOYcDNp-8-qatAy", + "type": "arrow" + }, + { + "id": "D7wcYbReGVsdKS6zekwV_", + "type": "arrow" + }, + { + "id": "U3b3OUVAc2HWH_Ov9ub1z", + "type": "arrow" + }, + { + "id": "UhzZaLorXhzHCL13ltc1S", + "type": "arrow" + }, + { + "id": "TAROyvSNF__OuhT-GJrKa", + "type": "arrow" + }, + { + "id": "nQ49xYwav9wWC5x4ItOTS", + "type": "arrow" + } + ], + "updated": 1775634605086, + "link": null, + "locked": false + }, + { + "id": "SjvoELUvaD0VFhw2JsMth", + "type": "text", + "x": 333.04761505126953, + "y": 323.57159423828125, + "width": 204.6198272705078, + "height": 50, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a7", + "roundness": null, + "seed": 1015154690, + "version": 204, + "versionNonce": 1214821634, + "isDeleted": false, + "boundElements": [], + "updated": 1775549922947, + "link": null, + "locked": false, + "text": "rpki objects\nrsync URI -> raw file", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "Q_91dvy9LwvYOonrHkccV", + "originalText": "rpki objects\nrsync URI -> raw file", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "4cfLqaGOYcDNp-8-qatAy", + "type": "arrow", + "x": 354.30159599812197, + "y": 251.158737323287, + "width": 34.035738846213974, + "height": 59.385606537597624, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a8", + "roundness": { + "type": 2 + }, + "seed": 1139616926, + "version": 86, + "versionNonce": 129460510, + "isDeleted": false, + "boundElements": [], + "updated": 1775549445449, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 34.035738846213974, + 59.385606537597624 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "9Hfy_5qe0BNkvBNovBJEt", + "focus": 0.3992095502162617, + "gap": 6.85711669921875 + }, + "endBinding": { + "elementId": "Q_91dvy9LwvYOonrHkccV", + "focus": -0.15715190225350015, + "gap": 10.000083923339844 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "D7wcYbReGVsdKS6zekwV_", + "type": "arrow", + "x": 488.05186640651806, + "y": 253.24916779489973, + "width": 37.101100465391426, + "height": 56.71970828644322, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a9", + "roundness": { + "type": 2 + }, + "seed": 2018021214, + "version": 79, + "versionNonce": 359175518, + "isDeleted": false, + "boundElements": [], + "updated": 1775549445449, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -37.101100465391426, + 56.71970828644322 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "bsldBjJQoZqKNT7_eZfR9", + "focus": -0.1523826909286624, + "gap": 9.999992370605469 + }, + "endBinding": { + "elementId": "Q_91dvy9LwvYOonrHkccV", + "focus": -0.05865512535223097, + "gap": 11.714378356933594 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "U3b3OUVAc2HWH_Ov9ub1z", + "type": "arrow", + "x": 633.1998240206987, + "y": 252.10427720014275, + "width": 102.47444980886121, + "height": 57.26813089967783, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aA", + "roundness": { + "type": 2 + }, + "seed": 501175646, + "version": 87, + "versionNonce": 1688877470, + "isDeleted": false, + "boundElements": [], + "updated": 1775549445450, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -102.47444980886121, + 57.26813089967783 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "FUj6N3igANwoKU6Hg4TPs", + "focus": -0.3297975428741643, + "gap": 6.000022888183594 + }, + "endBinding": { + "elementId": "Q_91dvy9LwvYOonrHkccV", + "focus": 0.12488352461346702, + "gap": 13.428581237792969 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "ROouj_o1lMurj5cFVXoIO", + "type": "ellipse", + "x": 309.42877197265625, + "y": 445.14300537109375, + "width": 250.14300537109375, + "height": 85, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aC", + "roundness": { + "type": 2 + }, + "seed": 891528962, + "version": 149, + "versionNonce": 749749662, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "6FWoXSgNaNGcQIaEqGRMq" + }, + { + "id": "--8mUO7U5C5M175e96RvZ", + "type": "arrow" + }, + { + "id": "TAROyvSNF__OuhT-GJrKa", + "type": "arrow" + }, + { + "id": "Waa7Z-LQYTgSrGwHRVEj1", + "type": "arrow" + }, + { + "id": "xIzKVDaSJBelyXfBMQwAt", + "type": "arrow" + }, + { + "id": "AUrv9HHvys175zzsPsO0B", + "type": "arrow" + } + ], + "updated": 1775549785467, + "link": null, + "locked": false + }, + { + "id": "6FWoXSgNaNGcQIaEqGRMq", + "type": "text", + "x": 379.74142068699894, + "y": 462.59096717066546, + "width": 109.639892578125, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aD", + "roundness": null, + "seed": 432928194, + "version": 65, + "versionNonce": 1668661662, + "isDeleted": false, + "boundElements": [], + "updated": 1775549259881, + "link": null, + "locked": false, + "text": "verification\nprocess", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ROouj_o1lMurj5cFVXoIO", + "originalText": "verification\nprocess", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "csf0mDpVbChW8YWb84V96", + "type": "rectangle", + "x": 175.21424865722656, + "y": 177.4285659790039, + "width": 115.85714721679688, + "height": 70.28572082519531, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aG", + "roundness": { + "type": 3 + }, + "seed": 1065544322, + "version": 199, + "versionNonce": 1107985118, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "t_ZJyLBuZnve5xOy3FFBl" + }, + { + "id": "UhzZaLorXhzHCL13ltc1S", + "type": "arrow" + } + ], + "updated": 1775549264381, + "link": null, + "locked": false + }, + { + "id": "t_ZJyLBuZnve5xOy3FFBl", + "type": "text", + "x": 211.04283142089844, + "y": 200.07142639160156, + "width": 44.199981689453125, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aH", + "roundness": null, + "seed": 1787185730, + "version": 114, + "versionNonce": 1746305026, + "isDeleted": false, + "boundElements": [], + "updated": 1775549267508, + "link": null, + "locked": false, + "text": "http", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "csf0mDpVbChW8YWb84V96", + "originalText": "http", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "UhzZaLorXhzHCL13ltc1S", + "type": "arrow", + "x": 243.03388522622282, + "y": 251.60638674220314, + "width": 86.66091754172083, + "height": 58.36249977349979, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aI", + "roundness": { + "type": 2 + }, + "seed": 504431262, + "version": 35, + "versionNonce": 1554863582, + "isDeleted": false, + "boundElements": [], + "updated": 1775549445450, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 86.66091754172083, + 58.36249977349979 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "csf0mDpVbChW8YWb84V96", + "focus": 0.4318631207636367, + "gap": 7.142890930175781 + }, + "endBinding": { + "elementId": "Q_91dvy9LwvYOonrHkccV", + "focus": -0.25144913901372107, + "gap": 11.714347839355469 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "NENhGCpsdLZqyXKZwI_za", + "type": "rectangle", + "x": 159.00006103515625, + "y": 463.99993896484375, + "width": 78.71435546875, + "height": 52.571502685546875, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aJ", + "roundness": { + "type": 3 + }, + "seed": 1800660866, + "version": 65, + "versionNonce": 1696151646, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "yqC5YHAea8i7LA6liQiuJ" + } + ], + "updated": 1775549764180, + "link": null, + "locked": false + }, + { + "id": "yqC5YHAea8i7LA6liQiuJ", + "type": "text", + "x": 182.0772476196289, + "y": 477.7856903076172, + "width": 32.55998229980469, + "height": 25, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aK", + "roundness": null, + "seed": 59173726, + "version": 31, + "versionNonce": 2122024066, + "isDeleted": false, + "boundElements": [], + "updated": 1775549764180, + "link": null, + "locked": false, + "text": ".tal", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "NENhGCpsdLZqyXKZwI_za", + "originalText": ".tal", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "--8mUO7U5C5M175e96RvZ", + "type": "arrow", + "x": 254.571533203125, + "y": 488.5714416503906, + "width": 45.571380615234375, + "height": 0.5714111328125, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aL", + "roundness": { + "type": 2 + }, + "seed": 1533412418, + "version": 30, + "versionNonce": 91616798, + "isDeleted": false, + "boundElements": [], + "updated": 1775549792536, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 45.571380615234375, + 0.5714111328125 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": { + "elementId": "ROouj_o1lMurj5cFVXoIO", + "focus": -0.07226358380899238, + "gap": 9.333189064749135 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "TAROyvSNF__OuhT-GJrKa", + "type": "arrow", + "x": 435.49461650498114, + "y": 386.04531141703774, + "width": 0.9418240985644388, + "height": 49.9547468006258, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aM", + "roundness": { + "type": 2 + }, + "seed": 615992898, + "version": 35, + "versionNonce": 1502141058, + "isDeleted": false, + "boundElements": [], + "updated": 1775549795052, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -0.9418240985644388, + 49.9547468006258 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "Q_91dvy9LwvYOonrHkccV", + "focus": -0.0066612019841682255, + "gap": 8.285606384277344 + }, + "endBinding": { + "elementId": "ROouj_o1lMurj5cFVXoIO", + "focus": 0.0034397212796117675, + "gap": 9.142951107048072 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "Waa7Z-LQYTgSrGwHRVEj1", + "type": "arrow", + "x": 488.2857971191406, + "y": 536.0000915527344, + "width": 110.85711669921875, + "height": 66.8570556640625, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aN", + "roundness": { + "type": 2 + }, + "seed": 176528642, + "version": 143, + "versionNonce": 544985566, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "mGs9_kOx-IfluF3BAISki" + } + ], + "updated": 1775549417220, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 24.5714111328125, + 41.14276123046875 + ], + [ + -86.28570556640625, + 66.8570556640625 + ], + [ + -78.28570556640625, + 12.57135009765625 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "ROouj_o1lMurj5cFVXoIO", + "focus": -0.1655365352193222, + "gap": 9.863661711568287 + }, + "endBinding": { + "elementId": "ROouj_o1lMurj5cFVXoIO", + "focus": 0.11818094343208067, + "gap": 19.209933210493876 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "mGs9_kOx-IfluF3BAISki", + "type": "text", + "x": 425.05514419725836, + "y": 585.025230959551, + "width": 62.63995361328125, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aNV", + "roundness": null, + "seed": 1052499330, + "version": 8, + "versionNonce": 683981698, + "isDeleted": false, + "boundElements": [], + "updated": 1775549415611, + "link": null, + "locked": false, + "text": "repeat", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "Waa7Z-LQYTgSrGwHRVEj1", + "originalText": "repeat", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "lVZy5K8i4k3P9QbvQLpZy", + "type": "rectangle", + "x": 618.500244140625, + "y": 464.57151794433594, + "width": 78.71435546875, + "height": 52.571502685546875, + "angle": 0, + "strokeColor": "#2f9e44", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aP", + "roundness": { + "type": 3 + }, + "seed": 401371266, + "version": 123, + "versionNonce": 267037278, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "9__S_WnC8Ou8MRkBrJ3kO" + }, + { + "id": "xIzKVDaSJBelyXfBMQwAt", + "type": "arrow" + } + ], + "updated": 1775549987626, + "link": null, + "locked": false + }, + { + "id": "9__S_WnC8Ou8MRkBrJ3kO", + "type": "text", + "x": 640.9174423217773, + "y": 478.3572692871094, + "width": 33.87995910644531, + "height": 25, + "angle": 0, + "strokeColor": "#2f9e44", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aQ", + "roundness": null, + "seed": 1204752450, + "version": 94, + "versionNonce": 959591042, + "isDeleted": false, + "boundElements": [], + "updated": 1775549987626, + "link": null, + "locked": false, + "text": ".ccr", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "lVZy5K8i4k3P9QbvQLpZy", + "originalText": ".ccr", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "xIzKVDaSJBelyXfBMQwAt", + "type": "arrow", + "x": 570.5715026855469, + "y": 488.5714416503906, + "width": 39.85736083984375, + "height": 0.5714111328125, + "angle": 0, + "strokeColor": "#2f9e44", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aR", + "roundness": { + "type": 2 + }, + "seed": 1814614594, + "version": 28, + "versionNonce": 570751490, + "isDeleted": false, + "boundElements": [], + "updated": 1775549991184, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 39.85736083984375, + 0.5714111328125 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "ROouj_o1lMurj5cFVXoIO", + "focus": -0.02308115859759812, + "gap": 11.016657608684747 + }, + "endBinding": { + "elementId": "lVZy5K8i4k3P9QbvQLpZy", + "focus": 0.03852744746772113, + "gap": 8.071380615234375 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "_mxjvcEwExAYsJzNc6lRP", + "type": "text", + "x": 155.71421813964844, + "y": 557.7142639160156, + "width": 140.8199005126953, + "height": 25, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aS", + "roundness": null, + "seed": 2094086366, + "version": 18, + "versionNonce": 26244382, + "isDeleted": false, + "boundElements": [ + { + "id": "AUrv9HHvys175zzsPsO0B", + "type": "arrow" + } + ], + "updated": 1775549785466, + "link": null, + "locked": false, + "text": "validation time", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "validation time", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "AUrv9HHvys175zzsPsO0B", + "type": "arrow", + "x": 308.28564453125, + "y": 560.5714416503906, + "width": 33.142974853515625, + "height": 29.142822265625, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aT", + "roundness": { + "type": 2 + }, + "seed": 1491332994, + "version": 28, + "versionNonce": 138420574, + "isDeleted": false, + "boundElements": [], + "updated": 1775549785467, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 33.142974853515625, + -29.142822265625 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "_mxjvcEwExAYsJzNc6lRP", + "focus": 0.841293765132644, + "gap": 11.75152587890625 + }, + "endBinding": { + "elementId": "ROouj_o1lMurj5cFVXoIO", + "focus": 0.24956603500751254, + "gap": 14.493878297087907 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "3ZEjgnCiP9UTRsLyR3MN-", + "type": "text", + "x": 184.2857666015625, + "y": 109.71427917480469, + "width": 188.6799774169922, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aU", + "roundness": null, + "seed": 1350238942, + "version": 71, + "versionNonce": 586831746, + "isDeleted": false, + "boundElements": [], + "updated": 1775549860779, + "link": null, + "locked": false, + "text": "RP软件运行逻辑抽象", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "RP软件运行逻辑抽象", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "_ZTdvhcyeVuzABk6tl4zP", + "type": "text", + "x": 173.428466796875, + "y": 705.714599609375, + "width": 386.199951171875, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aV", + "roundness": null, + "seed": 1998157278, + "version": 173, + "versionNonce": 847143874, + "isDeleted": false, + "boundElements": [], + "updated": 1775550736245, + "link": null, + "locked": false, + "text": "CIR: 同步协议无关的标准化输入快照描述", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "CIR: 同步协议无关的标准化输入快照描述", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "CsQAZ6ZahJco5tAegfUdB", + "type": "rectangle", + "x": 170.57118225097656, + "y": 779.4286804199219, + "width": 213.14299011230466, + "height": 197.1429443359375, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aW", + "roundness": { + "type": 3 + }, + "seed": 1043254430, + "version": 168, + "versionNonce": 1602413250, + "isDeleted": false, + "boundElements": [ + { + "id": "Q0q8W9cxIKnn3ncTw6pPh", + "type": "arrow" + } + ], + "updated": 1775550394788, + "link": null, + "locked": false + }, + { + "id": "KFXq_gFTJW_FI7Sku507K", + "type": "text", + "x": 185.4283447265625, + "y": 811.4286804199219, + "width": 179.27984619140625, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aX", + "roundness": null, + "seed": 1247464734, + "version": 87, + "versionNonce": 1198493598, + "isDeleted": false, + "boundElements": [], + "updated": 1775550345153, + "link": null, + "locked": false, + "text": "* Object map:\n- rsync uri -> hash", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "* Object map:\n- rsync uri -> hash", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "ZPJx7M_nxOAiY5ELIIiYk", + "type": "text", + "x": 187.7140655517578, + "y": 886.2859191894531, + "width": 96.11994934082031, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aY", + "roundness": null, + "seed": 1936369922, + "version": 51, + "versionNonce": 676850654, + "isDeleted": false, + "boundElements": [], + "updated": 1775550345153, + "link": null, + "locked": false, + "text": "* TAL list", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "* TAL list", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "NzW6gWlkch-NEPcbgidIr", + "type": "text", + "x": 190.51122283935547, + "y": 932.0718078613281, + "width": 159.3199005126953, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aZ", + "roundness": null, + "seed": 978892190, + "version": 108, + "versionNonce": 1013628958, + "isDeleted": false, + "boundElements": [], + "updated": 1775550345153, + "link": null, + "locked": false, + "text": "* validation time", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "* validation time", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "AMzQgbfRW4U0yYfipgwpH", + "type": "arrow", + "x": 101.99972534179688, + "y": 1042.2857360839844, + "width": 562.857177734375, + "height": 1.14300537109375, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aa", + "roundness": { + "type": 2 + }, + "seed": 986160194, + "version": 76, + "versionNonce": 64256706, + "isDeleted": false, + "boundElements": [], + "updated": 1775550288289, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 562.857177734375, + 1.14300537109375 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "BSqPf506kHvHRIJbG2zkQ", + "type": "ellipse", + "x": 153.4282989501953, + "y": 1037.1428527832031, + "width": 16.00018310546875, + "height": 16.00018310546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ab", + "roundness": { + "type": 2 + }, + "seed": 830607006, + "version": 54, + "versionNonce": 596349634, + "isDeleted": false, + "boundElements": [ + { + "id": "Q0q8W9cxIKnn3ncTw6pPh", + "type": "arrow" + }, + { + "id": "HZGt0UlvEqNKZxEr5l1El", + "type": "arrow" + } + ], + "updated": 1775550700838, + "link": null, + "locked": false + }, + { + "id": "ZDdMFPBH9FksDHD1bMTaq", + "type": "ellipse", + "x": 205.42831420898438, + "y": 1036.571533203125, + "width": 16.00018310546875, + "height": 16.00018310546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ac", + "roundness": { + "type": 2 + }, + "seed": 1842901470, + "version": 105, + "versionNonce": 2062741250, + "isDeleted": false, + "boundElements": [ + { + "id": "nCggvDKkHxY9KIeCD35RJ", + "type": "arrow" + }, + { + "id": "lVSiTjtehGGqHjsp7snk5", + "type": "arrow" + } + ], + "updated": 1775550722507, + "link": null, + "locked": false + }, + { + "id": "vzLGPfV9kWwxLF9gWo61U", + "type": "ellipse", + "x": 265.71398162841797, + "y": 1037.4285430908203, + "width": 16.00018310546875, + "height": 16.00018310546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ad", + "roundness": { + "type": 2 + }, + "seed": 1391344258, + "version": 136, + "versionNonce": 1517932510, + "isDeleted": false, + "boundElements": [ + { + "id": "DubMtLrk928S6qYncHmwb", + "type": "arrow" + } + ], + "updated": 1775550406377, + "link": null, + "locked": false + }, + { + "id": "i6KiiEmS1buHArCUOKvl-", + "type": "ellipse", + "x": 317.71399688720703, + "y": 1036.8572235107422, + "width": 16.00018310546875, + "height": 16.00018310546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ae", + "roundness": { + "type": 2 + }, + "seed": 336042562, + "version": 186, + "versionNonce": 1014745538, + "isDeleted": false, + "boundElements": [], + "updated": 1775550318050, + "link": null, + "locked": false + }, + { + "id": "6wt5BEcqpa2ahY4JRzmgI", + "type": "ellipse", + "x": 368.42822647094727, + "y": 1035.5713424682617, + "width": 16.00018310546875, + "height": 16.00018310546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "af", + "roundness": { + "type": 2 + }, + "seed": 1694856194, + "version": 136, + "versionNonce": 1875843650, + "isDeleted": false, + "boundElements": [], + "updated": 1775550327932, + "link": null, + "locked": false + }, + { + "id": "8qRVAjwlJef1A9HDJnI5T", + "type": "ellipse", + "x": 420.4282417297363, + "y": 1035.0000228881836, + "width": 16.00018310546875, + "height": 16.00018310546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ag", + "roundness": { + "type": 2 + }, + "seed": 570139586, + "version": 187, + "versionNonce": 1765179906, + "isDeleted": false, + "boundElements": [], + "updated": 1775550327932, + "link": null, + "locked": false + }, + { + "id": "dQemVv_pNK94naPAI_zgt", + "type": "ellipse", + "x": 480.7139091491699, + "y": 1035.857032775879, + "width": 16.00018310546875, + "height": 16.00018310546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ah", + "roundness": { + "type": 2 + }, + "seed": 135650178, + "version": 221, + "versionNonce": 387541854, + "isDeleted": false, + "boundElements": [ + { + "id": "6BvUjCUsNaUrLpWdE5RhQ", + "type": "arrow" + }, + { + "id": "H6zIxJt786nIvUjmEpngn", + "type": "arrow" + } + ], + "updated": 1775550726787, + "link": null, + "locked": false + }, + { + "id": "ipc4iZUuARANxX5dKCNdt", + "type": "ellipse", + "x": 532.713924407959, + "y": 1035.2857131958008, + "width": 16.00018310546875, + "height": 16.00018310546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ai", + "roundness": { + "type": 2 + }, + "seed": 1704139586, + "version": 272, + "versionNonce": 1535254878, + "isDeleted": false, + "boundElements": [ + { + "id": "fADvTV9riz93Y5kguyeSE", + "type": "arrow" + }, + { + "id": "5Uvl_Oh7Np0fcx3u82pkD", + "type": "arrow" + } + ], + "updated": 1775550716705, + "link": null, + "locked": false + }, + { + "id": "6bn4tfe3JsJ-T3-z1lR1i", + "type": "rectangle", + "x": 396.5712356567383, + "y": 782.5714721679688, + "width": 26.285720825195302, + "height": 193.7143249511719, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aj", + "roundness": { + "type": 3 + }, + "seed": 409734366, + "version": 315, + "versionNonce": 167353886, + "isDeleted": false, + "boundElements": [ + { + "id": "nCggvDKkHxY9KIeCD35RJ", + "type": "arrow" + } + ], + "updated": 1775550400388, + "link": null, + "locked": false + }, + { + "id": "3EFdwnPQIrgEibUViJSa5", + "type": "rectangle", + "x": 430.57115936279297, + "y": 782.5714569091797, + "width": 26.285720825195302, + "height": 193.7143249511719, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ak", + "roundness": { + "type": 3 + }, + "seed": 1500236354, + "version": 353, + "versionNonce": 496103518, + "isDeleted": false, + "boundElements": [ + { + "id": "DubMtLrk928S6qYncHmwb", + "type": "arrow" + } + ], + "updated": 1775550406377, + "link": null, + "locked": false + }, + { + "id": "KTFkxNeM36O4gieNwHnda", + "type": "rectangle", + "x": 503.2854919433594, + "y": 783.142936706543, + "width": 26.285720825195302, + "height": 193.7143249511719, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "al", + "roundness": { + "type": 3 + }, + "seed": 1652409346, + "version": 363, + "versionNonce": 1897142530, + "isDeleted": false, + "boundElements": [ + { + "id": "6BvUjCUsNaUrLpWdE5RhQ", + "type": "arrow" + } + ], + "updated": 1775550711877, + "link": null, + "locked": false + }, + { + "id": "j0aoETMDJlS5_GPtYtoel", + "type": "rectangle", + "x": 537.2854156494141, + "y": 783.1429214477539, + "width": 26.285720825195302, + "height": 193.7143249511719, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "am", + "roundness": { + "type": 3 + }, + "seed": 1810494402, + "version": 401, + "versionNonce": 1832224322, + "isDeleted": false, + "boundElements": [ + { + "id": "fADvTV9riz93Y5kguyeSE", + "type": "arrow" + } + ], + "updated": 1775550708202, + "link": null, + "locked": false + }, + { + "id": "KBAil9R1ipUeEttJK3ETd", + "type": "text", + "x": 471.1426086425781, + "y": 878.8572692871094, + "width": 16.439987182617188, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "an", + "roundness": null, + "seed": 8651842, + "version": 20, + "versionNonce": 1107928770, + "isDeleted": false, + "boundElements": [], + "updated": 1775550389488, + "link": null, + "locked": false, + "text": "...", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "...", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "Q0q8W9cxIKnn3ncTw6pPh", + "type": "arrow", + "x": 165.999755859375, + "y": 1029.7143859863281, + "width": 58.85711669921875, + "height": 45.1429443359375, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ao", + "roundness": { + "type": 2 + }, + "seed": 641490498, + "version": 23, + "versionNonce": 1514705666, + "isDeleted": false, + "boundElements": [], + "updated": 1775550394788, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 58.85711669921875, + -45.1429443359375 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "BSqPf506kHvHRIJbG2zkQ", + "focus": -0.8433979870643445, + "gap": 8.091452623304468 + }, + "endBinding": { + "elementId": "CsQAZ6ZahJco5tAegfUdB", + "focus": -0.36863253386932304, + "gap": 7.99981689453125 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "nCggvDKkHxY9KIeCD35RJ", + "type": "arrow", + "x": 220.85687255859375, + "y": 1031.4286804199219, + "width": 178.85726928710938, + "height": 50.2857666015625, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ap", + "roundness": { + "type": 2 + }, + "seed": 1805032002, + "version": 54, + "versionNonce": 1172998622, + "isDeleted": false, + "boundElements": [], + "updated": 1775550400388, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 178.85726928710938, + -50.2857666015625 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "ZDdMFPBH9FksDHD1bMTaq", + "focus": -1.0785525035141508, + "gap": 7.096898371413615 + }, + "endBinding": { + "elementId": "6bn4tfe3JsJ-T3-z1lR1i", + "focus": -0.983595682902412, + "gap": 5.226629226539765 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "DubMtLrk928S6qYncHmwb", + "type": "arrow", + "x": 287.7140197753906, + "y": 1034.2857360839844, + "width": 149.71429443359375, + "height": 50.28564453125, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aq", + "roundness": { + "type": 2 + }, + "seed": 698560158, + "version": 41, + "versionNonce": 414196766, + "isDeleted": false, + "boundElements": [], + "updated": 1775550406377, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 149.71429443359375, + -50.28564453125 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "vzLGPfV9kWwxLF9gWo61U", + "focus": -0.6026525732912275, + "gap": 9.89299909562479 + }, + "endBinding": { + "elementId": "3EFdwnPQIrgEibUViJSa5", + "focus": -1.0117376882966893, + "gap": 7.7143096923828125 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "2eW7cIFIEXAceNf9fZO3l", + "type": "text", + "x": 680.8570251464844, + "y": 1031.4286804199219, + "width": 40, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#1e1e1e", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ar", + "roundness": null, + "seed": 142340318, + "version": 44, + "versionNonce": 596062238, + "isDeleted": false, + "boundElements": [], + "updated": 1775550421865, + "link": null, + "locked": false, + "text": "时间", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "时间", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "GPS1f2piQJEbYYThqZjwv", + "type": "ellipse", + "x": 232.28546142578125, + "y": 1114.8572082519531, + "width": 316.00006103515625, + "height": 78.28570556640625, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "as", + "roundness": { + "type": 2 + }, + "seed": 645152926, + "version": 79, + "versionNonce": 1017162718, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "1iXsQF4-vktyx7fH0dRjF" + }, + { + "id": "HZGt0UlvEqNKZxEr5l1El", + "type": "arrow" + }, + { + "id": "5Uvl_Oh7Np0fcx3u82pkD", + "type": "arrow" + }, + { + "id": "lVSiTjtehGGqHjsp7snk5", + "type": "arrow" + }, + { + "id": "H6zIxJt786nIvUjmEpngn", + "type": "arrow" + } + ], + "updated": 1775550726787, + "link": null, + "locked": false + }, + { + "id": "1iXsQF4-vktyx7fH0dRjF", + "type": "text", + "x": 302.3326642443156, + "y": 1141.3218843971665, + "width": 175.45986938476562, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "at", + "roundness": null, + "seed": 312589854, + "version": 21, + "versionNonce": 1892553630, + "isDeleted": false, + "boundElements": [], + "updated": 1775550444316, + "link": null, + "locked": false, + "text": "hash -> raw bytes", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "GPS1f2piQJEbYYThqZjwv", + "originalText": "hash -> raw bytes", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "aiZAsBTIcmwvJN2z1Ys-h", + "type": "text", + "x": 295.7139892578125, + "y": 1206.2856140136719, + "width": 191.23988342285156, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "au", + "roundness": null, + "seed": 137889026, + "version": 66, + "versionNonce": 620897694, + "isDeleted": false, + "boundElements": [], + "updated": 1775550464125, + "link": null, + "locked": false, + "text": "shared objects pool", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "shared objects pool", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "ukwIA7jin5bkKG8V5F6wk", + "type": "text", + "x": 271.7140197753906, + "y": 750.8572082519531, + "width": 28.679962158203125, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "av", + "roundness": null, + "seed": 2069275330, + "version": 17, + "versionNonce": 1091593950, + "isDeleted": false, + "boundElements": [], + "updated": 1775550688554, + "link": null, + "locked": false, + "text": ".cir", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": ".cir", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "zhxAVeylfWbt7_VQ2a3c5", + "type": "text", + "x": 394.5170135498047, + "y": 755.5000610351562, + "width": 28.679962158203125, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aw", + "roundness": null, + "seed": 581879646, + "version": 41, + "versionNonce": 813406210, + "isDeleted": false, + "boundElements": [], + "updated": 1775550692253, + "link": null, + "locked": false, + "text": ".cir", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": ".cir", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "HZGt0UlvEqNKZxEr5l1El", + "type": "arrow", + "x": 167.14260864257812, + "y": 1061.7143859863281, + "width": 109.71426391601562, + "height": 57.71435546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ax", + "roundness": { + "type": 2 + }, + "seed": 2136145794, + "version": 37, + "versionNonce": 1339026050, + "isDeleted": false, + "boundElements": [], + "updated": 1775550700838, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 109.71426391601562, + 57.71435546875 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "BSqPf506kHvHRIJbG2zkQ", + "focus": 1.1111556665373699, + "gap": 9.528883526253797 + }, + "endBinding": { + "elementId": "GPS1f2piQJEbYYThqZjwv", + "focus": -0.20527926865301901, + "gap": 7.101050521961909 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "fADvTV9riz93Y5kguyeSE", + "type": "arrow", + "x": 543.1426086425781, + "y": 1030.8571472167969, + "width": 4.00006103515625, + "height": 38.8570556640625, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "ay", + "roundness": { + "type": 2 + }, + "seed": 183706050, + "version": 23, + "versionNonce": 1449764482, + "isDeleted": false, + "boundElements": [], + "updated": 1775550708202, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 4.00006103515625, + -38.8570556640625 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "ipc4iZUuARANxX5dKCNdt", + "focus": 0.13023525831313013, + "gap": 4.663620006509975 + }, + "endBinding": { + "elementId": "j0aoETMDJlS5_GPtYtoel", + "focus": -0.3566730834349062, + "gap": 15.142845153808594 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "6BvUjCUsNaUrLpWdE5RhQ", + "type": "arrow", + "x": 489.9997253417969, + "y": 1029.7143859863281, + "width": 23.4285888671875, + "height": 41.71429443359375, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "az", + "roundness": { + "type": 2 + }, + "seed": 1955064606, + "version": 34, + "versionNonce": 290689346, + "isDeleted": false, + "boundElements": [], + "updated": 1775550711877, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 23.4285888671875, + -41.71429443359375 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "dQemVv_pNK94naPAI_zgt", + "focus": -0.5328827066362748, + "gap": 6.200969522691278 + }, + "endBinding": { + "elementId": "KTFkxNeM36O4gieNwHnda", + "focus": -0.8536528937265017, + "gap": 11.142829895019531 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "5Uvl_Oh7Np0fcx3u82pkD", + "type": "arrow", + "x": 531.1426696777344, + "y": 1060.5714416503906, + "width": 63.4285888671875, + "height": 45.71429443359375, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b00", + "roundness": { + "type": 2 + }, + "seed": 1115635842, + "version": 38, + "versionNonce": 1202865566, + "isDeleted": false, + "boundElements": [], + "updated": 1775550716705, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -63.4285888671875, + 45.71429443359375 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "ipc4iZUuARANxX5dKCNdt", + "focus": -0.7545712720794168, + "gap": 11.758549918052536 + }, + "endBinding": { + "elementId": "GPS1f2piQJEbYYThqZjwv", + "focus": 0.05287041648151867, + "gap": 13.467755555929925 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "lVSiTjtehGGqHjsp7snk5", + "type": "arrow", + "x": 223.71401977539062, + "y": 1057.1428527832031, + "width": 88, + "height": 46.285888671875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b01", + "roundness": { + "type": 2 + }, + "seed": 2014282334, + "version": 28, + "versionNonce": 847363778, + "isDeleted": false, + "boundElements": [], + "updated": 1775550722507, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 88, + 46.285888671875 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "ZDdMFPBH9FksDHD1bMTaq", + "focus": 0.5866048879174653, + "gap": 8.242741628509478 + }, + "endBinding": { + "elementId": "GPS1f2piQJEbYYThqZjwv", + "focus": 0.07562234192678592, + "gap": 16.452622532318998 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "H6zIxJt786nIvUjmEpngn", + "type": "arrow", + "x": 480.8569030761719, + "y": 1058.2857360839844, + "width": 60.571380615234375, + "height": 41.71435546875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b02", + "roundness": { + "type": 2 + }, + "seed": 1164740318, + "version": 35, + "versionNonce": 1708150686, + "isDeleted": false, + "boundElements": [], + "updated": 1775550726787, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -60.571380615234375, + 41.71435546875 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "dQemVv_pNK94naPAI_zgt", + "focus": -0.6674939991750416, + "gap": 8.42911476672984 + }, + "endBinding": { + "elementId": "GPS1f2piQJEbYYThqZjwv", + "focus": -0.22533636094805828, + "gap": 15.551790093977932 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "SDQscdb0vh2Hw7M1o2CnO", + "type": "text", + "x": 147.7684555053711, + "y": 1286.2138671875, + "width": 120, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b03", + "roundness": null, + "seed": 1879561697, + "version": 44, + "versionNonce": 1696827951, + "isDeleted": false, + "boundElements": [], + "updated": 1775553197605, + "link": null, + "locked": false, + "text": "黑盒回放机制", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "黑盒回放机制", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "qyhIZmvBoOEsPvAqMJmtD", + "type": "rectangle", + "x": 198.62566375732422, + "y": 1494.2138671875, + "width": 69.71427917480469, + "height": 35, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b04", + "roundness": { + "type": 3 + }, + "seed": 781978223, + "version": 130, + "versionNonce": 819515727, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "rZ7Xt_dJCGlP6_cbEqyBE" + }, + { + "id": "DgkNwMY4R-6tx9bz7nik7", + "type": "arrow" + }, + { + "id": "C1qg4_pz7nsI8shB3JfCy", + "type": "arrow" + }, + { + "id": "U5sYDdUEunSJB4KAV5SnG", + "type": "arrow" + } + ], + "updated": 1775553580759, + "link": null, + "locked": false + }, + { + "id": "rZ7Xt_dJCGlP6_cbEqyBE", + "type": "text", + "x": 214.38282012939453, + "y": 1499.2138671875, + "width": 38.19996643066406, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b05", + "roundness": null, + "seed": 295373217, + "version": 100, + "versionNonce": 1386464783, + "isDeleted": false, + "boundElements": [], + "updated": 1775553310564, + "link": null, + "locked": false, + "text": "CIR", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "qyhIZmvBoOEsPvAqMJmtD", + "originalText": "CIR", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "sr26o78tTWzG1l949OZat", + "type": "ellipse", + "x": 115.7684097290039, + "y": 1358.7852172851562, + "width": 194.85720825195312, + "height": 85, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b06", + "roundness": { + "type": 2 + }, + "seed": 901402575, + "version": 153, + "versionNonce": 429146959, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "neh3dj0GYkYCWJneZ7BlY" + }, + { + "id": "FGF8FoSJYfBR649pKyabm", + "type": "arrow" + } + ], + "updated": 1775553308360, + "link": null, + "locked": false + }, + { + "id": "neh3dj0GYkYCWJneZ7BlY", + "type": "text", + "x": 153.8846195445956, + "y": 1376.233179084728, + "width": 118.83993530273438, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b07", + "roundness": null, + "seed": 1952887151, + "version": 121, + "versionNonce": 1609367919, + "isDeleted": false, + "boundElements": [], + "updated": 1775553308360, + "link": null, + "locked": false, + "text": "shared\nobjects pool", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "sr26o78tTWzG1l949OZat", + "originalText": "shared objects pool", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "eaXjBzty9JCbz0LcDxQJx", + "type": "rectangle", + "x": 380.3398666381836, + "y": 1402.78564453125, + "width": 169.14288330078125, + "height": 97.71435546875, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b08", + "roundness": { + "type": 3 + }, + "seed": 102247905, + "version": 98, + "versionNonce": 1284046369, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "tIgLturVcB00zWQ2HvSey" + }, + { + "id": "DgkNwMY4R-6tx9bz7nik7", + "type": "arrow" + }, + { + "id": "FGF8FoSJYfBR649pKyabm", + "type": "arrow" + }, + { + "id": "VHkIwivdfk63dVuBQu8oX", + "type": "arrow" + } + ], + "updated": 1775553594394, + "link": null, + "locked": false + }, + { + "id": "tIgLturVcB00zWQ2HvSey", + "type": "text", + "x": 407.97135162353516, + "y": 1439.142822265625, + "width": 113.87991333007812, + "height": 25, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b09", + "roundness": null, + "seed": 829596335, + "version": 48, + "versionNonce": 1529229423, + "isDeleted": false, + "boundElements": [], + "updated": 1775553594394, + "link": null, + "locked": false, + "text": "local file dir", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "eaXjBzty9JCbz0LcDxQJx", + "originalText": "local file dir", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "DgkNwMY4R-6tx9bz7nik7", + "type": "arrow", + "x": 268.60659286791906, + "y": 1498.0691655860412, + "width": 90.1818669154955, + "height": 42.37620591148607, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0A", + "roundness": { + "type": 2 + }, + "seed": 171626209, + "version": 127, + "versionNonce": 652596897, + "isDeleted": false, + "boundElements": [], + "updated": 1775553586434, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 90.1818669154955, + -42.37620591148607 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "qyhIZmvBoOEsPvAqMJmtD", + "focus": 0.08441351465398138, + "gap": 1.1392008200439665 + }, + "endBinding": { + "elementId": "eaXjBzty9JCbz0LcDxQJx", + "focus": 0.517135564884785, + "gap": 21.551406854769027 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "FGF8FoSJYfBR649pKyabm", + "type": "arrow", + "x": 307.67433635263365, + "y": 1423.936335936302, + "width": 58.75163983016148, + "height": 2.133440397075674, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0B", + "roundness": { + "type": 2 + }, + "seed": 1776073697, + "version": 77, + "versionNonce": 1959328655, + "isDeleted": false, + "boundElements": [], + "updated": 1775553308362, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 58.75163983016148, + 2.133440397075674 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "sr26o78tTWzG1l949OZat", + "focus": 0.41112228464056216, + "gap": 8.182854355418625 + }, + "endBinding": { + "elementId": "eaXjBzty9JCbz0LcDxQJx", + "focus": 0.4449077800475655, + "gap": 16.571380615234375 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "UDO6YGUwh_bwini6l9P_J", + "type": "ellipse", + "x": 363.76863861083984, + "y": 1689.0713500976562, + "width": 195.99996948242188, + "height": 82.2857666015625, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0C", + "roundness": { + "type": 2 + }, + "seed": 1414380833, + "version": 96, + "versionNonce": 855552257, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "sDVmHEMzsTtPEDvgSBWJD" + }, + { + "id": "8WSfkbV_CWcQ6ozuUMqys", + "type": "arrow" + }, + { + "id": "kXCQWr9cnCQuWUpkjhjdH", + "type": "arrow" + }, + { + "id": "F91aEpCc5O8O80eV3XO0x", + "type": "arrow" + }, + { + "id": "f5vwoVaYhd8ZAaFlO5s41", + "type": "arrow" + } + ], + "updated": 1775553745505, + "link": null, + "locked": false + }, + { + "id": "sDVmHEMzsTtPEDvgSBWJD", + "type": "text", + "x": 447.63218087686624, + "y": 1717.6218216188884, + "width": 28.679977416992188, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0D", + "roundness": null, + "seed": 708565569, + "version": 55, + "versionNonce": 374747137, + "isDeleted": false, + "boundElements": [], + "updated": 1775553394149, + "link": null, + "locked": false, + "text": "RP", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "UDO6YGUwh_bwini6l9P_J", + "originalText": "RP", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "P_Dk7wJCtZ-pIssk4GXbV", + "type": "text", + "x": 16.33994483947754, + "y": 1675.3571166992188, + "width": 301.71439933776855, + "height": 150, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0E", + "roundness": null, + "seed": 1963381327, + "version": 302, + "versionNonce": 1135428559, + "isDeleted": false, + "boundElements": [], + "updated": 1775553756195, + "link": null, + "locked": false, + "text": "args:\n--disable-rrdp\n--rsync-proc path/to/wrapper\n--tal xxx.tal,xxx.tal\n--validationtime\n", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "args:\n--disable-rrdp\n--rsync-proc path/to/wrapper\n--tal xxx.tal,xxx.tal\n--validationtime\n", + "autoResize": false, + "lineHeight": 1.25 + }, + { + "id": "8WSfkbV_CWcQ6ozuUMqys", + "type": "arrow", + "x": 288.91136932373047, + "y": 1722.7854614257812, + "width": 60.0112731554899, + "height": 1.3176372727152739, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0F", + "roundness": { + "type": 2 + }, + "seed": 1598567407, + "version": 153, + "versionNonce": 1293896975, + "isDeleted": false, + "boundElements": [], + "updated": 1775553394233, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 60.0112731554899, + 1.3176372727152739 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": { + "elementId": "UDO6YGUwh_bwini6l9P_J", + "focus": 0.08392341636740885, + "gap": 15.419991787304513 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "ldykGrkUhuhscwkF-GeCT", + "type": "ellipse", + "x": 374.62560272216797, + "y": 1557.6425170898438, + "width": 180.00015258789062, + "height": 85, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0H", + "roundness": { + "type": 2 + }, + "seed": 340597953, + "version": 121, + "versionNonce": 1254481263, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "iMRxuSSjGU_XFR8Bn1vCa" + }, + { + "id": "kXCQWr9cnCQuWUpkjhjdH", + "type": "arrow" + }, + { + "id": "VHkIwivdfk63dVuBQu8oX", + "type": "arrow" + } + ], + "updated": 1775553408032, + "link": null, + "locked": false + }, + { + "id": "iMRxuSSjGU_XFR8Bn1vCa", + "type": "text", + "x": 427.84605351868214, + "y": 1575.0904788894154, + "width": 73.27992248535156, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0I", + "roundness": null, + "seed": 906731439, + "version": 64, + "versionNonce": 817367471, + "isDeleted": false, + "boundElements": [], + "updated": 1775553396120, + "link": null, + "locked": false, + "text": "rsync\nwrapper", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ldykGrkUhuhscwkF-GeCT", + "originalText": "rsync wrapper", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "kXCQWr9cnCQuWUpkjhjdH", + "type": "arrow", + "x": 466.05416107177734, + "y": 1682.7852783203125, + "width": 1.142974853515625, + "height": 38.28564453125, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0J", + "roundness": { + "type": 2 + }, + "seed": 1203153391, + "version": 22, + "versionNonce": 1387452161, + "isDeleted": false, + "boundElements": [], + "updated": 1775553401920, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 1.142974853515625, + -38.28564453125 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "UDO6YGUwh_bwini6l9P_J", + "focus": 0.02891917721974869, + "gap": 6.324390069104857 + }, + "endBinding": { + "elementId": "ldykGrkUhuhscwkF-GeCT", + "focus": -0.042683654418113386, + "gap": 1.8742984676565957 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "VHkIwivdfk63dVuBQu8oX", + "type": "arrow", + "x": 466.6256332397461, + "y": 1553.6425170898438, + "width": 0.00006103515625, + "height": 44.57135009765625, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0K", + "roundness": { + "type": 2 + }, + "seed": 95603361, + "version": 24, + "versionNonce": 1210472335, + "isDeleted": false, + "boundElements": [], + "updated": 1775553408033, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0.00006103515625, + -44.57135009765625 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "ldykGrkUhuhscwkF-GeCT", + "focus": 0.02222097287625618, + "gap": 4.010278281018391 + }, + "endBinding": { + "elementId": "eaXjBzty9JCbz0LcDxQJx", + "focus": -0.020272366626914323, + "gap": 8.5711669921875 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "F91aEpCc5O8O80eV3XO0x", + "type": "arrow", + "x": 573.4827499389648, + "y": 1729.6425170898438, + "width": 57.14300537109375, + "height": 0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0L", + "roundness": { + "type": 2 + }, + "seed": 148909551, + "version": 25, + "versionNonce": 370774785, + "isDeleted": false, + "boundElements": [], + "updated": 1775553427081, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 57.14300537109375, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "UDO6YGUwh_bwini6l9P_J", + "focus": -0.013895873665783614, + "gap": 13.719415335898901 + }, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "8ZdiUjDmEiotwvYZq_i2G", + "type": "rectangle", + "x": 643.7684555053711, + "y": 1706.7852783203125, + "width": 72.57159423828125, + "height": 60, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0M", + "roundness": { + "type": 3 + }, + "seed": 1464010401, + "version": 60, + "versionNonce": 736604609, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "wWqkdYxqemO62WpysoIBR" + } + ], + "updated": 1775553440615, + "link": null, + "locked": false + }, + { + "id": "wWqkdYxqemO62WpysoIBR", + "type": "text", + "x": 651.9842758178711, + "y": 1711.7852783203125, + "width": 56.13995361328125, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0N", + "roundness": null, + "seed": 611795649, + "version": 14, + "versionNonce": 1949721825, + "isDeleted": false, + "boundElements": [], + "updated": 1775553443157, + "link": null, + "locked": false, + "text": ".ccr\n/ .csv", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "8ZdiUjDmEiotwvYZq_i2G", + "originalText": ".ccr\n/ .csv", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "gOWaic6IGVJxYn2fL_LKZ", + "type": "rectangle", + "x": 26.625526428222656, + "y": 1595.9284057617188, + "width": 117.71426391601562, + "height": 35, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0O", + "roundness": { + "type": 3 + }, + "seed": 115167791, + "version": 52, + "versionNonce": 1978044929, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "k3pswGHnkCI1w5MSatYX3" + }, + { + "id": "C1qg4_pz7nsI8shB3JfCy", + "type": "arrow" + } + ], + "updated": 1775553594394, + "link": null, + "locked": false + }, + { + "id": "k3pswGHnkCI1w5MSatYX3", + "type": "text", + "x": 44.74269104003906, + "y": 1600.9284057617188, + "width": 81.47993469238281, + "height": 25, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0P", + "roundness": null, + "seed": 2069313103, + "version": 35, + "versionNonce": 788810383, + "isDeleted": false, + "boundElements": [], + "updated": 1775553594394, + "link": null, + "locked": false, + "text": ".tal files", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "gOWaic6IGVJxYn2fL_LKZ", + "originalText": ".tal files", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "i1uSSsHroBMM0_orqevuB", + "type": "text", + "x": 177.48274993896484, + "y": 1604.4998168945312, + "width": 140.8199005126953, + "height": 25, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0S", + "roundness": null, + "seed": 59395169, + "version": 41, + "versionNonce": 1780918753, + "isDeleted": false, + "boundElements": [ + { + "id": "U5sYDdUEunSJB4KAV5SnG", + "type": "arrow" + } + ], + "updated": 1775553594394, + "link": null, + "locked": false, + "text": "validation time", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "validation time", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "C1qg4_pz7nsI8shB3JfCy", + "type": "arrow", + "x": 210.62545013427734, + "y": 1536.4995727539062, + "width": 83.42849731445312, + "height": 48.571533203125, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0T", + "roundness": { + "type": 2 + }, + "seed": 2038739745, + "version": 25, + "versionNonce": 67182575, + "isDeleted": false, + "boundElements": [], + "updated": 1775553586434, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -83.42849731445312, + 48.571533203125 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "qyhIZmvBoOEsPvAqMJmtD", + "focus": -0.30371018707602837, + "gap": 7.28570556640625 + }, + "endBinding": { + "elementId": "gOWaic6IGVJxYn2fL_LKZ", + "focus": -0.07865176876110261, + "gap": 10.8572998046875 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "U5sYDdUEunSJB4KAV5SnG", + "type": "arrow", + "x": 236.91121673583984, + "y": 1538.7852783203125, + "width": 8.571502685546875, + "height": 56.57147216796875, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0U", + "roundness": { + "type": 2 + }, + "seed": 1483738479, + "version": 30, + "versionNonce": 269769345, + "isDeleted": false, + "boundElements": [], + "updated": 1775553586434, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 8.571502685546875, + 56.57147216796875 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "qyhIZmvBoOEsPvAqMJmtD", + "focus": 0.01795179015070134, + "gap": 9.5714111328125 + }, + "endBinding": { + "elementId": "i1uSSsHroBMM0_orqevuB", + "focus": 0.012022829810546035, + "gap": 9.14306640625 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "OrAvm_xJwAeu0TkwtiXOW", + "type": "ellipse", + "x": 384.3397750854492, + "y": 1828.4999084472656, + "width": 161.14288330078125, + "height": 49, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0V", + "roundness": { + "type": 2 + }, + "seed": 956236015, + "version": 78, + "versionNonce": 475155777, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "6M6ZqBF6cjIUn82Txa722" + }, + { + "id": "f5vwoVaYhd8ZAaFlO5s41", + "type": "arrow" + } + ], + "updated": 1775553745504, + "link": null, + "locked": false + }, + { + "id": "6M6ZqBF6cjIUn82Txa722", + "type": "text", + "x": 423.53864059596617, + "y": 1840.6757923081952, + "width": 82.7999267578125, + "height": 25, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0W", + "roundness": null, + "seed": 1059279727, + "version": 51, + "versionNonce": 693869441, + "isDeleted": false, + "boundElements": [], + "updated": 1775553740488, + "link": null, + "locked": false, + "text": "faketime", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "OrAvm_xJwAeu0TkwtiXOW", + "originalText": "faketime", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "f5vwoVaYhd8ZAaFlO5s41", + "type": "arrow", + "x": 463.1969528198242, + "y": 1814.7853698730469, + "width": 0.000030517578125, + "height": 36, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0X", + "roundness": { + "type": 2 + }, + "seed": 1748594497, + "version": 31, + "versionNonce": 435748129, + "isDeleted": false, + "boundElements": [], + "updated": 1775553745505, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0.000030517578125, + -36 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "OrAvm_xJwAeu0TkwtiXOW", + "focus": -0.021276718324412577, + "gap": 13.71981144617794 + }, + "endBinding": { + "elementId": "UDO6YGUwh_bwini6l9P_J", + "focus": -0.014575519119027448, + "gap": 7.432488595910063 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "XtYcwBsJvxiG_Wg4KZSBE", + "type": "rectangle", + "x": 751.2687606811523, + "y": 179.64289093017578, + "width": 115.85714721679688, + "height": 70.28572082519531, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0Y", + "roundness": { + "type": 3 + }, + "seed": 197296112, + "version": 164, + "versionNonce": 952167408, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "mtLZ4j3THV8E5jYmoFCAj" + }, + { + "id": "nQ49xYwav9wWC5x4ItOTS", + "type": "arrow" + } + ], + "updated": 1775634605086, + "link": null, + "locked": false + }, + { + "id": "mtLZ4j3THV8E5jYmoFCAj", + "type": "text", + "x": 782.3173599243164, + "y": 202.28575134277344, + "width": 53.75994873046875, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0Z", + "roundness": null, + "seed": 1095565808, + "version": 76, + "versionNonce": 1024688112, + "isDeleted": false, + "boundElements": [], + "updated": 1775634599727, + "link": null, + "locked": false, + "text": "cache", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "XtYcwBsJvxiG_Wg4KZSBE", + "originalText": "cache", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "nQ49xYwav9wWC5x4ItOTS", + "type": "arrow", + "x": 777.1971817016602, + "y": 254.21424865722656, + "width": 185.714111328125, + "height": 66.28570556640625, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0a", + "roundness": { + "type": 2 + }, + "seed": 2001224688, + "version": 39, + "versionNonce": 463334896, + "isDeleted": false, + "boundElements": null, + "updated": 1775634605086, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -185.714111328125, + 66.28570556640625 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "XtYcwBsJvxiG_Wg4KZSBE", + "focus": -0.5017455415505067, + "gap": 4.285636901855469 + }, + "endBinding": { + "elementId": "Q_91dvy9LwvYOonrHkccV", + "focus": 0.325097986137544, + "gap": 17.477001640397337 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + } + ], + "appState": { + "gridSize": 20, + "gridStep": 5, + "gridModeEnabled": false, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file diff --git a/src/bin/cir_extract_inputs.rs b/src/bin/cir_extract_inputs.rs new file mode 100644 index 0000000..147e18e --- /dev/null +++ b/src/bin/cir_extract_inputs.rs @@ -0,0 +1,79 @@ +use std::path::PathBuf; + +fn usage() -> &'static str { + "Usage: cir_extract_inputs --cir --tals-dir --meta-json " +} + +fn main() { + if let Err(err) = run(std::env::args().collect()) { + eprintln!("error: {err}"); + std::process::exit(2); + } +} + +fn run(argv: Vec) -> Result<(), String> { + let mut cir_path: Option = None; + let mut tals_dir: Option = None; + let mut meta_json: Option = None; + + let mut i = 1usize; + while i < argv.len() { + match argv[i].as_str() { + "--help" | "-h" => return Err(usage().to_string()), + "--cir" => { + i += 1; + cir_path = Some(PathBuf::from(argv.get(i).ok_or("--cir requires a value")?)); + } + "--tals-dir" => { + i += 1; + tals_dir = Some(PathBuf::from(argv.get(i).ok_or("--tals-dir requires a value")?)); + } + "--meta-json" => { + i += 1; + meta_json = Some(PathBuf::from(argv.get(i).ok_or("--meta-json requires a value")?)); + } + other => return Err(format!("unknown argument: {other}\n\n{}", usage())), + } + i += 1; + } + + let cir_path = cir_path.ok_or_else(|| format!("--cir is required\n\n{}", usage()))?; + let tals_dir = tals_dir.ok_or_else(|| format!("--tals-dir is required\n\n{}", usage()))?; + let meta_json = meta_json.ok_or_else(|| format!("--meta-json is required\n\n{}", usage()))?; + + let bytes = std::fs::read(&cir_path) + .map_err(|e| format!("read CIR failed: {}: {e}", cir_path.display()))?; + let cir = rpki::cir::decode_cir(&bytes).map_err(|e| e.to_string())?; + + std::fs::create_dir_all(&tals_dir) + .map_err(|e| format!("create tals dir failed: {}: {e}", tals_dir.display()))?; + + let mut tal_files = Vec::new(); + for (idx, tal) in cir.tals.iter().enumerate() { + let filename = format!("tal-{:03}.tal", idx + 1); + let path = tals_dir.join(filename); + std::fs::write(&path, &tal.tal_bytes) + .map_err(|e| format!("write TAL failed: {}: {e}", path.display()))?; + tal_files.push(serde_json::json!({ + "talUri": tal.tal_uri, + "path": path, + })); + } + + let validation_time = cir + .validation_time + .format(&time::format_description::well_known::Rfc3339) + .map_err(|e| format!("format validationTime failed: {e}"))?; + let meta = serde_json::json!({ + "validationTime": validation_time, + "talFiles": tal_files, + }); + if let Some(parent) = meta_json.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create meta parent failed: {}: {e}", parent.display()))?; + } + std::fs::write(&meta_json, serde_json::to_vec_pretty(&meta).unwrap()) + .map_err(|e| format!("write meta json failed: {}: {e}", meta_json.display()))?; + Ok(()) +} + diff --git a/src/bin/cir_materialize.rs b/src/bin/cir_materialize.rs new file mode 100644 index 0000000..612b2e2 --- /dev/null +++ b/src/bin/cir_materialize.rs @@ -0,0 +1,77 @@ +use std::path::PathBuf; + +fn usage() -> &'static str { + "Usage: cir_materialize --cir --static-root --mirror-root [--keep-db]" +} + +fn main() { + if let Err(err) = run(std::env::args().collect()) { + eprintln!("error: {err}"); + std::process::exit(2); + } +} + +fn run(argv: Vec) -> Result<(), String> { + let mut cir_path: Option = None; + let mut static_root: Option = None; + let mut mirror_root: Option = None; + let mut keep_db = false; + + let mut i = 1usize; + while i < argv.len() { + match argv[i].as_str() { + "--help" | "-h" => return Err(usage().to_string()), + "--cir" => { + i += 1; + cir_path = Some(PathBuf::from(argv.get(i).ok_or("--cir requires a value")?)); + } + "--static-root" => { + i += 1; + static_root = Some(PathBuf::from( + argv.get(i).ok_or("--static-root requires a value")?, + )); + } + "--mirror-root" => { + i += 1; + mirror_root = Some(PathBuf::from( + argv.get(i).ok_or("--mirror-root requires a value")?, + )); + } + "--keep-db" => keep_db = true, + other => return Err(format!("unknown argument: {other}\n\n{}", usage())), + } + i += 1; + } + + let cir_path = cir_path.ok_or_else(|| format!("--cir is required\n\n{}", usage()))?; + let static_root = + static_root.ok_or_else(|| format!("--static-root is required\n\n{}", usage()))?; + let mirror_root = + mirror_root.ok_or_else(|| format!("--mirror-root is required\n\n{}", usage()))?; + + let bytes = std::fs::read(&cir_path) + .map_err(|e| format!("read CIR failed: {}: {e}", cir_path.display()))?; + let cir = rpki::cir::decode_cir(&bytes).map_err(|e| e.to_string())?; + + let result = rpki::cir::materialize_cir(&cir, &static_root, &mirror_root, true); + match result { + Ok(summary) => { + eprintln!( + "materialized CIR: mirror={} objects={} linked={} copied={} keep_db={}", + mirror_root.display(), + summary.object_count, + summary.linked_files, + summary.copied_files, + keep_db + ); + Ok(()) + } + Err(err) => { + if !keep_db && mirror_root.exists() { + let _ = std::fs::remove_dir_all(&mirror_root); + } + Err(err.to_string()) + } + } +} + diff --git a/src/bin/repository_view_stats.rs b/src/bin/repository_view_stats.rs new file mode 100644 index 0000000..67eadc7 --- /dev/null +++ b/src/bin/repository_view_stats.rs @@ -0,0 +1,108 @@ +use rocksdb::{DB, IteratorMode, Options}; +use rpki::storage::{column_family_descriptors, CF_REPOSITORY_VIEW}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn usage() -> String { + let bin = "repository_view_stats"; + format!( + "\ +Usage: + {bin} --db + +Options: + --db RocksDB directory + --help Show this help +" + ) +} + +fn parse_args(argv: &[String]) -> Result { + if argv.iter().any(|arg| arg == "--help" || arg == "-h") { + return Err(usage()); + } + let mut db_path: Option = None; + let mut i = 1usize; + while i < argv.len() { + match argv[i].as_str() { + "--db" => { + i += 1; + let value = argv.get(i).ok_or("--db requires a value")?; + db_path = Some(PathBuf::from(value)); + } + other => return Err(format!("unknown argument: {other}\n\n{}", usage())), + } + i += 1; + } + db_path.ok_or_else(|| format!("--db is required\n\n{}", usage())) +} + +fn dir_size(path: &Path) -> Result> { + let mut total = 0u64; + for entry in fs::read_dir(path)? { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_file() { + total = total.saturating_add(entry.metadata()?.len()); + } else if file_type.is_dir() { + total = total.saturating_add(dir_size(&entry.path())?); + } + } + Ok(total) +} + +fn main() -> Result<(), Box> { + let argv: Vec = std::env::args().collect(); + let db_path = parse_args(&argv).map_err(|e| -> Box { e.into() })?; + + let mut opts = Options::default(); + opts.create_if_missing(false); + opts.create_missing_column_families(false); + let db = DB::open_cf_descriptors(&opts, &db_path, column_family_descriptors())?; + let cf = db + .cf_handle(CF_REPOSITORY_VIEW) + .ok_or("missing repository_view column family")?; + + let mut kv_count = 0u64; + let mut key_bytes_total = 0u64; + let mut value_bytes_total = 0u64; + let mut max_key_bytes = 0usize; + let mut max_value_bytes = 0usize; + + for entry in db.iterator_cf(cf, IteratorMode::Start) { + let (key, value) = entry?; + kv_count += 1; + key_bytes_total += key.len() as u64; + value_bytes_total += value.len() as u64; + max_key_bytes = max_key_bytes.max(key.len()); + max_value_bytes = max_value_bytes.max(value.len()); + } + + let logical_total_bytes = key_bytes_total + value_bytes_total; + let avg_key_bytes = if kv_count > 0 { + key_bytes_total as f64 / kv_count as f64 + } else { + 0.0 + }; + let avg_value_bytes = if kv_count > 0 { + value_bytes_total as f64 / kv_count as f64 + } else { + 0.0 + }; + + let out = serde_json::json!({ + "db_path": db_path.display().to_string(), + "column_family": CF_REPOSITORY_VIEW, + "kv_count": kv_count, + "key_bytes_total": key_bytes_total, + "value_bytes_total": value_bytes_total, + "logical_total_bytes": logical_total_bytes, + "db_dir_on_disk_bytes": dir_size(&db_path)?, + "avg_key_bytes": avg_key_bytes, + "avg_value_bytes": avg_value_bytes, + "max_key_bytes": max_key_bytes, + "max_value_bytes": max_value_bytes, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + Ok(()) +} diff --git a/src/ccr/mod.rs b/src/ccr/mod.rs index 3beab8b..4e2c77e 100644 --- a/src/ccr/mod.rs +++ b/src/ccr/mod.rs @@ -1,20 +1,26 @@ -pub mod build; pub mod decode; pub mod encode; -pub mod export; -pub mod verify; pub mod dump; pub mod hash; pub mod model; +#[cfg(feature = "full")] +pub mod build; +#[cfg(feature = "full")] +pub mod export; +#[cfg(feature = "full")] +pub mod verify; +#[cfg(feature = "full")] pub use build::{ CcrBuildError, build_aspa_payload_state, build_manifest_state_from_vcirs, build_roa_payload_state, build_trust_anchor_state, }; pub use decode::{CcrDecodeError, decode_content_info}; pub use encode::{CcrEncodeError, encode_content_info}; +#[cfg(feature = "full")] pub use export::{CcrExportError, build_ccr_from_run, write_ccr_file}; pub use dump::{CcrDumpError, dump_content_info_json, dump_content_info_json_value}; +#[cfg(feature = "full")] pub use verify::{CcrVerifyError, CcrVerifySummary, extract_vrp_rows, verify_against_report_json_path, verify_against_vcir_store, verify_against_vcir_store_path, verify_content_info, verify_content_info_bytes}; pub use hash::{compute_state_hash, verify_state_hash}; pub use model::{ diff --git a/src/cir/decode.rs b/src/cir/decode.rs new file mode 100644 index 0000000..61bf297 --- /dev/null +++ b/src/cir/decode.rs @@ -0,0 +1,161 @@ +use crate::cir::model::{ + CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, +}; +use crate::data_model::common::DerReader; +use crate::data_model::oid::{OID_SHA256, OID_SHA256_RAW}; +use der_parser::der::parse_der_oid; + +#[derive(Debug, thiserror::Error)] +pub enum CirDecodeError { + #[error("DER parse error: {0}")] + Parse(String), + + #[error("unexpected CIR version: expected {expected}, got {actual}")] + UnexpectedVersion { expected: u32, actual: u32 }, + + #[error("unexpected digest algorithm OID: expected {expected}, got {actual}")] + UnexpectedDigestAlgorithm { expected: &'static str, actual: String }, + + #[error("CIR model validation failed after decode: {0}")] + Validate(String), +} + +pub fn decode_cir(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CirDecodeError::Parse)?; + if !top.is_empty() { + return Err(CirDecodeError::Parse("trailing bytes after CIR".into())); + } + + let version = seq.take_uint_u64().map_err(CirDecodeError::Parse)? as u32; + if version != CIR_VERSION_V1 { + return Err(CirDecodeError::UnexpectedVersion { + expected: CIR_VERSION_V1, + actual: version, + }); + } + let hash_alg = decode_hash_alg(seq.take_tag(0x06).map_err(CirDecodeError::Parse)?)?; + let validation_time = + parse_generalized_time(seq.take_tag(0x18).map_err(CirDecodeError::Parse)?)?; + + let objects_der = seq.take_tag(0x30).map_err(CirDecodeError::Parse)?; + let mut objects_reader = DerReader::new(objects_der); + let mut objects = Vec::new(); + while !objects_reader.is_empty() { + let (_tag, full, _value) = objects_reader + .take_any_full() + .map_err(CirDecodeError::Parse)?; + objects.push(decode_object(full)?); + } + + let tals_der = seq.take_tag(0x30).map_err(CirDecodeError::Parse)?; + let mut tals_reader = DerReader::new(tals_der); + let mut tals = Vec::new(); + while !tals_reader.is_empty() { + let (_tag, full, _value) = tals_reader.take_any_full().map_err(CirDecodeError::Parse)?; + tals.push(decode_tal(full)?); + } + + if !seq.is_empty() { + return Err(CirDecodeError::Parse("trailing fields in CIR".into())); + } + + let cir = CanonicalInputRepresentation { + version, + hash_alg, + validation_time, + objects, + tals, + }; + cir.validate().map_err(CirDecodeError::Validate)?; + Ok(cir) +} + +fn decode_hash_alg(raw_body: &[u8]) -> Result { + if raw_body != OID_SHA256_RAW { + return Err(CirDecodeError::UnexpectedDigestAlgorithm { + expected: OID_SHA256, + actual: oid_string(raw_body)?, + }); + } + Ok(CirHashAlgorithm::Sha256) +} + +fn decode_object(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CirDecodeError::Parse)?; + if !top.is_empty() { + return Err(CirDecodeError::Parse("trailing bytes after CirObject".into())); + } + let rsync_uri = std::str::from_utf8(seq.take_tag(0x16).map_err(CirDecodeError::Parse)?) + .map_err(|e| CirDecodeError::Parse(e.to_string()))? + .to_string(); + let sha256 = seq.take_octet_string().map_err(CirDecodeError::Parse)?.to_vec(); + if !seq.is_empty() { + return Err(CirDecodeError::Parse("trailing fields in CirObject".into())); + } + Ok(CirObject { rsync_uri, sha256 }) +} + +fn decode_tal(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CirDecodeError::Parse)?; + if !top.is_empty() { + return Err(CirDecodeError::Parse("trailing bytes after CirTal".into())); + } + let tal_uri = std::str::from_utf8(seq.take_tag(0x16).map_err(CirDecodeError::Parse)?) + .map_err(|e| CirDecodeError::Parse(e.to_string()))? + .to_string(); + let tal_bytes = seq.take_octet_string().map_err(CirDecodeError::Parse)?.to_vec(); + if !seq.is_empty() { + return Err(CirDecodeError::Parse("trailing fields in CirTal".into())); + } + Ok(CirTal { tal_uri, tal_bytes }) +} + +fn oid_string(raw_body: &[u8]) -> Result { + let der = { + let mut out = Vec::with_capacity(raw_body.len() + 2); + out.push(0x06); + if raw_body.len() < 0x80 { + out.push(raw_body.len() as u8); + } else { + return Err(CirDecodeError::Parse("OID too long".into())); + } + out.extend_from_slice(raw_body); + out + }; + let (_rem, oid) = parse_der_oid(&der).map_err(|e| CirDecodeError::Parse(e.to_string()))?; + let oid = oid + .as_oid_val() + .map_err(|e| CirDecodeError::Parse(e.to_string()))?; + Ok(oid.to_string()) +} + +fn parse_generalized_time(bytes: &[u8]) -> Result { + let s = std::str::from_utf8(bytes).map_err(|e| CirDecodeError::Parse(e.to_string()))?; + if s.len() != 15 || !s.ends_with('Z') { + return Err(CirDecodeError::Parse( + "GeneralizedTime must be YYYYMMDDHHMMSSZ".into(), + )); + } + let parse = |range: std::ops::Range| -> Result { + s[range] + .parse::() + .map_err(|e| CirDecodeError::Parse(e.to_string())) + }; + let year = parse(0..4)? as i32; + let month = parse(4..6)? as u8; + let day = parse(6..8)? as u8; + let hour = parse(8..10)? as u8; + let minute = parse(10..12)? as u8; + let second = parse(12..14)? as u8; + let month = time::Month::try_from(month) + .map_err(|e| CirDecodeError::Parse(e.to_string()))?; + let date = time::Date::from_calendar_date(year, month, day) + .map_err(|e| CirDecodeError::Parse(e.to_string()))?; + let timev = time::Time::from_hms(hour, minute, second) + .map_err(|e| CirDecodeError::Parse(e.to_string()))?; + Ok(time::PrimitiveDateTime::new(date, timev).assume_utc()) +} + diff --git a/src/cir/encode.rs b/src/cir/encode.rs new file mode 100644 index 0000000..2cad24a --- /dev/null +++ b/src/cir/encode.rs @@ -0,0 +1,147 @@ +use crate::cir::model::{ + CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, +}; +use crate::data_model::oid::OID_SHA256_RAW; + +#[derive(Debug, thiserror::Error)] +pub enum CirEncodeError { + #[error("CIR model validation failed: {0}")] + Validate(String), +} + +pub fn encode_cir( + cir: &CanonicalInputRepresentation, +) -> Result, CirEncodeError> { + cir.validate().map_err(CirEncodeError::Validate)?; + Ok(encode_sequence(&[ + encode_integer_u32(cir.version), + encode_oid(match cir.hash_alg { + CirHashAlgorithm::Sha256 => OID_SHA256_RAW, + }), + encode_generalized_time(cir.validation_time), + encode_sequence( + &cir.objects + .iter() + .map(encode_object) + .collect::, _>>()?, + ), + encode_sequence( + &cir.tals + .iter() + .map(encode_tal) + .collect::, _>>()?, + ), + ])) +} + +fn encode_object(object: &CirObject) -> Result, CirEncodeError> { + object.validate().map_err(CirEncodeError::Validate)?; + Ok(encode_sequence(&[ + encode_ia5_string(object.rsync_uri.as_bytes()), + encode_octet_string(&object.sha256), + ])) +} + +fn encode_tal(tal: &CirTal) -> Result, CirEncodeError> { + tal.validate().map_err(CirEncodeError::Validate)?; + Ok(encode_sequence(&[ + encode_ia5_string(tal.tal_uri.as_bytes()), + encode_octet_string(&tal.tal_bytes), + ])) +} + +fn encode_generalized_time(t: time::OffsetDateTime) -> Vec { + let t = t.to_offset(time::UtcOffset::UTC); + let s = format!( + "{:04}{:02}{:02}{:02}{:02}{:02}Z", + t.year(), + u8::from(t.month()), + t.day(), + t.hour(), + t.minute(), + t.second() + ); + encode_tlv(0x18, s.into_bytes()) +} + +fn encode_integer_u32(v: u32) -> Vec { + encode_integer_bytes(unsigned_integer_bytes(v as u64)) +} + +fn encode_integer_bytes(mut bytes: Vec) -> Vec { + if bytes.is_empty() { + bytes.push(0); + } + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + encode_tlv(0x02, bytes) +} + +fn unsigned_integer_bytes(v: u64) -> Vec { + if v == 0 { + return vec![0]; + } + let mut out = Vec::new(); + let mut n = v; + while n > 0 { + out.push((n & 0xFF) as u8); + n >>= 8; + } + out.reverse(); + out +} + +fn encode_oid(raw_body: &[u8]) -> Vec { + encode_tlv(0x06, raw_body.to_vec()) +} + +fn encode_ia5_string(bytes: &[u8]) -> Vec { + encode_tlv(0x16, bytes.to_vec()) +} + +fn encode_octet_string(bytes: &[u8]) -> Vec { + encode_tlv(0x04, bytes.to_vec()) +} + +fn encode_sequence(elements: &[Vec]) -> Vec { + let mut body = Vec::new(); + for element in elements { + body.extend_from_slice(element); + } + encode_tlv(0x30, body) +} + +fn encode_tlv(tag: u8, value: Vec) -> Vec { + let mut out = Vec::with_capacity(1 + encoded_len_len(value.len()) + value.len()); + out.push(tag); + encode_len_into(value.len(), &mut out); + out.extend_from_slice(&value); + out +} + +fn encoded_len_len(len: usize) -> usize { + if len < 0x80 { + 1 + } else { + 1 + len.to_be_bytes().iter().skip_while(|&&b| b == 0).count() + } +} + +fn encode_len_into(len: usize, out: &mut Vec) { + if len < 0x80 { + out.push(len as u8); + return; + } + let bytes = len.to_be_bytes(); + let first_non_zero = bytes.iter().position(|&b| b != 0).unwrap_or(bytes.len() - 1); + let len_bytes = &bytes[first_non_zero..]; + out.push(0x80 | (len_bytes.len() as u8)); + out.extend_from_slice(len_bytes); +} + +#[allow(dead_code)] +const _: () = { + let _ = CIR_VERSION_V1; +}; + diff --git a/src/cir/export.rs b/src/cir/export.rs new file mode 100644 index 0000000..be9e038 --- /dev/null +++ b/src/cir/export.rs @@ -0,0 +1,285 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::path::Path; + +use crate::cir::encode::{CirEncodeError, encode_cir}; +use crate::cir::model::{ + CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, +}; +use crate::cir::static_pool::{ + CirStaticPoolError, CirStaticPoolExportSummary, write_bytes_to_static_pool, + export_hashes_from_store, +}; +use crate::data_model::ta::TrustAnchor; +use crate::storage::{RepositoryViewState, RocksStore}; + +#[derive(Debug, thiserror::Error)] +pub enum CirExportError { + #[error("list repository_view entries failed: {0}")] + ListRepositoryView(String), + + #[error("CIR TAL URI must be http(s), got: {0}")] + InvalidTalUri(String), + + #[error("TAL does not contain any rsync TA URI; CIR replay scheme A requires one")] + MissingTaRsyncUri, + + #[error("CIR model validation failed: {0}")] + Validate(String), + + #[error("encode CIR failed: {0}")] + Encode(#[from] CirEncodeError), + + #[error("static pool export failed: {0}")] + StaticPool(#[from] CirStaticPoolError), + + #[error("write CIR file failed: {0}: {1}")] + Write(String, String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CirExportSummary { + pub object_count: usize, + pub tal_count: usize, + pub static_pool: CirStaticPoolExportSummary, +} + +pub fn build_cir_from_run( + store: &RocksStore, + trust_anchor: &TrustAnchor, + tal_uri: &str, + validation_time: time::OffsetDateTime, +) -> Result { + if !(tal_uri.starts_with("https://") || tal_uri.starts_with("http://")) { + return Err(CirExportError::InvalidTalUri(tal_uri.to_string())); + } + + let entries = store + .list_repository_view_entries_with_prefix("rsync://") + .map_err(|e| CirExportError::ListRepositoryView(e.to_string()))?; + + let mut objects: BTreeMap = BTreeMap::new(); + for entry in entries { + if matches!( + entry.state, + RepositoryViewState::Present | RepositoryViewState::Replaced + ) && let Some(hash) = entry.current_hash + { + objects.insert(entry.rsync_uri, hash.to_ascii_lowercase()); + } + } + + let ta_hash = ta_sha256_hex(&trust_anchor.ta_certificate.raw_der); + let mut saw_rsync_uri = false; + for uri in &trust_anchor.tal.ta_uris { + if uri.scheme() == "rsync" { + saw_rsync_uri = true; + objects.insert(uri.as_str().to_string(), ta_hash.clone()); + } + } + if !saw_rsync_uri { + return Err(CirExportError::MissingTaRsyncUri); + } + + let cir = CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: validation_time.to_offset(time::UtcOffset::UTC), + objects: objects + .into_iter() + .map(|(rsync_uri, sha256_hex)| CirObject { + rsync_uri, + sha256: hex::decode(sha256_hex).expect("validated hex"), + }) + .collect(), + tals: vec![CirTal { + tal_uri: tal_uri.to_string(), + tal_bytes: trust_anchor.tal.raw.clone(), + }], + }; + cir.validate().map_err(CirExportError::Validate)?; + Ok(cir) +} + +pub fn write_cir_file(path: &Path, cir: &CanonicalInputRepresentation) -> Result<(), CirExportError> { + let der = encode_cir(cir)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| CirExportError::Write(path.display().to_string(), e.to_string()))?; + } + std::fs::write(path, der) + .map_err(|e| CirExportError::Write(path.display().to_string(), e.to_string())) +} + +pub fn export_cir_static_pool( + store: &RocksStore, + static_root: &Path, + capture_date_utc: time::Date, + cir: &CanonicalInputRepresentation, + trust_anchor: &TrustAnchor, +) -> Result { + let ta_hash = ta_sha256_hex(&trust_anchor.ta_certificate.raw_der); + let hashes = cir + .objects + .iter() + .map(|item| hex::encode(&item.sha256)) + .filter(|hash| hash != &ta_hash) + .collect::>(); + let mut summary = export_hashes_from_store(store, static_root, capture_date_utc, &hashes)?; + + let ta_result = write_bytes_to_static_pool( + static_root, + capture_date_utc, + &ta_hash, + &trust_anchor.ta_certificate.raw_der, + )?; + let mut unique = hashes.iter().cloned().collect::>(); + unique.insert(ta_hash.clone()); + summary.unique_hashes = unique.len(); + if ta_result.written { + summary.written_files += 1; + } else { + summary.reused_files += 1; + } + Ok(summary) +} + +pub fn export_cir_from_run( + store: &RocksStore, + trust_anchor: &TrustAnchor, + tal_uri: &str, + validation_time: time::OffsetDateTime, + cir_out: &Path, + static_root: &Path, + capture_date_utc: time::Date, +) -> Result { + let cir = build_cir_from_run(store, trust_anchor, tal_uri, validation_time)?; + let static_pool = export_cir_static_pool(store, static_root, capture_date_utc, &cir, trust_anchor)?; + write_cir_file(cir_out, &cir)?; + Ok(CirExportSummary { + object_count: cir.objects.len(), + tal_count: cir.tals.len(), + static_pool, + }) +} + +fn ta_sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cir::decode::decode_cir; + use crate::cir::static_pool_path; + use crate::data_model::ta::TrustAnchor; + use crate::data_model::tal::Tal; + use crate::storage::{RawByHashEntry, RepositoryViewEntry, RepositoryViewState, RocksStore}; + + fn sample_time() -> time::OffsetDateTime { + time::OffsetDateTime::parse( + "2026-04-07T12:34:56Z", + &time::format_description::well_known::Rfc3339, + ) + .unwrap() + } + + fn sample_date() -> time::Date { + time::Date::from_calendar_date(2026, time::Month::April, 7).unwrap() + } + + fn sample_trust_anchor() -> TrustAnchor { + let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let tal_bytes = std::fs::read(base.join("tests/fixtures/tal/apnic-rfc7730-https.tal")).unwrap(); + let ta_der = std::fs::read(base.join("tests/fixtures/ta/apnic-ta.cer")).unwrap(); + let tal = Tal::decode_bytes(&tal_bytes).unwrap(); + TrustAnchor::bind_der(tal, &ta_der, None).unwrap() + } + + fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(bytes)) + } + + #[test] + fn build_cir_from_run_collects_repository_view_and_tal() { + let td = tempfile::tempdir().unwrap(); + let store = RocksStore::open(td.path()).unwrap(); + let bytes = b"object-a".to_vec(); + let hash = sha256_hex(&bytes); + let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone()); + raw.origin_uris.push("rsync://example.test/repo/a.cer".into()); + store.put_raw_by_hash_entry(&raw).unwrap(); + store + .put_repository_view_entry(&RepositoryViewEntry { + rsync_uri: "rsync://example.test/repo/a.cer".to_string(), + current_hash: Some(hash), + repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), + object_type: Some("cer".to_string()), + state: RepositoryViewState::Present, + }) + .unwrap(); + + let ta = sample_trust_anchor(); + let cir = build_cir_from_run(&store, &ta, "https://example.test/root.tal", sample_time()) + .expect("build cir"); + assert_eq!(cir.version, CIR_VERSION_V1); + assert_eq!(cir.tals.len(), 1); + assert_eq!(cir.tals[0].tal_uri, "https://example.test/root.tal"); + assert!(cir + .objects + .iter() + .any(|item| item.rsync_uri == "rsync://example.test/repo/a.cer")); + assert!(cir + .objects + .iter() + .any(|item| item.rsync_uri.contains("apnic-rpki-root-iana-origin.cer"))); + } + + #[test] + fn export_cir_from_run_writes_der_and_static_pool() { + let td = tempfile::tempdir().unwrap(); + let store_dir = td.path().join("db"); + let out_dir = td.path().join("out"); + let static_root = td.path().join("static"); + let store = RocksStore::open(&store_dir).unwrap(); + + let bytes = b"object-b".to_vec(); + let hash = sha256_hex(&bytes); + let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone()); + raw.origin_uris.push("rsync://example.test/repo/b.roa".into()); + store.put_raw_by_hash_entry(&raw).unwrap(); + store + .put_repository_view_entry(&RepositoryViewEntry { + rsync_uri: "rsync://example.test/repo/b.roa".to_string(), + current_hash: Some(hash.clone()), + repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), + object_type: Some("roa".to_string()), + state: RepositoryViewState::Present, + }) + .unwrap(); + + let ta = sample_trust_anchor(); + let cir_path = out_dir.join("example.cir"); + let summary = export_cir_from_run( + &store, + &ta, + "https://example.test/root.tal", + sample_time(), + &cir_path, + &static_root, + sample_date(), + ) + .expect("export cir"); + assert_eq!(summary.tal_count, 1); + assert!(summary.object_count >= 2); + + let der = std::fs::read(&cir_path).unwrap(); + let cir = decode_cir(&der).unwrap(); + assert_eq!(cir.tals[0].tal_uri, "https://example.test/root.tal"); + + let object_path = static_pool_path(&static_root, sample_date(), &hash).unwrap(); + assert_eq!(std::fs::read(object_path).unwrap(), bytes); + } +} diff --git a/src/cir/materialize.rs b/src/cir/materialize.rs new file mode 100644 index 0000000..edf5abd --- /dev/null +++ b/src/cir/materialize.rs @@ -0,0 +1,388 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::cir::model::CanonicalInputRepresentation; + +#[derive(Debug, thiserror::Error)] +pub enum CirMaterializeError { + #[error("invalid rsync URI: {0}")] + InvalidRsyncUri(String), + + #[error("rsync URI must reference a file object, got directory-like URI: {0}")] + DirectoryLikeRsyncUri(String), + + #[error("create mirror root failed: {path}: {detail}")] + CreateMirrorRoot { path: String, detail: String }, + + #[error("remove mirror root failed: {path}: {detail}")] + RemoveMirrorRoot { path: String, detail: String }, + + #[error("create parent directory failed: {path}: {detail}")] + CreateParent { path: String, detail: String }, + + #[error("remove existing target failed: {path}: {detail}")] + RemoveExistingTarget { path: String, detail: String }, + + #[error("static object not found for sha256={sha256_hex}")] + MissingStaticObject { sha256_hex: String }, + + #[error("link target failed: {src} -> {dst}: {detail}")] + Link { src: String, dst: String, detail: String }, + + #[error("copy target failed: {src} -> {dst}: {detail}")] + Copy { src: String, dst: String, detail: String }, + + #[error("mirror tree mismatch after materialize: {0}")] + TreeMismatch(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CirMaterializeSummary { + pub object_count: usize, + pub linked_files: usize, + pub copied_files: usize, +} + +pub fn materialize_cir( + cir: &CanonicalInputRepresentation, + static_root: &Path, + mirror_root: &Path, + clean_rebuild: bool, +) -> Result { + cir.validate() + .map_err(CirMaterializeError::TreeMismatch)?; + + if clean_rebuild && mirror_root.exists() { + fs::remove_dir_all(mirror_root).map_err(|e| CirMaterializeError::RemoveMirrorRoot { + path: mirror_root.display().to_string(), + detail: e.to_string(), + })?; + } + fs::create_dir_all(mirror_root).map_err(|e| CirMaterializeError::CreateMirrorRoot { + path: mirror_root.display().to_string(), + detail: e.to_string(), + })?; + + let mut linked_files = 0usize; + let mut copied_files = 0usize; + + for object in &cir.objects { + let sha256_hex = hex::encode(&object.sha256); + let source = resolve_static_pool_file(static_root, &sha256_hex)?; + let relative = mirror_relative_path_for_rsync_uri(&object.rsync_uri)?; + let target = mirror_root.join(&relative); + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|e| CirMaterializeError::CreateParent { + path: parent.display().to_string(), + detail: e.to_string(), + })?; + } + + if target.exists() { + fs::remove_file(&target).map_err(|e| CirMaterializeError::RemoveExistingTarget { + path: target.display().to_string(), + detail: e.to_string(), + })?; + } + + match fs::hard_link(&source, &target) { + Ok(()) => linked_files += 1, + Err(link_err) => { + fs::copy(&source, &target).map_err(|copy_err| CirMaterializeError::Copy { + src: source.display().to_string(), + dst: target.display().to_string(), + detail: format!("{copy_err}; original link error: {link_err}"), + })?; + copied_files += 1; + } + } + } + + let actual = collect_materialized_uris(mirror_root)?; + let expected = cir + .objects + .iter() + .map(|item| item.rsync_uri.clone()) + .collect::>(); + if actual != expected { + return Err(CirMaterializeError::TreeMismatch(format!( + "expected {} files, got {} files", + expected.len(), + actual.len() + ))); + } + + Ok(CirMaterializeSummary { + object_count: cir.objects.len(), + linked_files, + copied_files, + }) +} + +pub fn mirror_relative_path_for_rsync_uri(rsync_uri: &str) -> Result { + let url = url::Url::parse(rsync_uri) + .map_err(|_| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?; + if url.scheme() != "rsync" { + return Err(CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string())); + } + let host = url + .host_str() + .ok_or_else(|| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?; + let segments = url + .path_segments() + .ok_or_else(|| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))? + .collect::>(); + if segments.is_empty() || segments.last().copied().unwrap_or_default().is_empty() { + return Err(CirMaterializeError::DirectoryLikeRsyncUri( + rsync_uri.to_string(), + )); + } + + let mut path = PathBuf::from(host); + for segment in segments { + if !segment.is_empty() { + path.push(segment); + } + } + Ok(path) +} + +pub fn resolve_static_pool_file( + static_root: &Path, + sha256_hex: &str, +) -> Result { + if sha256_hex.len() != 64 || !sha256_hex.as_bytes().iter().all(u8::is_ascii_hexdigit) { + return Err(CirMaterializeError::MissingStaticObject { + sha256_hex: sha256_hex.to_string(), + }); + } + let prefix1 = &sha256_hex[0..2]; + let prefix2 = &sha256_hex[2..4]; + + let entries = fs::read_dir(static_root) + .map_err(|_| CirMaterializeError::MissingStaticObject { + sha256_hex: sha256_hex.to_string(), + })?; + let mut dates = entries + .filter_map(Result::ok) + .filter(|entry| entry.path().is_dir()) + .map(|entry| entry.path()) + .collect::>(); + dates.sort(); + + for date_dir in dates { + let candidate = date_dir.join(prefix1).join(prefix2).join(sha256_hex); + if candidate.is_file() { + return Ok(candidate); + } + } + Err(CirMaterializeError::MissingStaticObject { + sha256_hex: sha256_hex.to_string(), + }) +} + +fn collect_materialized_uris( + mirror_root: &Path, +) -> Result, CirMaterializeError> { + let mut out = std::collections::BTreeSet::new(); + let mut stack = vec![mirror_root.to_path_buf()]; + while let Some(path) = stack.pop() { + for entry in fs::read_dir(&path).map_err(|e| CirMaterializeError::CreateMirrorRoot { + path: path.display().to_string(), + detail: e.to_string(), + })? { + let entry = entry.map_err(|e| CirMaterializeError::CreateMirrorRoot { + path: path.display().to_string(), + detail: e.to_string(), + })?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + let rel = path + .strip_prefix(mirror_root) + .expect("materialized path under mirror root") + .to_string_lossy() + .replace('\\', "/"); + let uri = format!("rsync://{rel}"); + out.insert(uri); + } + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::{CirMaterializeError, materialize_cir, mirror_relative_path_for_rsync_uri, resolve_static_pool_file}; + use crate::cir::model::{ + CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + }; + use std::path::{Path, PathBuf}; + + fn sample_time() -> time::OffsetDateTime { + time::OffsetDateTime::parse( + "2026-04-07T12:34:56Z", + &time::format_description::well_known::Rfc3339, + ) + .unwrap() + } + + fn sample_cir() -> CanonicalInputRepresentation { + CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time(), + objects: vec![ + CirObject { + rsync_uri: "rsync://example.net/repo/a.cer".to_string(), + sha256: hex::decode( + "1111111111111111111111111111111111111111111111111111111111111111", + ) + .unwrap(), + }, + CirObject { + rsync_uri: "rsync://example.net/repo/nested/b.roa".to_string(), + sha256: hex::decode( + "2222222222222222222222222222222222222222222222222222222222222222", + ) + .unwrap(), + }, + ], + tals: vec![CirTal { + tal_uri: "https://tal.example.net/root.tal".to_string(), + tal_bytes: b"x".to_vec(), + }], + } + } + + #[test] + fn mirror_relative_path_for_rsync_uri_maps_host_and_path() { + let path = + mirror_relative_path_for_rsync_uri("rsync://example.net/repo/nested/b.roa").unwrap(); + assert_eq!(path, PathBuf::from("example.net").join("repo").join("nested").join("b.roa")); + } + + #[test] + fn resolve_static_pool_file_finds_hash_across_dates() { + let td = tempfile::tempdir().unwrap(); + let path = td + .path() + .join("20260407") + .join("11") + .join("11"); + std::fs::create_dir_all(&path).unwrap(); + let file = path.join("1111111111111111111111111111111111111111111111111111111111111111"); + std::fs::write(&file, b"x").unwrap(); + + let resolved = resolve_static_pool_file( + td.path(), + "1111111111111111111111111111111111111111111111111111111111111111", + ) + .unwrap(); + assert_eq!(resolved, file); + } + + #[test] + fn resolve_static_pool_file_rejects_invalid_hash_and_missing_hash() { + let td = tempfile::tempdir().unwrap(); + let err = resolve_static_pool_file(td.path(), "not-a-hash") + .expect_err("invalid hash should fail"); + assert!(matches!(err, CirMaterializeError::MissingStaticObject { .. })); + + let err = resolve_static_pool_file( + td.path(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect_err("missing hash should fail"); + assert!(matches!(err, CirMaterializeError::MissingStaticObject { .. })); + } + + #[test] + fn mirror_relative_path_rejects_non_rsync_and_directory_like_uris() { + let err = mirror_relative_path_for_rsync_uri("https://example.net/repo/a.roa") + .expect_err("non-rsync uri must fail"); + assert!(matches!(err, CirMaterializeError::InvalidRsyncUri(_))); + + let err = mirror_relative_path_for_rsync_uri("rsync://example.net/repo/") + .expect_err("directory-like uri must fail"); + assert!(matches!(err, CirMaterializeError::DirectoryLikeRsyncUri(_))); + } + + #[test] + fn materialize_clean_rebuild_creates_exact_tree_and_removes_stale_files() { + let td = tempfile::tempdir().unwrap(); + let static_root = td.path().join("static"); + let mirror_root = td.path().join("mirror"); + + write_static( + &static_root, + "20260407", + "1111111111111111111111111111111111111111111111111111111111111111", + b"a", + ); + write_static( + &static_root, + "20260407", + "2222222222222222222222222222222222222222222222222222222222222222", + b"b", + ); + std::fs::create_dir_all(mirror_root.join("stale")).unwrap(); + std::fs::write(mirror_root.join("stale/old.txt"), b"old").unwrap(); + + let summary = materialize_cir(&sample_cir(), &static_root, &mirror_root, true).unwrap(); + assert_eq!(summary.object_count, 2); + assert_eq!(std::fs::read(mirror_root.join("example.net/repo/a.cer")).unwrap(), b"a"); + assert_eq!( + std::fs::read(mirror_root.join("example.net/repo/nested/b.roa")).unwrap(), + b"b" + ); + assert!(!mirror_root.join("stale/old.txt").exists()); + } + + #[test] + fn materialize_fails_when_static_object_missing() { + let td = tempfile::tempdir().unwrap(); + let err = materialize_cir( + &sample_cir(), + td.path(), + &td.path().join("mirror"), + true, + ) + .expect_err("missing static object must fail"); + assert!(matches!(err, CirMaterializeError::MissingStaticObject { .. })); + } + + #[test] + fn materialize_without_clean_rebuild_detects_stale_extra_files() { + let td = tempfile::tempdir().unwrap(); + let static_root = td.path().join("static"); + let mirror_root = td.path().join("mirror"); + + write_static( + &static_root, + "20260407", + "1111111111111111111111111111111111111111111111111111111111111111", + b"a", + ); + write_static( + &static_root, + "20260407", + "2222222222222222222222222222222222222222222222222222222222222222", + b"b", + ); + std::fs::create_dir_all(mirror_root.join("extra")).unwrap(); + std::fs::write(mirror_root.join("extra/stale.txt"), b"stale").unwrap(); + + let err = materialize_cir(&sample_cir(), &static_root, &mirror_root, false) + .expect_err("stale extra files should fail exact tree check"); + assert!(matches!(err, CirMaterializeError::TreeMismatch(_))); + } + + fn write_static(root: &Path, date: &str, hash: &str, bytes: &[u8]) { + let path = root.join(date).join(&hash[0..2]).join(&hash[2..4]); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join(hash), bytes).unwrap(); + } +} diff --git a/src/cir/mod.rs b/src/cir/mod.rs new file mode 100644 index 0000000..c337acd --- /dev/null +++ b/src/cir/mod.rs @@ -0,0 +1,335 @@ +pub mod decode; +pub mod encode; +pub mod materialize; +pub mod model; +#[cfg(feature = "full")] +pub mod export; +#[cfg(feature = "full")] +pub mod static_pool; + +pub use decode::{CirDecodeError, decode_cir}; +pub use encode::{CirEncodeError, encode_cir}; +pub use materialize::{ + CirMaterializeError, CirMaterializeSummary, materialize_cir, mirror_relative_path_for_rsync_uri, + resolve_static_pool_file, +}; +pub use model::{ + CIR_VERSION_V1, CirHashAlgorithm, CirObject, CirTal, CanonicalInputRepresentation, +}; +#[cfg(feature = "full")] +pub use export::{CirExportError, CirExportSummary, build_cir_from_run, export_cir_from_run, write_cir_file}; +#[cfg(feature = "full")] +pub use static_pool::{ + CirStaticPoolError, CirStaticPoolExportSummary, CirStaticPoolWriteResult, + export_hashes_from_store, static_pool_path, static_pool_relative_path, + write_bytes_to_static_pool, write_raw_entry_to_static_pool, +}; + +#[cfg(test)] +mod tests { + use super::{ + CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + decode_cir, encode_cir, + }; + + fn sample_time() -> time::OffsetDateTime { + time::OffsetDateTime::parse( + "2026-04-07T12:34:56Z", + &time::format_description::well_known::Rfc3339, + ) + .expect("valid rfc3339") + } + + fn sample_cir() -> CanonicalInputRepresentation { + CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time(), + objects: vec![ + CirObject { + rsync_uri: "rsync://example.net/repo/a.cer".to_string(), + sha256: vec![0x11; 32], + }, + CirObject { + rsync_uri: "rsync://example.net/repo/b.roa".to_string(), + sha256: vec![0x22; 32], + }, + ], + tals: vec![CirTal { + tal_uri: "https://tal.example.net/root.tal".to_string(), + tal_bytes: + b"https://tal.example.net/ta.cer\nrsync://example.net/repo/ta.cer\nMIIB" + .to_vec(), + }], + } + } + + fn test_encode_tlv(tag: u8, value: &[u8]) -> Vec { + let mut out = Vec::with_capacity(8 + value.len()); + out.push(tag); + if value.len() < 0x80 { + out.push(value.len() as u8); + } else { + let len = value.len(); + let bytes = len.to_be_bytes(); + let first_non_zero = bytes.iter().position(|&b| b != 0).unwrap_or(bytes.len() - 1); + let len_bytes = &bytes[first_non_zero..]; + out.push(0x80 | len_bytes.len() as u8); + out.extend_from_slice(len_bytes); + } + out.extend_from_slice(value); + out + } + + #[test] + fn cir_roundtrip_full_succeeds() { + let cir = sample_cir(); + let der = encode_cir(&cir).expect("encode cir"); + let decoded = decode_cir(&der).expect("decode cir"); + assert_eq!(decoded, cir); + } + + #[test] + fn cir_roundtrip_minimal_succeeds() { + let cir = CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time(), + objects: Vec::new(), + tals: vec![CirTal { + tal_uri: "https://tal.example.net/minimal.tal".to_string(), + tal_bytes: b"rsync://example.net/repo/ta.cer\nMIIB".to_vec(), + }], + }; + let der = encode_cir(&cir).expect("encode minimal cir"); + let decoded = decode_cir(&der).expect("decode minimal cir"); + assert_eq!(decoded, cir); + } + + #[test] + fn cir_model_rejects_unsorted_duplicate_objects() { + let cir = CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time(), + objects: vec![ + CirObject { + rsync_uri: "rsync://example.net/repo/z.roa".to_string(), + sha256: vec![0x11; 32], + }, + CirObject { + rsync_uri: "rsync://example.net/repo/a.roa".to_string(), + sha256: vec![0x22; 32], + }, + ], + tals: vec![CirTal { + tal_uri: "https://tal.example.net/root.tal".to_string(), + tal_bytes: b"x".to_vec(), + }], + }; + let err = encode_cir(&cir).expect_err("unsorted objects must fail"); + assert!(err.to_string().contains("CIR.objects"), "{err}"); + } + + #[test] + fn cir_model_rejects_duplicate_tals() { + let cir = CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time(), + objects: Vec::new(), + tals: vec![ + CirTal { + tal_uri: "https://tal.example.net/root.tal".to_string(), + tal_bytes: b"a".to_vec(), + }, + CirTal { + tal_uri: "https://tal.example.net/root.tal".to_string(), + tal_bytes: b"b".to_vec(), + }, + ], + }; + let err = encode_cir(&cir).expect_err("duplicate tals must fail"); + assert!(err.to_string().contains("CIR.tals"), "{err}"); + } + + #[test] + fn cir_decode_rejects_wrong_version() { + let mut der = encode_cir(&sample_cir()).expect("encode cir"); + let pos = der + .windows(3) + .position(|window| window == [0x02, 0x01, CIR_VERSION_V1 as u8]) + .expect("find version integer"); + der[pos + 2] = 2; + let err = decode_cir(&der).expect_err("wrong version must fail"); + assert!(err.to_string().contains("unexpected CIR version"), "{err}"); + } + + #[test] + fn cir_decode_rejects_wrong_hash_oid() { + let mut der = encode_cir(&sample_cir()).expect("encode cir"); + let sha256_bytes = crate::data_model::oid::OID_SHA256_RAW; + let idx = der + .windows(sha256_bytes.len()) + .position(|window| window == sha256_bytes) + .expect("find sha256 oid"); + der[idx + sha256_bytes.len() - 1] ^= 0x01; + let err = decode_cir(&der).expect_err("wrong oid must fail"); + assert!( + err.to_string() + .contains(crate::data_model::oid::OID_SHA256), + "{err}" + ); + } + + #[test] + fn cir_decode_rejects_bad_generalized_time() { + let mut der = encode_cir(&sample_cir()).expect("encode cir"); + let pos = der + .windows(15) + .position(|window| window == b"20260407123456Z") + .expect("find generalized time"); + der[pos + 14] = b'X'; + let err = decode_cir(&der).expect_err("bad time must fail"); + assert!(err.to_string().contains("GeneralizedTime"), "{err}"); + } + + #[test] + fn cir_model_rejects_non_rsync_object_uri_and_empty_tals() { + let bad_object = CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time(), + objects: vec![CirObject { + rsync_uri: "https://example.net/repo/a.roa".to_string(), + sha256: vec![0x11; 32], + }], + tals: vec![CirTal { + tal_uri: "https://tal.example.net/root.tal".to_string(), + tal_bytes: b"x".to_vec(), + }], + }; + let err = encode_cir(&bad_object).expect_err("non-rsync object uri must fail"); + assert!(err.to_string().contains("rsync://"), "{err}"); + + let no_tals = CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time(), + objects: Vec::new(), + tals: Vec::new(), + }; + let err = encode_cir(&no_tals).expect_err("empty tals must fail"); + assert!(err.to_string().contains("CIR.tals must be non-empty"), "{err}"); + } + + #[test] + fn cir_model_rejects_non_utc_time_bad_hash_len_and_non_http_tal_uri() { + let bad_time = CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time().to_offset(time::UtcOffset::from_hms(8, 0, 0).unwrap()), + objects: Vec::new(), + tals: vec![CirTal { + tal_uri: "https://tal.example.net/root.tal".to_string(), + tal_bytes: b"x".to_vec(), + }], + }; + let err = encode_cir(&bad_time).expect_err("non-utc validation time must fail"); + assert!(err.to_string().contains("UTC"), "{err}"); + + let bad_hash = CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time(), + objects: vec![CirObject { + rsync_uri: "rsync://example.net/repo/a.roa".to_string(), + sha256: vec![0x11; 31], + }], + tals: vec![CirTal { + tal_uri: "https://tal.example.net/root.tal".to_string(), + tal_bytes: b"x".to_vec(), + }], + }; + let err = encode_cir(&bad_hash).expect_err("bad digest len must fail"); + assert!(err.to_string().contains("32 bytes"), "{err}"); + + let bad_tal_uri = CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: sample_time(), + objects: Vec::new(), + tals: vec![CirTal { + tal_uri: "ftp://tal.example.net/root.tal".to_string(), + tal_bytes: b"x".to_vec(), + }], + }; + let err = encode_cir(&bad_tal_uri).expect_err("bad tal uri must fail"); + assert!(err.to_string().contains("http:// or https://"), "{err}"); + } + + #[test] + fn cir_decode_rejects_trailing_bytes_and_trailing_fields() { + let cir = sample_cir(); + let mut der = encode_cir(&cir).expect("encode cir"); + der.push(0); + let err = decode_cir(&der).expect_err("trailing bytes after cir must fail"); + assert!(err.to_string().contains("trailing bytes after CIR"), "{err}"); + + let object = test_encode_tlv( + 0x30, + &[ + test_encode_tlv(0x16, b"rsync://example.net/repo/a.roa"), + test_encode_tlv(0x04, &[0x11; 32]), + test_encode_tlv(0x02, &[0x01]), + ] + .concat(), + ); + let tal = test_encode_tlv( + 0x30, + &[ + test_encode_tlv(0x16, b"https://tal.example.net/root.tal"), + test_encode_tlv(0x04, b"x"), + ] + .concat(), + ); + let bad = test_encode_tlv( + 0x30, + &[ + test_encode_tlv(0x02, &[CIR_VERSION_V1 as u8]), + test_encode_tlv(0x06, crate::data_model::oid::OID_SHA256_RAW), + test_encode_tlv(0x18, b"20260407123456Z"), + test_encode_tlv(0x30, &object), + test_encode_tlv(0x30, &tal), + ] + .concat(), + ); + let err = decode_cir(&bad).expect_err("trailing field in object must fail"); + assert!(err.to_string().contains("trailing fields in CirObject"), "{err}"); + } + + #[test] + fn cir_decode_rejects_invalid_object_and_tal_shapes() { + let cir = sample_cir(); + let mut der = encode_cir(&cir).expect("encode cir"); + + let rsync_text = b"rsync://example.net/repo/a.cer"; + let idx = der + .windows(rsync_text.len()) + .position(|window| window == rsync_text) + .expect("find object uri"); + der[idx] = 0xFF; + let err = decode_cir(&der).expect_err("invalid utf8 object uri must fail"); + assert!(err.to_string().contains("utf-8"), "{err}"); + + let mut der = encode_cir(&cir).expect("encode cir"); + let tal_text = b"https://tal.example.net/root.tal"; + let idx = der + .windows(tal_text.len()) + .position(|window| window == tal_text) + .expect("find tal uri"); + der[idx] = 0xFF; + let err = decode_cir(&der).expect_err("invalid utf8 tal uri must fail"); + assert!(err.to_string().contains("utf-8"), "{err}"); + } +} diff --git a/src/cir/model.rs b/src/cir/model.rs new file mode 100644 index 0000000..a488f96 --- /dev/null +++ b/src/cir/model.rs @@ -0,0 +1,120 @@ +use crate::data_model::oid::OID_SHA256; + +pub const CIR_VERSION_V1: u32 = 1; +pub const DIGEST_LEN_SHA256: usize = 32; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CirHashAlgorithm { + Sha256, +} + +impl CirHashAlgorithm { + pub fn oid(&self) -> &'static str { + match self { + Self::Sha256 => OID_SHA256, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CanonicalInputRepresentation { + pub version: u32, + pub hash_alg: CirHashAlgorithm, + pub validation_time: time::OffsetDateTime, + pub objects: Vec, + pub tals: Vec, +} + +impl CanonicalInputRepresentation { + pub fn validate(&self) -> Result<(), String> { + if self.version != CIR_VERSION_V1 { + return Err(format!( + "CIR version must be {CIR_VERSION_V1}, got {}", + self.version + )); + } + if !matches!(self.hash_alg, CirHashAlgorithm::Sha256) { + return Err("CIR hashAlg must be SHA-256".into()); + } + if self.validation_time.offset() != time::UtcOffset::UTC { + return Err("CIR validationTime must be UTC".into()); + } + validate_sorted_unique_strings( + self.objects.iter().map(|item| item.rsync_uri.as_str()), + "CIR.objects must be sorted by rsyncUri and unique", + )?; + validate_sorted_unique_strings( + self.tals.iter().map(|item| item.tal_uri.as_str()), + "CIR.tals must be sorted by talUri and unique", + )?; + if self.tals.is_empty() { + return Err("CIR.tals must be non-empty".into()); + } + for object in &self.objects { + object.validate()?; + } + for tal in &self.tals { + tal.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CirObject { + pub rsync_uri: String, + pub sha256: Vec, +} + +impl CirObject { + pub fn validate(&self) -> Result<(), String> { + if !self.rsync_uri.starts_with("rsync://") { + return Err(format!( + "CirObject.rsync_uri must start with rsync://, got {}", + self.rsync_uri + )); + } + if self.sha256.len() != DIGEST_LEN_SHA256 { + return Err(format!( + "CirObject.sha256 must be {DIGEST_LEN_SHA256} bytes, got {}", + self.sha256.len() + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CirTal { + pub tal_uri: String, + pub tal_bytes: Vec, +} + +impl CirTal { + pub fn validate(&self) -> Result<(), String> { + if !(self.tal_uri.starts_with("https://") || self.tal_uri.starts_with("http://")) { + return Err(format!( + "CirTal.tal_uri must start with http:// or https://, got {}", + self.tal_uri + )); + } + if self.tal_bytes.is_empty() { + return Err("CirTal.tal_bytes must be non-empty".into()); + } + Ok(()) + } +} + +fn validate_sorted_unique_strings<'a>( + items: impl IntoIterator, + message: &str, +) -> Result<(), String> { + let mut prev: Option<&'a str> = None; + for key in items { + if let Some(prev_key) = prev && key <= prev_key { + return Err(message.into()); + } + prev = Some(key); + } + Ok(()) +} diff --git a/src/cir/static_pool.rs b/src/cir/static_pool.rs new file mode 100644 index 0000000..55f2b8d --- /dev/null +++ b/src/cir/static_pool.rs @@ -0,0 +1,376 @@ +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::storage::{RawByHashEntry, RocksStore}; + +#[derive(Debug, thiserror::Error)] +pub enum CirStaticPoolError { + #[error("invalid sha256 hex: {0}")] + InvalidSha256Hex(String), + + #[error("raw bytes are empty for sha256={sha256_hex}")] + EmptyBytes { sha256_hex: String }, + + #[error("raw bytes do not match sha256 hex: {sha256_hex}")] + HashMismatch { sha256_hex: String }, + + #[error("create directory failed: {path}: {detail}")] + CreateDir { path: String, detail: String }, + + #[error("create temp file failed: {path}: {detail}")] + CreateTemp { path: String, detail: String }, + + #[error("write temp file failed: {path}: {detail}")] + WriteTemp { path: String, detail: String }, + + #[error("sync temp file failed: {path}: {detail}")] + SyncTemp { path: String, detail: String }, + + #[error("publish temp file failed: {temp_path} -> {final_path}: {detail}")] + Publish { + temp_path: String, + final_path: String, + detail: String, + }, + + #[error("remove temp file failed: {path}: {detail}")] + RemoveTemp { path: String, detail: String }, + + #[error("raw_by_hash entry missing for sha256={sha256_hex}")] + MissingRawByHash { sha256_hex: String }, + + #[error("storage error: {0}")] + Storage(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CirStaticPoolWriteResult { + pub final_path: PathBuf, + pub written: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CirStaticPoolExportSummary { + pub unique_hashes: usize, + pub written_files: usize, + pub reused_files: usize, +} + +pub fn static_pool_relative_path( + capture_date_utc: time::Date, + sha256_hex: &str, +) -> Result { + validate_sha256_hex(sha256_hex)?; + let date = format_utc_date(capture_date_utc); + Ok(PathBuf::from(date) + .join(&sha256_hex[0..2]) + .join(&sha256_hex[2..4]) + .join(sha256_hex)) +} + +pub fn static_pool_path( + static_root: &Path, + capture_date_utc: time::Date, + sha256_hex: &str, +) -> Result { + Ok(static_root.join(static_pool_relative_path( + capture_date_utc, + sha256_hex, + )?)) +} + +pub fn write_bytes_to_static_pool( + static_root: &Path, + capture_date_utc: time::Date, + sha256_hex_value: &str, + bytes: &[u8], +) -> Result { + validate_sha256_hex(sha256_hex_value)?; + if bytes.is_empty() { + return Err(CirStaticPoolError::EmptyBytes { + sha256_hex: sha256_hex_value.to_string(), + }); + } + let computed = compute_sha256_hex(bytes); + if computed != sha256_hex_value.to_ascii_lowercase() { + return Err(CirStaticPoolError::HashMismatch { + sha256_hex: sha256_hex_value.to_string(), + }); + } + + let final_path = static_pool_path(static_root, capture_date_utc, sha256_hex_value)?; + if final_path.exists() { + return Ok(CirStaticPoolWriteResult { + final_path, + written: false, + }); + } + + let parent = final_path.parent().expect("static pool file has parent"); + fs::create_dir_all(parent).map_err(|e| CirStaticPoolError::CreateDir { + path: parent.display().to_string(), + detail: e.to_string(), + })?; + + let temp_path = parent.join(format!("{sha256_hex_value}.tmp.{}", uuid::Uuid::new_v4())); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .map_err(|e| CirStaticPoolError::CreateTemp { + path: temp_path.display().to_string(), + detail: e.to_string(), + })?; + file.write_all(bytes) + .map_err(|e| CirStaticPoolError::WriteTemp { + path: temp_path.display().to_string(), + detail: e.to_string(), + })?; + file.sync_all().map_err(|e| CirStaticPoolError::SyncTemp { + path: temp_path.display().to_string(), + detail: e.to_string(), + })?; + drop(file); + + match fs::hard_link(&temp_path, &final_path) { + Ok(()) => { + fs::remove_file(&temp_path).map_err(|e| CirStaticPoolError::RemoveTemp { + path: temp_path.display().to_string(), + detail: e.to_string(), + })?; + Ok(CirStaticPoolWriteResult { + final_path, + written: true, + }) + } + Err(e) if final_path.exists() => { + fs::remove_file(&temp_path).map_err(|remove_err| CirStaticPoolError::RemoveTemp { + path: temp_path.display().to_string(), + detail: remove_err.to_string(), + })?; + let _ = e; + Ok(CirStaticPoolWriteResult { + final_path, + written: false, + }) + } + Err(e) => Err(CirStaticPoolError::Publish { + temp_path: temp_path.display().to_string(), + final_path: final_path.display().to_string(), + detail: e.to_string(), + }), + } +} + +pub fn write_raw_entry_to_static_pool( + static_root: &Path, + capture_date_utc: time::Date, + entry: &RawByHashEntry, +) -> Result { + write_bytes_to_static_pool( + static_root, + capture_date_utc, + &entry.sha256_hex, + &entry.bytes, + ) +} + +pub fn export_hashes_from_store( + store: &RocksStore, + static_root: &Path, + capture_date_utc: time::Date, + sha256_hexes: &[String], +) -> Result { + let unique: BTreeSet = sha256_hexes + .iter() + .map(|item| item.to_ascii_lowercase()) + .collect(); + + let mut written_files = 0usize; + let mut reused_files = 0usize; + for sha256_hex in &unique { + let entry = store + .get_raw_by_hash_entry(sha256_hex) + .map_err(|e| CirStaticPoolError::Storage(e.to_string()))? + .ok_or_else(|| CirStaticPoolError::MissingRawByHash { + sha256_hex: sha256_hex.clone(), + })?; + let result = write_raw_entry_to_static_pool(static_root, capture_date_utc, &entry)?; + if result.written { + written_files += 1; + } else { + reused_files += 1; + } + } + + Ok(CirStaticPoolExportSummary { + unique_hashes: unique.len(), + written_files, + reused_files, + }) +} + +fn format_utc_date(date: time::Date) -> String { + format!( + "{:04}{:02}{:02}", + date.year(), + u8::from(date.month()), + date.day() + ) +} + +fn validate_sha256_hex(sha256_hex: &str) -> Result<(), CirStaticPoolError> { + if sha256_hex.len() != 64 || !sha256_hex.as_bytes().iter().all(u8::is_ascii_hexdigit) { + return Err(CirStaticPoolError::InvalidSha256Hex( + sha256_hex.to_string(), + )); + } + Ok(()) +} + +fn compute_sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::{ + CirStaticPoolError, compute_sha256_hex, export_hashes_from_store, static_pool_relative_path, + write_bytes_to_static_pool, + }; + use crate::storage::{RawByHashEntry, RepositoryViewEntry, RepositoryViewState, RocksStore}; + use std::fs; + + fn sample_date() -> time::Date { + time::Date::from_calendar_date(2026, time::Month::April, 7).unwrap() + } + + #[test] + fn static_pool_relative_path_uses_date_and_hash_prefixes() { + let path = static_pool_relative_path( + sample_date(), + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + ) + .expect("relative path"); + assert_eq!( + path, + std::path::PathBuf::from("20260407") + .join("ab") + .join("cd") + .join("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789") + ); + } + + #[test] + fn write_bytes_to_static_pool_is_idempotent_and_leaves_no_temp_files() { + let td = tempfile::tempdir().expect("tempdir"); + let bytes = b"static-pool-object"; + let sha = compute_sha256_hex(bytes); + + let first = write_bytes_to_static_pool(td.path(), sample_date(), &sha, bytes) + .expect("first write"); + let second = write_bytes_to_static_pool(td.path(), sample_date(), &sha, bytes) + .expect("second write"); + + assert!(first.written); + assert!(!second.written); + assert_eq!(fs::read(&first.final_path).expect("read final"), bytes); + + let all_files: Vec<_> = walk_files(td.path()); + assert_eq!(all_files.len(), 1); + assert!(!all_files[0] + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .contains(".tmp.")); + } + + #[test] + fn write_bytes_to_static_pool_rejects_bad_hash_and_empty_bytes() { + let td = tempfile::tempdir().expect("tempdir"); + let err = write_bytes_to_static_pool(td.path(), sample_date(), "not-a-hash", b"x") + .expect_err("bad hash must fail"); + assert!(matches!(err, CirStaticPoolError::InvalidSha256Hex(_))); + + let err = write_bytes_to_static_pool( + td.path(), + sample_date(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + b"", + ) + .expect_err("empty bytes must fail"); + assert!(matches!(err, CirStaticPoolError::EmptyBytes { .. })); + } + + #[test] + fn export_hashes_from_store_writes_unique_entries_and_fails_when_missing() { + let td = tempfile::tempdir().expect("tempdir"); + let store_dir = td.path().join("db"); + let static_root = td.path().join("static"); + let store = RocksStore::open(&store_dir).expect("open rocksdb"); + + let bytes = b"store-object".to_vec(); + let sha = compute_sha256_hex(&bytes); + let mut entry = RawByHashEntry::from_bytes(sha.clone(), bytes.clone()); + entry.origin_uris.push("rsync://example.test/repo/object.cer".to_string()); + store.put_raw_by_hash_entry(&entry).expect("put raw entry"); + store + .put_repository_view_entry(&RepositoryViewEntry { + rsync_uri: "rsync://example.test/repo/object.cer".to_string(), + current_hash: Some(sha.clone()), + repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), + object_type: Some("cer".to_string()), + state: RepositoryViewState::Present, + }) + .expect("put repository view"); + + let summary = export_hashes_from_store( + &store, + &static_root, + sample_date(), + &[sha.clone(), sha.clone()], + ) + .expect("export hashes"); + assert_eq!(summary.unique_hashes, 1); + assert_eq!(summary.written_files, 1); + assert_eq!(summary.reused_files, 0); + + let summary = export_hashes_from_store(&store, &static_root, sample_date(), &[sha.clone()]) + .expect("re-export hashes"); + assert_eq!(summary.unique_hashes, 1); + assert_eq!(summary.written_files, 0); + assert_eq!(summary.reused_files, 1); + + let err = export_hashes_from_store( + &store, + &static_root, + sample_date(), + &[String::from( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + )], + ) + .expect_err("missing raw_by_hash must fail"); + assert!(matches!(err, CirStaticPoolError::MissingRawByHash { .. })); + } + + fn walk_files(root: &std::path::Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + for entry in fs::read_dir(path).expect("read_dir") { + let entry = entry.expect("dir entry"); + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out + } +} diff --git a/src/cli.rs b/src/cli.rs index aaa7a75..9b452ee 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ use crate::ccr::{build_ccr_from_run, write_ccr_file}; +use crate::cir::export_cir_from_run; use std::path::{Path, PathBuf}; use crate::analysis::timing::{TimingHandle, TimingMeta, TimingMetaUpdate}; @@ -32,6 +33,10 @@ pub struct CliArgs { pub policy_path: Option, pub report_json_path: Option, pub ccr_out_path: Option, + pub cir_enabled: bool, + pub cir_out_path: Option, + pub cir_static_root: Option, + pub cir_tal_uri: Option, pub payload_replay_archive: Option, pub payload_replay_locks: Option, pub payload_base_archive: Option, @@ -41,6 +46,8 @@ pub struct CliArgs { pub payload_delta_locks: Option, pub rsync_local_dir: Option, + pub disable_rrdp: bool, + pub rsync_command: Option, pub http_timeout_secs: u64, pub rsync_timeout_secs: u64, @@ -67,6 +74,10 @@ Options: --policy Policy TOML path (optional) --report-json Write full audit report as JSON (optional) --ccr-out Write CCR DER ContentInfo to this path (optional) + --cir-enable Export CIR after the run completes + --cir-out Write CIR DER to this path (requires --cir-enable) + --cir-static-root Shared static pool root for CIR export (requires --cir-enable) + --cir-tal-uri Override TAL URI for CIR export when using --tal-path (optional) --payload-replay-archive Use local payload replay archive root (offline replay mode) --payload-replay-locks Use local payload replay locks.json (offline replay mode) --payload-base-archive Use local base payload archive root (offline delta replay) @@ -80,6 +91,8 @@ Options: --ta-path TA certificate DER file path (offline-friendly) --rsync-local-dir Use LocalDirRsyncFetcher rooted at this directory (offline tests) + --disable-rrdp Disable RRDP and synchronize only via rsync + --rsync-command Use this rsync command instead of the default rsync binary --http-timeout-secs HTTP fetch timeout seconds (default: 20) --rsync-timeout-secs rsync I/O timeout seconds (default: 60) --rsync-mirror-root Persist rsync mirrors under this directory (default: disabled) @@ -103,6 +116,10 @@ pub fn parse_args(argv: &[String]) -> Result { let mut policy_path: Option = None; let mut report_json_path: Option = None; let mut ccr_out_path: Option = None; + let mut cir_enabled: bool = false; + let mut cir_out_path: Option = None; + let mut cir_static_root: Option = None; + let mut cir_tal_uri: Option = None; let mut payload_replay_archive: Option = None; let mut payload_replay_locks: Option = None; let mut payload_base_archive: Option = None; @@ -112,6 +129,8 @@ pub fn parse_args(argv: &[String]) -> Result { let mut payload_delta_locks: Option = None; let mut rsync_local_dir: Option = None; + let mut disable_rrdp: bool = false; + let mut rsync_command: Option = None; let mut http_timeout_secs: u64 = 20; let mut rsync_timeout_secs: u64 = 60; let mut rsync_mirror_root: Option = None; @@ -161,6 +180,24 @@ pub fn parse_args(argv: &[String]) -> Result { let v = argv.get(i).ok_or("--ccr-out requires a value")?; ccr_out_path = Some(PathBuf::from(v)); } + "--cir-enable" => { + cir_enabled = true; + } + "--cir-out" => { + i += 1; + let v = argv.get(i).ok_or("--cir-out requires a value")?; + cir_out_path = Some(PathBuf::from(v)); + } + "--cir-static-root" => { + i += 1; + let v = argv.get(i).ok_or("--cir-static-root requires a value")?; + cir_static_root = Some(PathBuf::from(v)); + } + "--cir-tal-uri" => { + i += 1; + let v = argv.get(i).ok_or("--cir-tal-uri requires a value")?; + cir_tal_uri = Some(v.clone()); + } "--payload-replay-archive" => { i += 1; let v = argv @@ -215,6 +252,14 @@ pub fn parse_args(argv: &[String]) -> Result { let v = argv.get(i).ok_or("--rsync-local-dir requires a value")?; rsync_local_dir = Some(PathBuf::from(v)); } + "--disable-rrdp" => { + disable_rrdp = true; + } + "--rsync-command" => { + i += 1; + let v = argv.get(i).ok_or("--rsync-command requires a value")?; + rsync_command = Some(PathBuf::from(v)); + } "--http-timeout-secs" => { i += 1; let v = argv.get(i).ok_or("--http-timeout-secs requires a value")?; @@ -278,9 +323,28 @@ pub fn parse_args(argv: &[String]) -> Result { usage() )); } - if tal_path.is_some() && ta_path.is_none() { + if tal_path.is_some() && ta_path.is_none() && !disable_rrdp { return Err(format!( - "--tal-path requires --ta-path (offline-friendly mode)\n\n{}", + "--tal-path requires --ta-path unless --disable-rrdp is set\n\n{}", + usage() + )); + } + if cir_enabled && (cir_out_path.is_none() || cir_static_root.is_none()) { + return Err(format!( + "--cir-enable requires both --cir-out and --cir-static-root\n\n{}", + usage() + )); + } + if !cir_enabled && (cir_out_path.is_some() || cir_static_root.is_some() || cir_tal_uri.is_some()) + { + return Err(format!( + "--cir-out/--cir-static-root/--cir-tal-uri require --cir-enable\n\n{}", + usage() + )); + } + if cir_enabled && tal_path.is_some() && cir_tal_uri.is_none() { + return Err(format!( + "CIR export in --tal-path mode requires --cir-tal-uri\n\n{}", usage() )); } @@ -377,6 +441,10 @@ pub fn parse_args(argv: &[String]) -> Result { policy_path, report_json_path, ccr_out_path, + cir_enabled, + cir_out_path, + cir_static_root, + cir_tal_uri, payload_replay_archive, payload_replay_locks, payload_base_archive, @@ -385,6 +453,8 @@ pub fn parse_args(argv: &[String]) -> Result { payload_delta_archive, payload_delta_locks, rsync_local_dir, + disable_rrdp, + rsync_command, http_timeout_secs, rsync_timeout_secs, rsync_mirror_root, @@ -509,7 +579,10 @@ fn build_report( pub fn run(argv: &[String]) -> Result<(), String> { let args = parse_args(argv)?; - let policy = read_policy(args.policy_path.as_deref())?; + let mut policy = read_policy(args.policy_path.as_deref())?; + if args.disable_rrdp { + policy.sync_preference = crate::policy::SyncPreference::RsyncOnly; + } let validation_time = args .validation_time .unwrap_or_else(time::OffsetDateTime::now_utc); @@ -767,6 +840,37 @@ pub fn run(argv: &[String]) -> Result<(), String> { .map_err(|e| e.to_string())? } } + (None, Some(tal_path), None) => { + let tal_bytes = std::fs::read(tal_path) + .map_err(|e| format!("read tal failed: {}: {e}", tal_path.display()))?; + let tal_uri = args.cir_tal_uri.clone(); + if let Some((_, t)) = timing.as_ref() { + crate::validation::run_tree_from_tal::run_tree_from_tal_bytes_serial_audit_with_timing( + &store, + &policy, + &tal_bytes, + tal_uri, + &http, + &rsync, + validation_time, + &config, + t, + ) + .map_err(|e| e.to_string())? + } else { + crate::validation::run_tree_from_tal::run_tree_from_tal_bytes_serial_audit( + &store, + &policy, + &tal_bytes, + tal_uri, + &http, + &rsync, + validation_time, + &config, + ) + .map_err(|e| e.to_string())? + } + } _ => unreachable!("validated by parse_args"), } } else { @@ -776,6 +880,10 @@ pub fn run(argv: &[String]) -> Result<(), String> { }) .map_err(|e| e.to_string())?; let rsync = SystemRsyncFetcher::new(SystemRsyncConfig { + rsync_bin: args + .rsync_command + .clone() + .unwrap_or_else(|| PathBuf::from("rsync")), timeout: std::time::Duration::from_secs(args.rsync_timeout_secs.max(1)), mirror_root: args.rsync_mirror_root.clone(), ..SystemRsyncConfig::default() @@ -845,6 +953,37 @@ pub fn run(argv: &[String]) -> Result<(), String> { .map_err(|e| e.to_string())? } } + (None, Some(tal_path), None) => { + let tal_bytes = std::fs::read(tal_path) + .map_err(|e| format!("read tal failed: {}: {e}", tal_path.display()))?; + let tal_uri = args.cir_tal_uri.clone(); + if let Some((_, t)) = timing.as_ref() { + crate::validation::run_tree_from_tal::run_tree_from_tal_bytes_serial_audit_with_timing( + &store, + &policy, + &tal_bytes, + tal_uri, + &http, + &rsync, + validation_time, + &config, + t, + ) + .map_err(|e| e.to_string())? + } else { + crate::validation::run_tree_from_tal::run_tree_from_tal_bytes_serial_audit( + &store, + &policy, + &tal_bytes, + tal_uri, + &http, + &rsync, + validation_time, + &config, + ) + .map_err(|e| e.to_string())? + } + } _ => unreachable!("validated by parse_args"), } }; @@ -880,6 +1019,40 @@ pub fn run(argv: &[String]) -> Result<(), String> { eprintln!("wrote CCR: {}", path.display()); } + if args.cir_enabled { + let cir_tal_uri = args + .tal_url + .clone() + .or(args.cir_tal_uri.clone()) + .ok_or_else(|| "CIR export requires a TAL URI source".to_string())?; + let cir_out_path = args + .cir_out_path + .as_deref() + .expect("validated by parse_args for cir"); + let cir_static_root = args + .cir_static_root + .as_deref() + .expect("validated by parse_args for cir"); + let summary = export_cir_from_run( + &store, + &out.discovery.trust_anchor, + &cir_tal_uri, + validation_time, + cir_out_path, + cir_static_root, + time::OffsetDateTime::now_utc().date(), + ) + .map_err(|e| e.to_string())?; + eprintln!( + "wrote CIR: {} (objects={}, tals={}, static_written={}, static_reused={})", + cir_out_path.display(), + summary.object_count, + summary.tal_count, + summary.static_pool.written_files, + summary.static_pool.reused_files + ); + } + let report = build_report(&policy, validation_time, out); if let Some(p) = args.report_json_path.as_deref() { @@ -1014,6 +1187,80 @@ mod tests { assert_eq!(args.ccr_out_path.as_deref(), Some(std::path::Path::new("out/example.ccr"))); } + #[test] + fn parse_accepts_cir_enable_with_required_paths_and_tal_override() { + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-path".to_string(), + "x.tal".to_string(), + "--ta-path".to_string(), + "x.cer".to_string(), + "--rsync-local-dir".to_string(), + "repo".to_string(), + "--cir-enable".to_string(), + "--cir-out".to_string(), + "out/example.cir".to_string(), + "--cir-static-root".to_string(), + "out/static".to_string(), + "--cir-tal-uri".to_string(), + "https://example.test/root.tal".to_string(), + ]; + let args = parse_args(&argv).expect("parse args"); + assert!(args.cir_enabled); + assert_eq!(args.cir_out_path.as_deref(), Some(std::path::Path::new("out/example.cir"))); + assert_eq!(args.cir_static_root.as_deref(), Some(std::path::Path::new("out/static"))); + assert_eq!(args.cir_tal_uri.as_deref(), Some("https://example.test/root.tal")); + } + + #[test] + fn parse_rejects_incomplete_or_invalid_cir_flags() { + let argv_missing = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-url".to_string(), + "https://example.test/root.tal".to_string(), + "--cir-enable".to_string(), + "--cir-out".to_string(), + "out/example.cir".to_string(), + ]; + let err = parse_args(&argv_missing).unwrap_err(); + assert!(err.contains("--cir-enable requires both --cir-out and --cir-static-root"), "{err}"); + + let argv_needs_enable = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-url".to_string(), + "https://example.test/root.tal".to_string(), + "--cir-out".to_string(), + "out/example.cir".to_string(), + ]; + let err = parse_args(&argv_needs_enable).unwrap_err(); + assert!(err.contains("require --cir-enable"), "{err}"); + + let argv_offline_missing_uri = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-path".to_string(), + "x.tal".to_string(), + "--ta-path".to_string(), + "x.cer".to_string(), + "--rsync-local-dir".to_string(), + "repo".to_string(), + "--cir-enable".to_string(), + "--cir-out".to_string(), + "out/example.cir".to_string(), + "--cir-static-root".to_string(), + "out/static".to_string(), + ]; + let err = parse_args(&argv_offline_missing_uri).unwrap_err(); + assert!(err.contains("requires --cir-tal-uri"), "{err}"); + } + #[test] fn parse_rejects_invalid_validation_time() { let argv = vec![ @@ -1114,6 +1361,28 @@ mod tests { assert_eq!(args.max_depth, Some(0)); } + #[test] + fn parse_accepts_tal_path_without_ta_when_disable_rrdp_is_set() { + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-path".to_string(), + "a.tal".to_string(), + "--disable-rrdp".to_string(), + "--rsync-command".to_string(), + "/tmp/fake-rsync".to_string(), + ]; + let args = parse_args(&argv).expect("parse"); + assert_eq!(args.tal_path.as_deref(), Some(Path::new("a.tal"))); + assert!(args.ta_path.is_none()); + assert!(args.disable_rrdp); + assert_eq!( + args.rsync_command.as_deref(), + Some(Path::new("/tmp/fake-rsync")) + ); + } + #[test] fn parse_accepts_payload_delta_replay_mode_with_offline_tal_and_ta() { let argv = vec![ diff --git a/src/data_model/mod.rs b/src/data_model/mod.rs index e7ffdc6..011570d 100644 --- a/src/data_model/mod.rs +++ b/src/data_model/mod.rs @@ -9,4 +9,5 @@ pub mod signed_object; pub mod ta; pub mod tal; +#[cfg(feature = "full")] pub mod router_cert; diff --git a/src/fetch/rsync_system.rs b/src/fetch/rsync_system.rs index 5ac33c8..b84894b 100644 --- a/src/fetch/rsync_system.rs +++ b/src/fetch/rsync_system.rs @@ -98,7 +98,8 @@ impl SystemRsyncFetcher { } fn module_fetch_uri(&self, rsync_base_uri: &str) -> String { - rsync_capture_scope_uri(rsync_base_uri).unwrap_or_else(|| normalize_rsync_base_uri(rsync_base_uri)) + rsync_module_root_uri(rsync_base_uri) + .unwrap_or_else(|| normalize_rsync_base_uri(rsync_base_uri)) } } @@ -171,7 +172,7 @@ impl Drop for TempDir { } } -fn rsync_capture_scope_uri(s: &str) -> Option { +fn rsync_module_root_uri(s: &str) -> Option { let normalized = normalize_rsync_base_uri(s); let rest = normalized.strip_prefix("rsync://")?; let mut host_and_path = rest.splitn(2, '/'); @@ -181,10 +182,8 @@ fn rsync_capture_scope_uri(s: &str) -> Option { if segments.is_empty() { return None; } - if segments.len() >= 4 { - segments.pop(); - } - Some(format!("rsync://{authority}/{}/", segments.join("/"))) + let module = segments.remove(0); + Some(format!("rsync://{authority}/{module}/")) } fn walk_dir_collect( @@ -283,28 +282,28 @@ mod tests { } #[test] - fn rsync_capture_scope_uri_widens_only_deep_publication_points() { + fn rsync_module_root_uri_returns_host_and_module_only() { assert_eq!( - rsync_capture_scope_uri("rsync://example.net/repo/ta/ca/publication-point/"), - Some("rsync://example.net/repo/ta/ca/".to_string()) - ); - assert_eq!( - rsync_capture_scope_uri("rsync://example.net/repo/ta/"), - Some("rsync://example.net/repo/ta/".to_string()) - ); - assert_eq!( - rsync_capture_scope_uri("rsync://example.net/repo/"), + rsync_module_root_uri("rsync://example.net/repo/ta/ca/publication-point/"), Some("rsync://example.net/repo/".to_string()) ); - assert_eq!(rsync_capture_scope_uri("https://example.net/repo"), None); + assert_eq!( + rsync_module_root_uri("rsync://example.net/repo/ta/"), + Some("rsync://example.net/repo/".to_string()) + ); + assert_eq!( + rsync_module_root_uri("rsync://example.net/repo/"), + Some("rsync://example.net/repo/".to_string()) + ); + assert_eq!(rsync_module_root_uri("https://example.net/repo"), None); } #[test] - fn system_rsync_dedup_key_uses_capture_scope() { + fn system_rsync_dedup_key_uses_module_root() { let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig::default()); assert_eq!( fetcher.dedup_key("rsync://example.net/repo/ta/ca/publication-point/"), - "rsync://example.net/repo/ta/ca/" + "rsync://example.net/repo/" ); } diff --git a/src/lib.rs b/src/lib.rs index c6f0365..a128c74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod ccr; +pub mod cir; pub mod data_model; #[cfg(feature = "full")] diff --git a/src/sync/repo.rs b/src/sync/repo.rs index b0b8946..ea604b0 100644 --- a/src/sync/repo.rs +++ b/src/sync/repo.rs @@ -558,11 +558,12 @@ fn rsync_sync_into_current_store( download_log: Option<&DownloadLogHandle>, ) -> Result { let started = std::time::Instant::now(); + let sync_scope_uri = rsync_fetcher.dedup_key(rsync_base_uri); crate::progress_log::emit( "rsync_sync_start", serde_json::json!({ "rsync_base_uri": rsync_base_uri, - "dedup_key": rsync_fetcher.dedup_key(rsync_base_uri), + "sync_scope_uri": &sync_scope_uri, }), ); let _s = timing @@ -613,6 +614,7 @@ fn rsync_sync_into_current_store( "rsync_sync_fetch_done", serde_json::json!({ "rsync_base_uri": rsync_base_uri, + "sync_scope_uri": &sync_scope_uri, "object_count": object_count, "bytes_total": bytes_total, "duration_ms": started.elapsed().as_millis() as u64, @@ -625,7 +627,7 @@ fn rsync_sync_into_current_store( drop(_p); let existing_view = store - .list_repository_view_entries_with_prefix(rsync_base_uri) + .list_repository_view_entries_with_prefix(&sync_scope_uri) .map_err(|e| RepoSyncError::Storage(e.to_string()))?; let _proj = timing @@ -669,7 +671,7 @@ fn rsync_sync_into_current_store( for entry in existing_view { if !new_set.contains(&entry.rsync_uri) { repository_view_entries.push(build_repository_view_withdrawn_entry( - rsync_base_uri, + &sync_scope_uri, &entry.rsync_uri, entry.current_hash, )); @@ -682,7 +684,7 @@ fn rsync_sync_into_current_store( .cloned() .ok_or_else(|| RepoSyncError::Storage(format!("missing raw_by_hash mapping for {uri}")))?; repository_view_entries.push(build_repository_view_present_entry( - rsync_base_uri, + &sync_scope_uri, uri, ¤t_hash, )); @@ -700,6 +702,7 @@ fn rsync_sync_into_current_store( "rsync_sync_done", serde_json::json!({ "rsync_base_uri": rsync_base_uri, + "sync_scope_uri": &sync_scope_uri, "object_count": object_count, "bytes_total": bytes_total, "duration_ms": total_duration_ms, @@ -710,6 +713,7 @@ fn rsync_sync_into_current_store( "rsync_sync_slow", serde_json::json!({ "rsync_base_uri": rsync_base_uri, + "sync_scope_uri": &sync_scope_uri, "object_count": object_count, "bytes_total": bytes_total, "duration_ms": total_duration_ms, @@ -731,6 +735,8 @@ mod tests { use crate::replay::delta_fetch_rsync::PayloadDeltaReplayRsyncFetcher; use crate::replay::fetch_http::PayloadReplayHttpFetcher; use crate::replay::fetch_rsync::PayloadReplayRsyncFetcher; + use crate::storage::RepositoryViewState; + use crate::sync::store_projection::build_repository_view_present_entry; use crate::sync::rrdp::Fetcher as HttpFetcher; use crate::sync::rrdp::RrdpState; use base64::Engine; @@ -779,6 +785,62 @@ mod tests { ); } + #[test] + fn rsync_sync_uses_fetcher_dedup_scope_for_repository_view_projection() { + struct ScopeFetcher; + impl RsyncFetcher for ScopeFetcher { + fn fetch_objects( + &self, + _rsync_base_uri: &str, + ) -> Result)>, RsyncFetchError> { + Ok(vec![( + "rsync://example.net/repo/child/a.mft".to_string(), + b"manifest".to_vec(), + )]) + } + + fn dedup_key(&self, _rsync_base_uri: &str) -> String { + "rsync://example.net/repo/".to_string() + } + } + + let td = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(td.path()).expect("open rocksdb"); + let seeded = build_repository_view_present_entry( + "rsync://example.net/repo/", + "rsync://example.net/repo/sibling/old.roa", + &compute_sha256_hex(b"old"), + ); + store + .put_projection_batch(&[seeded], &[], &[]) + .expect("seed repository view"); + + let fetcher = ScopeFetcher; + let written = rsync_sync_into_current_store( + &store, + "rsync://example.net/repo/child/", + &fetcher, + None, + None, + ) + .expect("sync ok"); + assert_eq!(written, 1); + + let entries = store + .list_repository_view_entries_with_prefix("rsync://example.net/repo/") + .expect("list repository view"); + let sibling = entries + .iter() + .find(|entry| entry.rsync_uri == "rsync://example.net/repo/sibling/old.roa") + .expect("sibling entry exists"); + assert_eq!(sibling.state, RepositoryViewState::Withdrawn); + let child = entries + .iter() + .find(|entry| entry.rsync_uri == "rsync://example.net/repo/child/a.mft") + .expect("child entry exists"); + assert_eq!(child.state, RepositoryViewState::Present); + } + fn notification_xml( session_id: &str, serial: u64, diff --git a/src/validation/from_tal.rs b/src/validation/from_tal.rs index 948ae16..8827fa8 100644 --- a/src/validation/from_tal.rs +++ b/src/validation/from_tal.rs @@ -2,6 +2,7 @@ use url::Url; use crate::data_model::ta::{TrustAnchor, TrustAnchorError}; use crate::data_model::tal::{Tal, TalDecodeError}; +use crate::fetch::rsync::RsyncFetcher; use crate::sync::rrdp::Fetcher; use crate::validation::ca_instance::{ CaInstanceUris, CaInstanceUrisError, ca_instance_uris_from_ca_certificate, @@ -104,6 +105,108 @@ pub fn discover_root_ca_instance_from_tal( }))) } +pub fn discover_root_ca_instance_from_tal_with_fetchers( + http_fetcher: &dyn Fetcher, + rsync_fetcher: &dyn RsyncFetcher, + tal: Tal, + tal_url: Option, +) -> Result { + if tal.ta_uris.is_empty() { + return Err(FromTalError::NoTaUris); + } + + let mut last_err: Option = None; + let mut ta_uris = tal.ta_uris.clone(); + ta_uris.sort_by_key(|uri| if uri.scheme() == "rsync" { 0 } else { 1 }); + for ta_uri in ta_uris.iter() { + let ta_der = match fetch_ta_der(http_fetcher, rsync_fetcher, ta_uri) { + Ok(b) => b, + Err(e) => { + last_err = Some(format!("fetch {ta_uri} failed: {e}")); + continue; + } + }; + + let trust_anchor = match TrustAnchor::bind_der(tal.clone(), &ta_der, Some(ta_uri)) { + Ok(ta) => ta, + Err(e) => { + last_err = Some(format!("bind {ta_uri} failed: {e}")); + continue; + } + }; + + let ca_instance = + match ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca) { + Ok(v) => v, + Err(e) => { + last_err = Some(format!("CA instance discovery failed: {e}")); + continue; + } + }; + + return Ok(DiscoveredRootCaInstance { + tal_url, + trust_anchor, + ca_instance, + }); + } + + Err(FromTalError::TaFetch(last_err.unwrap_or_else(|| { + "unknown TA candidate error".to_string() + }))) +} + +fn fetch_ta_der( + http_fetcher: &dyn Fetcher, + rsync_fetcher: &dyn RsyncFetcher, + ta_uri: &Url, +) -> Result, String> { + match ta_uri.scheme() { + "https" | "http" => http_fetcher.fetch(ta_uri.as_str()), + "rsync" => fetch_ta_der_via_rsync(rsync_fetcher, ta_uri.as_str()), + scheme => Err(format!("unsupported TA URI scheme: {scheme}")), + } +} + +fn fetch_ta_der_via_rsync( + rsync_fetcher: &dyn RsyncFetcher, + ta_rsync_uri: &str, +) -> Result, String> { + let base = rsync_parent_uri(ta_rsync_uri)?; + let objects = rsync_fetcher + .fetch_objects(&base) + .map_err(|e| e.to_string())?; + objects + .into_iter() + .find(|(uri, _)| uri == ta_rsync_uri) + .map(|(_, bytes)| bytes) + .ok_or_else(|| format!("TA rsync object not found in fetched subtree: {ta_rsync_uri}")) +} + +fn rsync_parent_uri(ta_rsync_uri: &str) -> Result { + let url = Url::parse(ta_rsync_uri).map_err(|e| e.to_string())?; + if url.scheme() != "rsync" { + return Err(format!("not an rsync URI: {ta_rsync_uri}")); + } + let host = url + .host_str() + .ok_or_else(|| format!("missing host in rsync URI: {ta_rsync_uri}"))?; + let segments = url + .path_segments() + .ok_or_else(|| format!("missing path in rsync URI: {ta_rsync_uri}"))? + .collect::>(); + if segments.is_empty() || segments.last().copied().unwrap_or_default().is_empty() { + return Err(format!("rsync URI must reference a file object: {ta_rsync_uri}")); + } + let parent_segments = &segments[..segments.len() - 1]; + let mut parent = format!("rsync://{host}/"); + if !parent_segments.is_empty() { + parent.push_str(&parent_segments.join("/")); + parent.push('/'); + } + Ok(parent) +} + pub fn discover_root_ca_instance_from_tal_and_ta_der( tal_bytes: &[u8], ta_der: &[u8], @@ -119,6 +222,58 @@ pub fn discover_root_ca_instance_from_tal_and_ta_der( }) } +#[cfg(test)] +mod tests { + use super::*; + use crate::fetch::rsync::LocalDirRsyncFetcher; + + #[test] + fn discover_root_ca_instance_from_tal_with_fetchers_supports_rsync_ta_uri() { + let tal_bytes = std::fs::read( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/tal/apnic-rfc7730-https.tal"), + ) + .unwrap(); + let ta_der = std::fs::read( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/ta/apnic-ta.cer"), + ) + .unwrap(); + let tal = Tal::decode_bytes(&tal_bytes).unwrap(); + let rsync_uri = tal + .ta_uris + .iter() + .find(|uri| uri.scheme() == "rsync") + .unwrap() + .clone(); + + let td = tempfile::tempdir().unwrap(); + let mirror_root = td.path().join(rsync_uri.host_str().unwrap()).join("repository"); + std::fs::create_dir_all(&mirror_root).unwrap(); + std::fs::write( + mirror_root.join("apnic-rpki-root-iana-origin.cer"), + ta_der, + ) + .unwrap(); + + let http = crate::fetch::http::BlockingHttpFetcher::new( + crate::fetch::http::HttpFetcherConfig::default(), + ) + .unwrap(); + let rsync = LocalDirRsyncFetcher::new( + td.path().join(rsync_uri.host_str().unwrap()).join("repository"), + ); + let discovery = discover_root_ca_instance_from_tal_with_fetchers(&http, &rsync, tal, None) + .expect("discover via rsync TA"); + assert!(discovery + .trust_anchor + .resolved_ta_uri + .unwrap() + .as_str() + .starts_with("rsync://")); + } +} + pub fn run_root_from_tal_url_once( store: &crate::storage::RocksStore, policy: &crate::policy::Policy, diff --git a/src/validation/run_tree_from_tal.rs b/src/validation/run_tree_from_tal.rs index cc64823..a7f44de 100644 --- a/src/validation/run_tree_from_tal.rs +++ b/src/validation/run_tree_from_tal.rs @@ -15,6 +15,7 @@ use crate::replay::fetch_rsync::PayloadReplayRsyncFetcher; use crate::sync::rrdp::Fetcher; use crate::validation::from_tal::{ DiscoveredRootCaInstance, FromTalError, discover_root_ca_instance_from_tal_and_ta_der, + discover_root_ca_instance_from_tal_with_fetchers, discover_root_ca_instance_from_tal_url, }; use crate::validation::tree::{ @@ -295,6 +296,117 @@ pub fn run_tree_from_tal_and_ta_der_serial( Ok(RunTreeFromTalOutput { discovery, tree }) } +pub fn run_tree_from_tal_bytes_serial_audit( + store: &crate::storage::RocksStore, + policy: &crate::policy::Policy, + tal_bytes: &[u8], + tal_uri: Option, + http_fetcher: &dyn Fetcher, + rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher, + validation_time: time::OffsetDateTime, + config: &TreeRunConfig, +) -> Result { + let tal = crate::data_model::tal::Tal::decode_bytes(tal_bytes).map_err(FromTalError::from)?; + let discovery = + discover_root_ca_instance_from_tal_with_fetchers(http_fetcher, rsync_fetcher, tal, tal_uri)?; + + let download_log = DownloadLogHandle::new(); + let runner = Rpkiv1PublicationPointRunner { + store, + policy, + http_fetcher, + rsync_fetcher, + validation_time, + timing: None, + download_log: Some(download_log.clone()), + replay_archive_index: None, + replay_delta_index: None, + rrdp_dedup: true, + rrdp_repo_cache: Mutex::new(HashMap::new()), + rsync_dedup: true, + rsync_repo_cache: Mutex::new(HashMap::new()), + }; + + let root = root_handle_from_trust_anchor( + &discovery.trust_anchor, + derive_tal_id(&discovery), + None, + &discovery.ca_instance, + ); + let TreeRunAuditOutput { + tree, + publication_points, + } = run_tree_serial_audit(root, &runner, config)?; + + let downloads = download_log.snapshot_events(); + let download_stats = DownloadLogHandle::stats_from_events(&downloads); + Ok(RunTreeFromTalAuditOutput { + discovery, + tree, + publication_points, + downloads, + download_stats, + }) +} + +pub fn run_tree_from_tal_bytes_serial_audit_with_timing( + store: &crate::storage::RocksStore, + policy: &crate::policy::Policy, + tal_bytes: &[u8], + tal_uri: Option, + http_fetcher: &dyn Fetcher, + rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher, + validation_time: time::OffsetDateTime, + config: &TreeRunConfig, + timing: &TimingHandle, +) -> Result { + let _tal = timing.span_phase("tal_bootstrap"); + let tal = crate::data_model::tal::Tal::decode_bytes(tal_bytes).map_err(FromTalError::from)?; + let discovery = + discover_root_ca_instance_from_tal_with_fetchers(http_fetcher, rsync_fetcher, tal, tal_uri)?; + drop(_tal); + + let download_log = DownloadLogHandle::new(); + let runner = Rpkiv1PublicationPointRunner { + store, + policy, + http_fetcher, + rsync_fetcher, + validation_time, + timing: Some(timing.clone()), + download_log: Some(download_log.clone()), + replay_archive_index: None, + replay_delta_index: None, + rrdp_dedup: true, + rrdp_repo_cache: Mutex::new(HashMap::new()), + rsync_dedup: true, + rsync_repo_cache: Mutex::new(HashMap::new()), + }; + + let root = root_handle_from_trust_anchor( + &discovery.trust_anchor, + derive_tal_id(&discovery), + None, + &discovery.ca_instance, + ); + let _tree = timing.span_phase("tree_run"); + let TreeRunAuditOutput { + tree, + publication_points, + } = run_tree_serial_audit(root, &runner, config)?; + drop(_tree); + + let downloads = download_log.snapshot_events(); + let download_stats = DownloadLogHandle::stats_from_events(&downloads); + Ok(RunTreeFromTalAuditOutput { + discovery, + tree, + publication_points, + downloads, + download_stats, + }) +} + pub fn run_tree_from_tal_and_ta_der_serial_audit( store: &crate::storage::RocksStore, policy: &crate::policy::Policy, diff --git a/src/validation/tree_runner.rs b/src/validation/tree_runner.rs index b73da1f..ad40bd1 100644 --- a/src/validation/tree_runner.rs +++ b/src/validation/tree_runner.rs @@ -111,7 +111,11 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> { let attempted_rrdp = self.policy.sync_preference == crate::policy::SyncPreference::RrdpThenRsync; let original_notification_uri = ca.rrdp_notification_uri.as_deref(); - let mut effective_notification_uri = original_notification_uri; + let mut effective_notification_uri = if attempted_rrdp { + original_notification_uri + } else { + None + }; let mut skip_sync_due_to_dedup = false; if attempted_rrdp && self.rrdp_dedup { @@ -4129,6 +4133,116 @@ authorityKeyIdentifier = keyid:always assert_eq!(calls.load(Ordering::SeqCst), 1, "module-scope dedup should skip second sync"); } + #[test] + fn runner_rsync_dedup_works_in_rsync_only_mode_even_when_rrdp_notify_exists() { + let fixture_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0"); + assert!(fixture_dir.is_dir(), "fixture directory must exist"); + + let first_base_uri = "rsync://rpki.cernet.net/repo/cernet/0/".to_string(); + let second_base_uri = "rsync://rpki.cernet.net/repo/cernet/0/sub/".to_string(); + let manifest_file = "05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft"; + let manifest_rsync_uri = format!("{first_base_uri}{manifest_file}"); + + let fixture_manifest_bytes = + std::fs::read(fixture_dir.join(manifest_file)).expect("read manifest fixture"); + let fixture_manifest = + crate::data_model::manifest::ManifestObject::decode_der(&fixture_manifest_bytes) + .expect("decode manifest fixture"); + let validation_time = fixture_manifest.manifest.this_update + time::Duration::seconds(60); + + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let policy = Policy { + sync_preference: crate::policy::SyncPreference::RsyncOnly, + ..Policy::default() + }; + + let issuer_ca_der = std::fs::read( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer", + ), + ) + .expect("read issuer ca fixture"); + let issuer_ca = ResourceCertificate::decode_der(&issuer_ca_der).expect("decode issuer ca"); + + let handle = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: issuer_ca_der, + ca_certificate_rsync_uri: Some("rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer".to_string()), + effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), + effective_as_resources: issuer_ca.tbs.extensions.as_resources.clone(), + rsync_base_uri: first_base_uri.clone(), + manifest_rsync_uri: manifest_rsync_uri.clone(), + publication_point_rsync_uri: first_base_uri.clone(), + rrdp_notification_uri: Some("https://rrdp.example.test/notification.xml".to_string()), + }; + let second_handle = CaInstanceHandle { + rsync_base_uri: second_base_uri.clone(), + publication_point_rsync_uri: second_base_uri.clone(), + ..handle.clone() + }; + + struct ModuleScopeRsyncFetcher { + inner: LocalDirRsyncFetcher, + calls: Arc, + } + impl RsyncFetcher for ModuleScopeRsyncFetcher { + fn fetch_objects( + &self, + rsync_base_uri: &str, + ) -> Result)>, RsyncFetchError> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.inner.fetch_objects(rsync_base_uri) + } + + fn dedup_key(&self, _rsync_base_uri: &str) -> String { + "rsync://rpki.cernet.net/repo/".to_string() + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let rsync = ModuleScopeRsyncFetcher { + inner: LocalDirRsyncFetcher::new(&fixture_dir), + calls: calls.clone(), + }; + + let runner = Rpkiv1PublicationPointRunner { + store: &store, + policy: &policy, + http_fetcher: &NeverHttpFetcher, + rsync_fetcher: &rsync, + validation_time, + timing: None, + download_log: None, + replay_archive_index: None, + replay_delta_index: None, + rrdp_dedup: true, + rrdp_repo_cache: Mutex::new(HashMap::new()), + rsync_dedup: true, + rsync_repo_cache: Mutex::new(HashMap::new()), + }; + + let first = runner.run_publication_point(&handle).expect("first run ok"); + assert_eq!(first.source, PublicationPointSource::Fresh); + + let second = runner + .run_publication_point(&second_handle) + .expect("second run ok"); + assert!(matches!( + second.source, + PublicationPointSource::Fresh | PublicationPointSource::VcirCurrentInstance + )); + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "rsync-only mode must deduplicate by rsync scope even when RRDP notification is present" + ); + } + #[test] fn runner_when_repo_sync_fails_uses_current_instance_vcir_and_keeps_children_empty_for_fixture() { diff --git a/tests/test_cir_matrix_m9.rs b/tests/test_cir_matrix_m9.rs new file mode 100644 index 0000000..3d16f42 --- /dev/null +++ b/tests/test_cir_matrix_m9.rs @@ -0,0 +1,151 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use rpki::cir::{ + encode_cir, materialize_cir, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + CIR_VERSION_V1, +}; + +fn apnic_tal_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tal/apnic-rfc7730-https.tal") +} + +fn apnic_ta_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ta/apnic-ta.cer") +} + +fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { + let tal_bytes = std::fs::read(apnic_tal_path()).expect("read tal"); + let ta_bytes = std::fs::read(apnic_ta_path()).expect("read ta"); + let tal = rpki::data_model::tal::Tal::decode_bytes(&tal_bytes).expect("decode tal"); + let ta_rsync_uri = tal + .ta_uris + .iter() + .find(|uri| uri.scheme() == "rsync") + .expect("tal has rsync uri") + .as_str() + .to_string(); + let ta_hash = { + use sha2::{Digest, Sha256}; + Sha256::digest(&ta_bytes).to_vec() + }; + ( + CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: time::OffsetDateTime::parse( + "2026-04-07T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .unwrap(), + objects: vec![CirObject { + rsync_uri: ta_rsync_uri, + sha256: ta_hash, + }], + tals: vec![CirTal { + tal_uri: "https://example.test/root.tal".to_string(), + tal_bytes, + }], + }, + ta_bytes, + ) +} + +fn write_static(root: &Path, date: &str, bytes: &[u8]) { + use sha2::{Digest, Sha256}; + let hash = hex::encode(Sha256::digest(bytes)); + let dir = root.join(date).join(&hash[0..2]).join(&hash[2..4]); + std::fs::create_dir_all(&dir).expect("mkdir static"); + std::fs::write(dir.join(hash), bytes).expect("write static object"); +} + +fn prepare_reference_ccr(work: &Path, cir: &CanonicalInputRepresentation, mirror_root: &Path) -> PathBuf { + let reference_ccr = work.join("reference.ccr"); + let rpki_bin = env!("CARGO_BIN_EXE_rpki"); + let wrapper = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/cir/cir-rsync-wrapper"); + let tal_path = apnic_tal_path(); + let ta_path = apnic_ta_path(); + let out = Command::new(rpki_bin) + .env("REAL_RSYNC_BIN", "/usr/bin/rsync") + .env("CIR_MIRROR_ROOT", mirror_root) + .args([ + "--db", + work.join("reference-db").to_string_lossy().as_ref(), + "--tal-path", + tal_path.to_string_lossy().as_ref(), + "--ta-path", + ta_path.to_string_lossy().as_ref(), + "--disable-rrdp", + "--rsync-command", + wrapper.to_string_lossy().as_ref(), + "--validation-time", + &cir.validation_time + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + "--max-depth", + "0", + "--max-instances", + "1", + "--ccr-out", + reference_ccr.to_string_lossy().as_ref(), + ]) + .output() + .expect("run reference rpki"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + reference_ccr +} + +#[test] +fn cir_replay_matrix_script_matches_reference_for_all_participants() { + if !Path::new("/usr/bin/rsync").exists() + || !Path::new("/home/yuyr/dev/rust_playground/routinator/target/debug/routinator").exists() + || !Path::new("/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client").exists() + { + return; + } + + let td = tempfile::tempdir().expect("tempdir"); + let static_root = td.path().join("static"); + let cir_path = td.path().join("sample.cir"); + let mirror_root = td.path().join("mirror"); + let out_dir = td.path().join("matrix-out"); + + let (cir, ta_bytes) = build_ta_only_cir(); + std::fs::write(&cir_path, encode_cir(&cir).expect("encode cir")).expect("write cir"); + write_static(&static_root, "20260407", &ta_bytes); + materialize_cir(&cir, &static_root, &mirror_root, true).expect("materialize"); + let reference_ccr = prepare_reference_ccr(td.path(), &cir, &mirror_root); + + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/cir/run_cir_replay_matrix.sh"); + let out = Command::new(script) + .args([ + "--cir", + cir_path.to_string_lossy().as_ref(), + "--static-root", + static_root.to_string_lossy().as_ref(), + "--out-dir", + out_dir.to_string_lossy().as_ref(), + "--reference-ccr", + reference_ccr.to_string_lossy().as_ref(), + "--rpki-client-build-dir", + "/home/yuyr/dev/rpki-client-9.7/build-m5", + "--rpki-bin", + env!("CARGO_BIN_EXE_rpki"), + ]) + .output() + .expect("run cir matrix script"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + + let summary: serde_json::Value = + serde_json::from_slice(&std::fs::read(out_dir.join("summary.json")).expect("read summary")) + .expect("parse summary"); + assert_eq!(summary["allMatch"], true); + let participants = summary["participants"].as_array().expect("participants array"); + assert_eq!(participants.len(), 3); + for participant in participants { + assert_eq!(participant["exitCode"], 0); + assert_eq!(participant["match"], true); + assert_eq!(participant["vrps"]["match"], true); + assert_eq!(participant["vaps"]["match"], true); + } +} diff --git a/tests/test_cir_peer_replay_m8.rs b/tests/test_cir_peer_replay_m8.rs new file mode 100644 index 0000000..3608367 --- /dev/null +++ b/tests/test_cir_peer_replay_m8.rs @@ -0,0 +1,182 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use rpki::cir::{ + CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir, + materialize_cir, +}; + +fn apnic_tal_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tal/apnic-rfc7730-https.tal") +} + +fn apnic_ta_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ta/apnic-ta.cer") +} + +fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { + let tal_bytes = std::fs::read(apnic_tal_path()).expect("read tal"); + let ta_bytes = std::fs::read(apnic_ta_path()).expect("read ta"); + let tal = rpki::data_model::tal::Tal::decode_bytes(&tal_bytes).expect("decode tal"); + let ta_rsync_uri = tal + .ta_uris + .iter() + .find(|uri| uri.scheme() == "rsync") + .expect("tal has rsync uri") + .as_str() + .to_string(); + let ta_hash = { + use sha2::{Digest, Sha256}; + Sha256::digest(&ta_bytes).to_vec() + }; + ( + CanonicalInputRepresentation { + version: CIR_VERSION_V1, + hash_alg: CirHashAlgorithm::Sha256, + validation_time: time::OffsetDateTime::parse( + "2026-04-07T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .unwrap(), + objects: vec![CirObject { + rsync_uri: ta_rsync_uri, + sha256: ta_hash, + }], + tals: vec![CirTal { + tal_uri: "https://example.test/root.tal".to_string(), + tal_bytes, + }], + }, + ta_bytes, + ) +} + +fn write_static(root: &Path, date: &str, bytes: &[u8]) { + use sha2::{Digest, Sha256}; + let hash = hex::encode(Sha256::digest(bytes)); + let dir = root.join(date).join(&hash[0..2]).join(&hash[2..4]); + std::fs::create_dir_all(&dir).expect("mkdir static"); + std::fs::write(dir.join(hash), bytes).expect("write static object"); +} + +fn prepare_reference_ccr(work: &Path, cir: &CanonicalInputRepresentation, mirror_root: &Path) -> PathBuf { + let reference_ccr = work.join("reference.ccr"); + let rpki_bin = env!("CARGO_BIN_EXE_rpki"); + let wrapper = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/cir/cir-rsync-wrapper"); + let tal_path = apnic_tal_path(); + let ta_path = apnic_ta_path(); + let out = Command::new(rpki_bin) + .env("REAL_RSYNC_BIN", "/usr/bin/rsync") + .env("CIR_MIRROR_ROOT", mirror_root) + .args([ + "--db", + work.join("reference-db").to_string_lossy().as_ref(), + "--tal-path", + tal_path.to_string_lossy().as_ref(), + "--ta-path", + ta_path.to_string_lossy().as_ref(), + "--disable-rrdp", + "--rsync-command", + wrapper.to_string_lossy().as_ref(), + "--validation-time", + &cir.validation_time + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + "--max-depth", + "0", + "--max-instances", + "1", + "--ccr-out", + reference_ccr.to_string_lossy().as_ref(), + ]) + .output() + .expect("run reference rpki"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + reference_ccr +} + +#[test] +fn cir_routinator_script_matches_reference_on_ta_only_cir() { + if !Path::new("/usr/bin/rsync").exists() + || !Path::new("/home/yuyr/dev/rust_playground/routinator/target/debug/routinator").exists() + { + return; + } + let td = tempfile::tempdir().expect("tempdir"); + let static_root = td.path().join("static"); + let cir_path = td.path().join("sample.cir"); + let mirror_root = td.path().join("mirror"); + let out_dir = td.path().join("routinator-out"); + + let (cir, ta_bytes) = build_ta_only_cir(); + std::fs::write(&cir_path, encode_cir(&cir).expect("encode cir")).expect("write cir"); + write_static(&static_root, "20260407", &ta_bytes); + materialize_cir(&cir, &static_root, &mirror_root, true).expect("materialize"); + let reference_ccr = prepare_reference_ccr(td.path(), &cir, &mirror_root); + + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/cir/run_cir_replay_routinator.sh"); + let out = Command::new(script) + .args([ + "--cir", + cir_path.to_string_lossy().as_ref(), + "--static-root", + static_root.to_string_lossy().as_ref(), + "--out-dir", + out_dir.to_string_lossy().as_ref(), + "--reference-ccr", + reference_ccr.to_string_lossy().as_ref(), + ]) + .output() + .expect("run routinator cir script"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + let summary: serde_json::Value = serde_json::from_slice( + &std::fs::read(out_dir.join("compare-summary.json")).expect("read summary"), + ) + .expect("parse summary"); + assert_eq!(summary["vrps"]["match"], true); + assert_eq!(summary["vaps"]["match"], true); +} + +#[test] +fn cir_rpki_client_script_matches_reference_on_ta_only_cir() { + if !Path::new("/usr/bin/rsync").exists() + || !Path::new("/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client").exists() + { + return; + } + let td = tempfile::tempdir().expect("tempdir"); + let static_root = td.path().join("static"); + let cir_path = td.path().join("sample.cir"); + let mirror_root = td.path().join("mirror"); + let out_dir = td.path().join("rpki-client-out"); + + let (cir, ta_bytes) = build_ta_only_cir(); + std::fs::write(&cir_path, encode_cir(&cir).expect("encode cir")).expect("write cir"); + write_static(&static_root, "20260407", &ta_bytes); + materialize_cir(&cir, &static_root, &mirror_root, true).expect("materialize"); + let reference_ccr = prepare_reference_ccr(td.path(), &cir, &mirror_root); + + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/cir/run_cir_replay_rpki_client.sh"); + let out = Command::new(script) + .args([ + "--cir", + cir_path.to_string_lossy().as_ref(), + "--static-root", + static_root.to_string_lossy().as_ref(), + "--out-dir", + out_dir.to_string_lossy().as_ref(), + "--reference-ccr", + reference_ccr.to_string_lossy().as_ref(), + "--build-dir", + "/home/yuyr/dev/rpki-client-9.7/build-m5", + ]) + .output() + .expect("run rpki-client cir script"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + let summary: serde_json::Value = serde_json::from_slice( + &std::fs::read(out_dir.join("compare-summary.json")).expect("read summary"), + ) + .expect("parse summary"); + assert_eq!(summary["vrps"]["match"], true); + assert_eq!(summary["vaps"]["match"], true); +} diff --git a/tests/test_cir_wrapper_m6.rs b/tests/test_cir_wrapper_m6.rs new file mode 100644 index 0000000..090b46e --- /dev/null +++ b/tests/test_cir_wrapper_m6.rs @@ -0,0 +1,190 @@ +use std::path::PathBuf; +use std::process::Command; +use std::os::unix::fs::MetadataExt; + +fn wrapper_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/cir/cir-rsync-wrapper") +} + +fn real_rsync() -> Option { + let candidate = "/usr/bin/rsync"; + if std::path::Path::new(candidate).exists() { + return Some(candidate.to_string()); + } + None +} + +#[test] +fn cir_rsync_wrapper_passes_through_help() { + let Some(real) = real_rsync() else { + return; + }; + let out = Command::new(wrapper_path()) + .env("REAL_RSYNC_BIN", real) + .arg("-h") + .output() + .expect("run wrapper -h"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stdout.contains("rsync") || stderr.contains("rsync")); +} + +#[test] +fn cir_rsync_wrapper_rewrites_rsync_source_to_mirror_tree() { + let Some(real) = real_rsync() else { + return; + }; + let td = tempfile::tempdir().expect("tempdir"); + let mirror_root = td.path().join("mirror"); + let dest_root = td.path().join("dest"); + let repo_root = mirror_root.join("example.net").join("repo"); + std::fs::create_dir_all(repo_root.join("nested")).expect("mkdirs"); + std::fs::write(repo_root.join("a.roa"), b"roa").expect("write roa"); + std::fs::write(repo_root.join("nested").join("b.txt"), b"txt").expect("write txt"); + std::fs::create_dir_all(&dest_root).expect("mkdir dest"); + + let out = Command::new(wrapper_path()) + .env("REAL_RSYNC_BIN", real) + .env("CIR_MIRROR_ROOT", &mirror_root) + .args([ + "-rt", + "--address", + "127.0.0.1", + "--contimeout=10", + "--include=*/", + "--include=*.roa", + "--exclude=*", + "rsync://example.net/repo/", + dest_root.to_string_lossy().as_ref(), + ]) + .output() + .expect("run wrapper rewrite"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(std::fs::read(dest_root.join("a.roa")).expect("read copied roa"), b"roa"); + assert!(!dest_root.join("nested").join("b.txt").exists()); +} + +#[test] +fn cir_rsync_wrapper_rewrites_module_root_without_trailing_slash_as_contents() { + let Some(real) = real_rsync() else { + return; + }; + let td = tempfile::tempdir().expect("tempdir"); + let mirror_root = td.path().join("mirror"); + let dest_root = td.path().join("dest"); + let repo_root = mirror_root.join("example.net").join("repo"); + std::fs::create_dir_all(repo_root.join("sub")).expect("mkdirs"); + std::fs::write(repo_root.join("root.cer"), b"cer").expect("write cer"); + std::fs::write(repo_root.join("sub").join("child.roa"), b"roa").expect("write roa"); + std::fs::create_dir_all(&dest_root).expect("mkdir dest"); + + let out = Command::new(wrapper_path()) + .env("REAL_RSYNC_BIN", real) + .env("CIR_MIRROR_ROOT", &mirror_root) + .args([ + "-rt", + "--include=*/", + "--include=*.cer", + "--include=*.roa", + "--exclude=*", + "rsync://example.net/repo", + dest_root.to_string_lossy().as_ref(), + ]) + .output() + .expect("run wrapper rewrite"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(std::fs::read(dest_root.join("root.cer")).expect("read copied root cer"), b"cer"); + assert_eq!( + std::fs::read(dest_root.join("sub").join("child.roa")).expect("read copied child roa"), + b"roa" + ); + assert!(!dest_root.join("repo").exists(), "module root must not be nested under destination"); +} + +#[test] +fn cir_rsync_wrapper_requires_mirror_root_for_rsync_source() { + let Some(real) = real_rsync() else { + return; + }; + let td = tempfile::tempdir().expect("tempdir"); + let dest_root = td.path().join("dest"); + std::fs::create_dir_all(&dest_root).expect("mkdir dest"); + + let out = Command::new(wrapper_path()) + .env("REAL_RSYNC_BIN", real) + .args(["-rt", "rsync://example.net/repo/", dest_root.to_string_lossy().as_ref()]) + .output() + .expect("run wrapper missing env"); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("CIR_MIRROR_ROOT")); +} + +#[test] +fn cir_rsync_wrapper_leaves_local_source_untouched() { + let Some(real) = real_rsync() else { + return; + }; + let td = tempfile::tempdir().expect("tempdir"); + let src_root = td.path().join("src"); + let dest_root = td.path().join("dest"); + std::fs::create_dir_all(&src_root).expect("mkdir src"); + std::fs::create_dir_all(&dest_root).expect("mkdir dest"); + std::fs::write(src_root.join("x.cer"), b"x").expect("write source"); + + let out = Command::new(wrapper_path()) + .env("REAL_RSYNC_BIN", real) + .args([ + "-rt", + src_root.to_string_lossy().as_ref(), + dest_root.to_string_lossy().as_ref(), + ]) + .output() + .expect("run wrapper local passthrough"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(std::fs::read(dest_root.join("src").join("x.cer")).expect("read copied file"), b"x"); +} + +#[test] +fn cir_rsync_wrapper_local_link_mode_uses_hardlinks_for_rewritten_sources() { + let Some(real) = real_rsync() else { + return; + }; + let td = tempfile::tempdir().expect("tempdir"); + let mirror_root = td.path().join("mirror"); + let dest_root = td.path().join("dest"); + let repo_root = mirror_root.join("example.net").join("repo"); + std::fs::create_dir_all(repo_root.join("nested")).expect("mkdirs"); + let src_file = repo_root.join("a.roa"); + let src_nested = repo_root.join("nested").join("b.cer"); + std::fs::write(&src_file, b"roa").expect("write roa"); + std::fs::write(&src_nested, b"cer").expect("write cer"); + std::fs::create_dir_all(&dest_root).expect("mkdir dest"); + + let out = Command::new(wrapper_path()) + .env("REAL_RSYNC_BIN", real) + .env("CIR_MIRROR_ROOT", &mirror_root) + .env("CIR_LOCAL_LINK_MODE", "1") + .args([ + "-rt", + "--delete", + "--include=*/", + "--include=*.roa", + "--include=*.cer", + "--exclude=*", + "rsync://example.net/repo/", + dest_root.to_string_lossy().as_ref(), + ]) + .output() + .expect("run wrapper local-link mode"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + + let dst_file = dest_root.join("a.roa"); + let dst_nested = dest_root.join("nested").join("b.cer"); + assert_eq!(std::fs::read(&dst_file).expect("read dest roa"), b"roa"); + assert_eq!(std::fs::read(&dst_nested).expect("read dest cer"), b"cer"); + + let src_meta = std::fs::metadata(&src_file).expect("src metadata"); + let dst_meta = std::fs::metadata(&dst_file).expect("dst metadata"); + assert_eq!(src_meta.ino(), dst_meta.ino(), "expected hardlinked destination file"); +} diff --git a/tests/test_cli_run_offline_m18.rs b/tests/test_cli_run_offline_m18.rs index 938d47d..5bc3993 100644 --- a/tests/test_cli_run_offline_m18.rs +++ b/tests/test_cli_run_offline_m18.rs @@ -1,3 +1,5 @@ +use std::process::Command; + #[test] fn cli_run_offline_mode_executes_and_writes_json_and_ccr() { let db_dir = tempfile::tempdir().expect("db tempdir"); @@ -80,3 +82,168 @@ fn cli_run_offline_mode_writes_decodable_ccr() { let ccr = rpki::ccr::decode_content_info(&bytes).expect("decode ccr"); assert!(ccr.content.tas.is_some()); } + +#[test] +fn cli_run_offline_mode_writes_cir_and_static_pool() { + let db_dir = tempfile::tempdir().expect("db tempdir"); + let repo_dir = tempfile::tempdir().expect("repo tempdir"); + let out_dir = tempfile::tempdir().expect("out tempdir"); + let cir_path = out_dir.path().join("result.cir"); + let static_root = out_dir.path().join("static"); + + let policy_path = out_dir.path().join("policy.toml"); + std::fs::write(&policy_path, "sync_preference = \"rsync_only\"\n").expect("write policy"); + + let tal_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/tal/apnic-rfc7730-https.tal"); + let ta_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ta/apnic-ta.cer"); + + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + db_dir.path().to_string_lossy().to_string(), + "--policy".to_string(), + policy_path.to_string_lossy().to_string(), + "--tal-path".to_string(), + tal_path.to_string_lossy().to_string(), + "--ta-path".to_string(), + ta_path.to_string_lossy().to_string(), + "--rsync-local-dir".to_string(), + repo_dir.path().to_string_lossy().to_string(), + "--max-depth".to_string(), + "0".to_string(), + "--max-instances".to_string(), + "1".to_string(), + "--cir-enable".to_string(), + "--cir-out".to_string(), + cir_path.to_string_lossy().to_string(), + "--cir-static-root".to_string(), + static_root.to_string_lossy().to_string(), + "--cir-tal-uri".to_string(), + "https://example.test/root.tal".to_string(), + ]; + + rpki::cli::run(&argv).expect("cli run"); + + let bytes = std::fs::read(&cir_path).expect("read cir"); + let cir = rpki::cir::decode_cir(&bytes).expect("decode cir"); + assert_eq!(cir.tals.len(), 1); + assert_eq!(cir.tals[0].tal_uri, "https://example.test/root.tal"); + assert!(cir + .objects + .iter() + .any(|item| item.rsync_uri.contains("apnic-rpki-root-iana-origin.cer"))); + + let mut file_count = 0usize; + let mut stack = vec![static_root.clone()]; + while let Some(path) = stack.pop() { + for entry in std::fs::read_dir(path).expect("read_dir") { + let entry = entry.expect("entry"); + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + file_count += 1; + } + } + } + assert!(file_count >= 1); +} + +#[test] +fn cli_run_blackbox_rsync_wrapper_mode_matches_reference_ccr_without_ta_path() { + let real_rsync = std::path::Path::new("/usr/bin/rsync"); + if !real_rsync.exists() { + return; + } + + let db_dir = tempfile::tempdir().expect("db tempdir"); + let out_dir = tempfile::tempdir().expect("out tempdir"); + let mirror_root = out_dir.path().join("mirror"); + let ref_ccr_path = out_dir.path().join("reference.ccr"); + let actual_ccr_path = out_dir.path().join("actual.ccr"); + + let tal_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/tal/apnic-rfc7730-https.tal"); + let ta_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ta/apnic-ta.cer"); + let ta_bytes = std::fs::read(&ta_path).expect("read ta"); + + std::fs::create_dir_all(mirror_root.join("rpki.apnic.net").join("repository")) + .expect("mkdir mirror"); + std::fs::write( + mirror_root + .join("rpki.apnic.net") + .join("repository") + .join("apnic-rpki-root-iana-origin.cer"), + ta_bytes, + ) + .expect("write ta into mirror"); + + let bin = env!("CARGO_BIN_EXE_rpki"); + let wrapper = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("scripts/cir/cir-rsync-wrapper"); + let reference = Command::new(bin) + .env("REAL_RSYNC_BIN", real_rsync) + .env("CIR_MIRROR_ROOT", &mirror_root) + .args([ + "--db", + db_dir.path().join("reference-db").to_string_lossy().as_ref(), + "--tal-path", + tal_path.to_string_lossy().as_ref(), + "--ta-path", + ta_path.to_string_lossy().as_ref(), + "--disable-rrdp", + "--rsync-command", + wrapper.to_string_lossy().as_ref(), + "--validation-time", + "2026-04-07T00:00:00Z", + "--max-depth", + "0", + "--max-instances", + "1", + "--ccr-out", + ref_ccr_path.to_string_lossy().as_ref(), + ]) + .output() + .expect("run reference wrapper mode"); + assert!(reference.status.success(), "stderr={}", String::from_utf8_lossy(&reference.stderr)); + + let out = Command::new(bin) + .env("REAL_RSYNC_BIN", real_rsync) + .env("CIR_MIRROR_ROOT", &mirror_root) + .args([ + "--db", + db_dir.path().join("actual-db").to_string_lossy().as_ref(), + "--tal-path", + tal_path.to_string_lossy().as_ref(), + "--disable-rrdp", + "--rsync-command", + wrapper.to_string_lossy().as_ref(), + "--validation-time", + "2026-04-07T00:00:00Z", + "--max-depth", + "0", + "--max-instances", + "1", + "--ccr-out", + actual_ccr_path.to_string_lossy().as_ref(), + ]) + .output() + .expect("run blackbox wrapper mode"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + + let reference = rpki::ccr::decode_content_info(&std::fs::read(&ref_ccr_path).unwrap()) + .expect("decode reference ccr"); + let actual = rpki::ccr::decode_content_info(&std::fs::read(&actual_ccr_path).unwrap()) + .expect("decode actual ccr"); + + assert_eq!(actual.content.version, reference.content.version); + assert_eq!(actual.content.hash_alg, reference.content.hash_alg); + assert_eq!(actual.content.mfts, reference.content.mfts); + assert_eq!(actual.content.vrps, reference.content.vrps); + assert_eq!(actual.content.vaps, reference.content.vaps); + assert_eq!(actual.content.tas, reference.content.tas); + assert_eq!(actual.content.rks, reference.content.rks); +}