From 44f1b5bc99ecea45e4240a5bb5d2ef98c0e38795 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 5 Jul 2026 13:20:45 -0400 Subject: [PATCH 1/3] feat: rveng M0-M2 reverse-engineering learning platform engine and API Framework-free analysis engine (hex dump, hand-rolled ELF64 parser, capstone x86-64 disassembly, string extraction, byte patching, challenge grading) behind a thin read-only FastAPI layer over curated pre-compiled challenge binaries. No-execution security posture: the backend only reads and decodes bytes, never runs a target; patch challenges are graded by static byte diff. Reveal-after-solve pedagogy: challenge source is returned only on a correct submission. Input size-caps enforced at the API boundary. M0 research (ELF format, x86-64/capstone, pedagogy, no-execution) is cross-checked against a real compiled binary. 63 tests green, including an ELF parser cross-check against pyelftools and disassembly that matches objdump instruction-for-instruction. --- PROJECTS/advanced/rveng/.gitignore | 10 + PROJECTS/advanced/rveng/.python-version | 1 + PROJECTS/advanced/rveng/README.md | 0 .../03-flip-the-gate/challenge.json | 7 + .../challenges/03-flip-the-gate/source.c | 23 ++ .../rveng/challenges/03-flip-the-gate/target | Bin 0 -> 15904 bytes .../04-name-the-function/challenge.json | 7 + .../challenges/04-name-the-function/source.c | 23 ++ .../challenges/04-name-the-function/target | Bin 0 -> 15904 bytes .../05-find-the-gate/challenge.json | 7 + .../challenges/05-find-the-gate/source.c | 23 ++ .../rveng/challenges/05-find-the-gate/target | Bin 0 -> 15904 bytes .../rveng/docs/research/01-elf-format.md | 140 +++++++ .../rveng/docs/research/02-x86-64-capstone.md | 127 +++++++ .../rveng/docs/research/03-pedagogy.md | 85 +++++ .../docs/research/04-no-execution-posture.md | 87 +++++ .../advanced/rveng/docs/research/INDEX.md | 38 ++ PROJECTS/advanced/rveng/pyproject.toml | 25 ++ PROJECTS/advanced/rveng/src/rveng/__init__.py | 2 + .../advanced/rveng/src/rveng/api/__init__.py | 4 + PROJECTS/advanced/rveng/src/rveng/api/app.py | 164 ++++++++ .../advanced/rveng/src/rveng/api/limits.py | 9 + .../advanced/rveng/src/rveng/api/schemas.py | 98 +++++ .../advanced/rveng/src/rveng/api/server.py | 8 + .../advanced/rveng/src/rveng/api/store.py | 118 ++++++ .../rveng/src/rveng/engine/__init__.py | 4 + .../rveng/src/rveng/engine/challenge.py | 163 ++++++++ .../advanced/rveng/src/rveng/engine/disasm.py | 115 ++++++ .../advanced/rveng/src/rveng/engine/elf.py | 301 +++++++++++++++ .../advanced/rveng/src/rveng/engine/hex.py | 63 ++++ .../advanced/rveng/src/rveng/engine/patch.py | 61 +++ .../rveng/src/rveng/engine/strings.py | 75 ++++ PROJECTS/advanced/rveng/tests/conftest.py | 20 + PROJECTS/advanced/rveng/tests/fixtures/gate | Bin 0 -> 15904 bytes PROJECTS/advanced/rveng/tests/fixtures/gate.c | 23 ++ PROJECTS/advanced/rveng/tests/test_api.py | 132 +++++++ .../advanced/rveng/tests/test_challenge.py | 69 ++++ PROJECTS/advanced/rveng/tests/test_disasm.py | 54 +++ PROJECTS/advanced/rveng/tests/test_elf.py | 79 ++++ PROJECTS/advanced/rveng/tests/test_hex.py | 40 ++ PROJECTS/advanced/rveng/tests/test_patch.py | 55 +++ .../advanced/rveng/tests/test_robustness.py | 94 +++++ PROJECTS/advanced/rveng/tests/test_strings.py | 34 ++ PROJECTS/advanced/rveng/uv.lock | 352 ++++++++++++++++++ 44 files changed, 2740 insertions(+) create mode 100644 PROJECTS/advanced/rveng/.gitignore create mode 100644 PROJECTS/advanced/rveng/.python-version create mode 100644 PROJECTS/advanced/rveng/README.md create mode 100644 PROJECTS/advanced/rveng/challenges/03-flip-the-gate/challenge.json create mode 100644 PROJECTS/advanced/rveng/challenges/03-flip-the-gate/source.c create mode 100755 PROJECTS/advanced/rveng/challenges/03-flip-the-gate/target create mode 100644 PROJECTS/advanced/rveng/challenges/04-name-the-function/challenge.json create mode 100644 PROJECTS/advanced/rveng/challenges/04-name-the-function/source.c create mode 100755 PROJECTS/advanced/rveng/challenges/04-name-the-function/target create mode 100644 PROJECTS/advanced/rveng/challenges/05-find-the-gate/challenge.json create mode 100644 PROJECTS/advanced/rveng/challenges/05-find-the-gate/source.c create mode 100755 PROJECTS/advanced/rveng/challenges/05-find-the-gate/target create mode 100644 PROJECTS/advanced/rveng/docs/research/01-elf-format.md create mode 100644 PROJECTS/advanced/rveng/docs/research/02-x86-64-capstone.md create mode 100644 PROJECTS/advanced/rveng/docs/research/03-pedagogy.md create mode 100644 PROJECTS/advanced/rveng/docs/research/04-no-execution-posture.md create mode 100644 PROJECTS/advanced/rveng/docs/research/INDEX.md create mode 100644 PROJECTS/advanced/rveng/pyproject.toml create mode 100644 PROJECTS/advanced/rveng/src/rveng/__init__.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/api/__init__.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/api/app.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/api/limits.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/api/schemas.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/api/server.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/api/store.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/__init__.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/challenge.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/disasm.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/elf.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/hex.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/patch.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/strings.py create mode 100644 PROJECTS/advanced/rveng/tests/conftest.py create mode 100755 PROJECTS/advanced/rveng/tests/fixtures/gate create mode 100644 PROJECTS/advanced/rveng/tests/fixtures/gate.c create mode 100644 PROJECTS/advanced/rveng/tests/test_api.py create mode 100644 PROJECTS/advanced/rveng/tests/test_challenge.py create mode 100644 PROJECTS/advanced/rveng/tests/test_disasm.py create mode 100644 PROJECTS/advanced/rveng/tests/test_elf.py create mode 100644 PROJECTS/advanced/rveng/tests/test_hex.py create mode 100644 PROJECTS/advanced/rveng/tests/test_patch.py create mode 100644 PROJECTS/advanced/rveng/tests/test_robustness.py create mode 100644 PROJECTS/advanced/rveng/tests/test_strings.py create mode 100644 PROJECTS/advanced/rveng/uv.lock diff --git a/PROJECTS/advanced/rveng/.gitignore b/PROJECTS/advanced/rveng/.gitignore new file mode 100644 index 00000000..173d0337 --- /dev/null +++ b/PROJECTS/advanced/rveng/.gitignore @@ -0,0 +1,10 @@ +# ©AngelaMos | 2026 +# .gitignore + +docs/plans/ +docs/context/ + +__pycache__/ +.pytest_cache/ +.venv/ +*.pyc diff --git a/PROJECTS/advanced/rveng/.python-version b/PROJECTS/advanced/rveng/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/PROJECTS/advanced/rveng/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/PROJECTS/advanced/rveng/README.md b/PROJECTS/advanced/rveng/README.md new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/advanced/rveng/challenges/03-flip-the-gate/challenge.json b/PROJECTS/advanced/rveng/challenges/03-flip-the-gate/challenge.json new file mode 100644 index 00000000..2a80da63 --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/03-flip-the-gate/challenge.json @@ -0,0 +1,7 @@ +{ + "id": "03-flip-the-gate", + "module": "patching", + "title": "Flip the gate", + "mission": "The check at file offset 0x1154 is a conditional jump (jne, bytes 75 07) that skips the unlock path. Patch those two bytes so the branch is never taken and the unlock path always runs. Submit the two replacement bytes as hex.", + "answer": { "category": "patched_bytes", "offset": 4436, "patch": "9090" } +} diff --git a/PROJECTS/advanced/rveng/challenges/03-flip-the-gate/source.c b/PROJECTS/advanced/rveng/challenges/03-flip-the-gate/source.c new file mode 100644 index 00000000..1c42d31b --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/03-flip-the-gate/source.c @@ -0,0 +1,23 @@ +#include +#include + +int check(int n) { + if (n == 1337) { + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + char *secret = "the_flag_is_here"; + int n = 0; + if (argc > 1) { + n = atoi(argv[1]); + } + if (check(n)) { + printf("unlocked: %s\n", secret); + } else { + printf("wrong number\n"); + } + return 0; +} diff --git a/PROJECTS/advanced/rveng/challenges/03-flip-the-gate/target b/PROJECTS/advanced/rveng/challenges/03-flip-the-gate/target new file mode 100755 index 0000000000000000000000000000000000000000..bc1436b17427672fa70b3c2a0d73bb41cf236307 GIT binary patch literal 15904 zcmeHOeQXrh5r22a*deifhC&=Dupp4!Dn4v576Ae4k2CAafD0xes?zoPZtYv;zI3;j z*l9^f-47iom7_+Ls(z(^^y8nZsG<^WrPhflR-#IER8{4VO0g;;nF6V!rWHvot~2k= zoYxy$P5Va`sXNl{yqPz@dGqG&?!CQz@3~liZ#WbZOkr_EpfnWGrAnQJc)3H0q&pz$ zg)X*FU1^VTr{8iDSpHAr93WgPIc}x=GjFH%m~(rd1{~(|!xCQ}r>MQv#;=yf zjd_R~z;@9GUTeoroDt#kVhVNM`j3H(>BjoM{OD)z9sKtDZ9o6ct92*e3^iq5{M}FX zq@3Z7_C2XYOUlU<$6Ce?bhLD|M+@2LUg-yYu|Ft&D7qi4)~{fRepb?NT}2-Qy#|M$ zFvlzT5xJsU5SE*DglR&*;%33M@~)Y-oQ%ljos2soP>sFo5Xbuay1LE1(Y?|AmGZuJ zK#r!f8A6)SQW)}A1v$VYEOvp0$E~>8xev38w-4$rzxZ_sEi4W~8R_~_U2=@qo5MMe zp06Egs4M##5NODyy4ItoTSw|69z8vZq&_$W83-~EWFW{ukbxirK?Z^h1R3}!WWboX zU29yd{=*(2j4w^O;qt68akX~35?Xox>rhqR{aZLUY}cWLauVxr&Xs|7zlt(WrEktk z`4=eTRQcu<$ZdD}N^gADDE--(xUn!W7;Bqqd(D`cNi>41jgNupyCczt?O#NH(*Lwq z>=EKhGfb$B!+3T#TtO=g^S)oBqC7V;IX&V9=;Bw>{`t2hD=iqWynWPosxQ<-KT5HMnb;zBY2w`N(6gJ2i?KOl zVhV->>SpGDdaqnA#TGBd7D4zX1Offt3N{Ny=^D5=D_zXUCgz_4+glBw9Z=f&GoX-F zufb5tF!8{7E};P}^NtC!Wq{GoxCjm>#zCBDAII5PoCDZ(9q0Ete;Ow=R^+1HgxX ze;)Xzcht*gJMiluBjGnsq|h^`L})A$+VVh6?PRDXg5T)y8~=6iwG$11R<>2&^U(HB zV9Vd`@Y`Q29A`nN0~hs?-uk9bHQfJn?Rjx@^Px}f+a=v1?kpUSLX6wcuCqS!<#2c1 z+Q%Uf5V3v?jzOq@7I>vTJgy8th2I4+UThn`-6G+)zpyL?ryv7C27(L(83-~EWFW{u zkbxirK?Z^hd^9tl!v|QT2yyTG6zU(q6B4bHu2Yh@LG}-k%!~1hXT-`VglE*A~h){HWFvXYcl6)T_?Yz z8Dzlkn7FPJ=|RPWR;|kt>lY{cq>w__mXN)~DY~dcM9vl!W6S5)TPnAArQ;gV$9E|9PP6 zRaV03b8uk0e7~^%D^LiD4T7(K*8czsEA@A^e*IYbX%x*nbaqkUEBX1QwBIQF<9Su0 zB7)b$vy(qc`v(Qz2duvX1&qm0han*v+*M7KAFf{q`jzarfIb5DoKLpjiT2_8%JYKt z&8zr1M1K738lcx0?BS1rzA@Yc+c9*HR)_@ZMFW789{;mJDu6x$88ziDx0wjV4298 zqp9q$l`<1oyYsSK@I<=nK6TEjh<7&Jl21*tFzxc z+1qq;om`^^M(P~B!C+YDl%>$FztlpT0#a+TMd@in^>=Q6@cqyTMQsc zg{+yhGKrKe%)XOgm2fgzN zW(qr>6Vfng6_O&F7|(!H#ofHBdCJZgoNQ*9U_u@A#zFv zl<_>DTkzqzeSiN;gzqAUJU`S3V2~a~oJ0EYUjrTH1mly$&+mv5dEQC2GFC1l|0*0_ zyheD9@H!fKh}7~s1**RRhZoPkXG3K81>b-%o?U*|hv)hKjYeg`^I*28w6Z9^Pk7Eh z|1JwnnY30cRl$!}^P{El=!B^&|_+SEJJ0&g2tiJ^nc +#include + +int check(int n) { + if (n == 1337) { + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + char *secret = "the_flag_is_here"; + int n = 0; + if (argc > 1) { + n = atoi(argv[1]); + } + if (check(n)) { + printf("unlocked: %s\n", secret); + } else { + printf("wrong number\n"); + } + return 0; +} diff --git a/PROJECTS/advanced/rveng/challenges/04-name-the-function/target b/PROJECTS/advanced/rveng/challenges/04-name-the-function/target new file mode 100755 index 0000000000000000000000000000000000000000..bc1436b17427672fa70b3c2a0d73bb41cf236307 GIT binary patch literal 15904 zcmeHOeQXrh5r22a*deifhC&=Dupp4!Dn4v576Ae4k2CAafD0xes?zoPZtYv;zI3;j z*l9^f-47iom7_+Ls(z(^^y8nZsG<^WrPhflR-#IER8{4VO0g;;nF6V!rWHvot~2k= zoYxy$P5Va`sXNl{yqPz@dGqG&?!CQz@3~liZ#WbZOkr_EpfnWGrAnQJc)3H0q&pz$ zg)X*FU1^VTr{8iDSpHAr93WgPIc}x=GjFH%m~(rd1{~(|!xCQ}r>MQv#;=yf zjd_R~z;@9GUTeoroDt#kVhVNM`j3H(>BjoM{OD)z9sKtDZ9o6ct92*e3^iq5{M}FX zq@3Z7_C2XYOUlU<$6Ce?bhLD|M+@2LUg-yYu|Ft&D7qi4)~{fRepb?NT}2-Qy#|M$ zFvlzT5xJsU5SE*DglR&*;%33M@~)Y-oQ%ljos2soP>sFo5Xbuay1LE1(Y?|AmGZuJ zK#r!f8A6)SQW)}A1v$VYEOvp0$E~>8xev38w-4$rzxZ_sEi4W~8R_~_U2=@qo5MMe zp06Egs4M##5NODyy4ItoTSw|69z8vZq&_$W83-~EWFW{ukbxirK?Z^h1R3}!WWboX zU29yd{=*(2j4w^O;qt68akX~35?Xox>rhqR{aZLUY}cWLauVxr&Xs|7zlt(WrEktk z`4=eTRQcu<$ZdD}N^gADDE--(xUn!W7;Bqqd(D`cNi>41jgNupyCczt?O#NH(*Lwq z>=EKhGfb$B!+3T#TtO=g^S)oBqC7V;IX&V9=;Bw>{`t2hD=iqWynWPosxQ<-KT5HMnb;zBY2w`N(6gJ2i?KOl zVhV->>SpGDdaqnA#TGBd7D4zX1Offt3N{Ny=^D5=D_zXUCgz_4+glBw9Z=f&GoX-F zufb5tF!8{7E};P}^NtC!Wq{GoxCjm>#zCBDAII5PoCDZ(9q0Ete;Ow=R^+1HgxX ze;)Xzcht*gJMiluBjGnsq|h^`L})A$+VVh6?PRDXg5T)y8~=6iwG$11R<>2&^U(HB zV9Vd`@Y`Q29A`nN0~hs?-uk9bHQfJn?Rjx@^Px}f+a=v1?kpUSLX6wcuCqS!<#2c1 z+Q%Uf5V3v?jzOq@7I>vTJgy8th2I4+UThn`-6G+)zpyL?ryv7C27(L(83-~EWFW{u zkbxirK?Z^hd^9tl!v|QT2yyTG6zU(q6B4bHu2Yh@LG}-k%!~1hXT-`VglE*A~h){HWFvXYcl6)T_?Yz z8Dzlkn7FPJ=|RPWR;|kt>lY{cq>w__mXN)~DY~dcM9vl!W6S5)TPnAArQ;gV$9E|9PP6 zRaV03b8uk0e7~^%D^LiD4T7(K*8czsEA@A^e*IYbX%x*nbaqkUEBX1QwBIQF<9Su0 zB7)b$vy(qc`v(Qz2duvX1&qm0han*v+*M7KAFf{q`jzarfIb5DoKLpjiT2_8%JYKt z&8zr1M1K738lcx0?BS1rzA@Yc+c9*HR)_@ZMFW789{;mJDu6x$88ziDx0wjV4298 zqp9q$l`<1oyYsSK@I<=nK6TEjh<7&Jl21*tFzxc z+1qq;om`^^M(P~B!C+YDl%>$FztlpT0#a+TMd@in^>=Q6@cqyTMQsc zg{+yhGKrKe%)XOgm2fgzN zW(qr>6Vfng6_O&F7|(!H#ofHBdCJZgoNQ*9U_u@A#zFv zl<_>DTkzqzeSiN;gzqAUJU`S3V2~a~oJ0EYUjrTH1mly$&+mv5dEQC2GFC1l|0*0_ zyheD9@H!fKh}7~s1**RRhZoPkXG3K81>b-%o?U*|hv)hKjYeg`^I*28w6Z9^Pk7Eh z|1JwnnY30cRl$!}^P{El=!B^&|_+SEJJ0&g2tiJ^nc +#include + +int check(int n) { + if (n == 1337) { + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + char *secret = "the_flag_is_here"; + int n = 0; + if (argc > 1) { + n = atoi(argv[1]); + } + if (check(n)) { + printf("unlocked: %s\n", secret); + } else { + printf("wrong number\n"); + } + return 0; +} diff --git a/PROJECTS/advanced/rveng/challenges/05-find-the-gate/target b/PROJECTS/advanced/rveng/challenges/05-find-the-gate/target new file mode 100755 index 0000000000000000000000000000000000000000..bc1436b17427672fa70b3c2a0d73bb41cf236307 GIT binary patch literal 15904 zcmeHOeQXrh5r22a*deifhC&=Dupp4!Dn4v576Ae4k2CAafD0xes?zoPZtYv;zI3;j z*l9^f-47iom7_+Ls(z(^^y8nZsG<^WrPhflR-#IER8{4VO0g;;nF6V!rWHvot~2k= zoYxy$P5Va`sXNl{yqPz@dGqG&?!CQz@3~liZ#WbZOkr_EpfnWGrAnQJc)3H0q&pz$ zg)X*FU1^VTr{8iDSpHAr93WgPIc}x=GjFH%m~(rd1{~(|!xCQ}r>MQv#;=yf zjd_R~z;@9GUTeoroDt#kVhVNM`j3H(>BjoM{OD)z9sKtDZ9o6ct92*e3^iq5{M}FX zq@3Z7_C2XYOUlU<$6Ce?bhLD|M+@2LUg-yYu|Ft&D7qi4)~{fRepb?NT}2-Qy#|M$ zFvlzT5xJsU5SE*DglR&*;%33M@~)Y-oQ%ljos2soP>sFo5Xbuay1LE1(Y?|AmGZuJ zK#r!f8A6)SQW)}A1v$VYEOvp0$E~>8xev38w-4$rzxZ_sEi4W~8R_~_U2=@qo5MMe zp06Egs4M##5NODyy4ItoTSw|69z8vZq&_$W83-~EWFW{ukbxirK?Z^h1R3}!WWboX zU29yd{=*(2j4w^O;qt68akX~35?Xox>rhqR{aZLUY}cWLauVxr&Xs|7zlt(WrEktk z`4=eTRQcu<$ZdD}N^gADDE--(xUn!W7;Bqqd(D`cNi>41jgNupyCczt?O#NH(*Lwq z>=EKhGfb$B!+3T#TtO=g^S)oBqC7V;IX&V9=;Bw>{`t2hD=iqWynWPosxQ<-KT5HMnb;zBY2w`N(6gJ2i?KOl zVhV->>SpGDdaqnA#TGBd7D4zX1Offt3N{Ny=^D5=D_zXUCgz_4+glBw9Z=f&GoX-F zufb5tF!8{7E};P}^NtC!Wq{GoxCjm>#zCBDAII5PoCDZ(9q0Ete;Ow=R^+1HgxX ze;)Xzcht*gJMiluBjGnsq|h^`L})A$+VVh6?PRDXg5T)y8~=6iwG$11R<>2&^U(HB zV9Vd`@Y`Q29A`nN0~hs?-uk9bHQfJn?Rjx@^Px}f+a=v1?kpUSLX6wcuCqS!<#2c1 z+Q%Uf5V3v?jzOq@7I>vTJgy8th2I4+UThn`-6G+)zpyL?ryv7C27(L(83-~EWFW{u zkbxirK?Z^hd^9tl!v|QT2yyTG6zU(q6B4bHu2Yh@LG}-k%!~1hXT-`VglE*A~h){HWFvXYcl6)T_?Yz z8Dzlkn7FPJ=|RPWR;|kt>lY{cq>w__mXN)~DY~dcM9vl!W6S5)TPnAArQ;gV$9E|9PP6 zRaV03b8uk0e7~^%D^LiD4T7(K*8czsEA@A^e*IYbX%x*nbaqkUEBX1QwBIQF<9Su0 zB7)b$vy(qc`v(Qz2duvX1&qm0han*v+*M7KAFf{q`jzarfIb5DoKLpjiT2_8%JYKt z&8zr1M1K738lcx0?BS1rzA@Yc+c9*HR)_@ZMFW789{;mJDu6x$88ziDx0wjV4298 zqp9q$l`<1oyYsSK@I<=nK6TEjh<7&Jl21*tFzxc z+1qq;om`^^M(P~B!C+YDl%>$FztlpT0#a+TMd@in^>=Q6@cqyTMQsc zg{+yhGKrKe%)XOgm2fgzN zW(qr>6Vfng6_O&F7|(!H#ofHBdCJZgoNQ*9U_u@A#zFv zl<_>DTkzqzeSiN;gzqAUJU`S3V2~a~oJ0EYUjrTH1mly$&+mv5dEQC2GFC1l|0*0_ zyheD9@H!fKh}7~s1**RRhZoPkXG3K81>b-%o?U*|hv)hKjYeg`^I*28w6Z9^Pk7Eh z|1JwnnY30cRl$!}^P{El=!B^&|_+SEJJ0&g2tiJ^nc + + +# ELF Format Research + +Primary source: the System V Application Binary Interface and the ELF-64 +Object File Format specification. Every field size and offset below is +cross-checked against a real binary compiled locally. + +## Ground-truth sample + +``` +gate.c compiled with: gcc -no-pie -fno-stack-protector -O0 -o gate gate.c +file: ELF 64-bit LSB executable, x86-64, EXEC, dynamically linked, not stripped +``` + +Chosen `-no-pie` so the load addresses are fixed and readable (EXEC type, not a +PIE/DYN shared object with load-time relocation). This is the shape the curated +challenge binaries use so a learner sees stable addresses. + +## ELF header (Elf64_Ehdr) + +`readelf -h gate` reported, and the first 64 raw bytes confirm, this layout. +Offsets are into the file from byte 0. + +| Off | Size | Field | Value in sample | Raw bytes | +|-----|------|-------|-----------------|-----------| +| 0x00 | 16 | e_ident | magic + class + data + version | `7f 45 4c 46 02 01 01 00` then padding | +| 0x10 | 2 | e_type | EXEC (2) | `02 00` | +| 0x12 | 2 | e_machine | x86-64 (62) | `3e 00` | +| 0x14 | 4 | e_version | 1 | `01 00 00 00` | +| 0x18 | 8 | e_entry | 0x401060 | `60 10 40 00 00 00 00 00` | +| 0x20 | 8 | e_phoff | 64 | `40 00 00 00 00 00 00 00` | +| 0x28 | 8 | e_shoff | 13984 (0x36a0) | `a0 36 00 00 00 00 00 00` | +| 0x30 | 4 | e_flags | 0 | `00 00 00 00` | +| 0x34 | 2 | e_ehsize | 64 | `40 00` | +| 0x36 | 2 | e_phentsize | 56 | `38 00` | +| 0x38 | 2 | e_phnum | 14 | `0e 00` | +| 0x3a | 2 | e_shentsize | 64 | `40 00` | +| 0x3c | 2 | e_shnum | 30 | `1e 00` | +| 0x3e | 2 | e_shstrndx | 29 | `1d 00` | + +e_ident breakdown (the first 16 bytes): +- bytes 0-3: magic `7f 45 4c 46` = `\x7f E L F`. Every ELF starts with these. +- byte 4 (EI_CLASS): `02` = ELFCLASS64 (64-bit). `01` would be 32-bit. +- byte 5 (EI_DATA): `01` = little-endian (ELFDATA2LSB). +- byte 6 (EI_VERSION): `01`. +- bytes 7-15: OS/ABI + padding, zero here. + +The raw hex cross-check is exact: the entry point bytes at file offset 0x18 are +`60 10 40 00` little-endian = 0x401060, matching both `readelf -h` and the entry +point of `.text`. The section header table offset at 0x28 is `a0 36 00 00` = +0x36a0 = 13984, matching e_shoff. + +## Section header table (Elf64_Shdr) + +Located at e_shoff (0x36a0), e_shnum (30) entries of e_shentsize (64) bytes +each. Section names are indices into the section named by e_shstrndx (29, +`.shstrtab`). Sections relevant to the learning modules, from `readelf -S`: + +| Nr | Name | Type | Address | Offset | Size | Flags | +|----|------|------|---------|--------|------|-------| +| 11 | .init | PROGBITS | 0x401000 | 0x1000 | 0x17 | AX | +| 12 | .plt | PROGBITS | 0x401020 | 0x1020 | 0x40 | AX | +| 13 | .text | PROGBITS | 0x401060 | 0x1060 | 0x182 | AX | +| 15 | .rodata | PROGBITS | 0x402000 | 0x2000 | 0x30 | A | +| 24 | .data | PROGBITS | 0x404018 | 0x3018 | 0x10 | WA | +| 25 | .bss | NOBITS | 0x404028 | 0x3028 | 0x8 | WA | +| 27 | .symtab | SYMTAB | 0 | 0x3048 | 0x378 | | +| 28 | .strtab | STRTAB | 0 | 0x33c0 | 0x1ca | | + +Key facts the engine must model: +- Flags: A = allocated into memory, X = executable, W = writable. `.text` is + AX (code), `.rodata` is A (read-only data), `.data` is WA, `.bss` is WA. +- `.bss` type is NOBITS: it occupies memory at runtime but has zero size in the + file (Size 0x8 but no file bytes). The engine must not read file bytes for a + NOBITS section. +- A section with Address 0 is not loaded into the process image (`.symtab`, + `.strtab`, `.shstrtab`). These exist only in the file. +- The entry point 0x401060 equals `.text` Address, and the symbol table shows + `_start` at 0x401060. Entry is the first instruction the loader jumps to. + +## Program header table (Elf64_Phdr) + +Located at e_phoff (64), e_phnum (14) entries of e_phentsize (56) bytes. These +describe segments: how the file maps into memory at load time. From +`readelf -l`, the LOAD segments and their flags: + +| Offset | VirtAddr | FileSiz | Flags | Holds | +|--------|----------|---------|-------|-------| +| 0x0000 | 0x400000 | 0x568 | R | ELF header + read-only metadata | +| 0x1000 | 0x401000 | 0x1ed | R E | .init .plt .text .fini (code) | +| 0x2000 | 0x402000 | 0x14c | R | .rodata + eh_frame | +| 0x2df8 | 0x403df8 | 0x230 | RW | .data .bss and relro | + +Sections are the linker/analysis view. Segments are the loader view. The engine +parses sections for the learning modules (naming, layout) and can show segments +for the elf-anatomy module to explain how file bytes become a running process. + +## Symbol table (Elf64_Sym) + +`.symtab` at file offset 0x3048, entry size 24 (0x18). Names index into +`.strtab`. Sample function symbols from `readelf -s`: + +| Value | Size | Type | Bind | Name | +|-------|------|------|------|------| +| 0x401060 | 34 | FUNC | GLOBAL | _start | +| 0x401146 | 30 | FUNC | GLOBAL | check | +| 0x401164 | 126 | FUNC | GLOBAL | main | + +Each symbol carries value (address), size, a type (FUNC/OBJECT/etc.), a binding +(LOCAL/GLOBAL/WEAK), and a section index. `check` at 0x401146 with size 30 is +the target of the strings-symbols and disassembly modules. A stripped binary +has no `.symtab`, which is why later challenges can strip symbols to force +learners into raw disassembly. + +## What we hand-roll vs delegate + +Hand-roll (this is the ELF-format learning surface): +- e_ident + Elf64_Ehdr field parse from raw bytes (the table above). +- Section header table walk: read e_shoff/e_shnum/e_shentsize, iterate entries, + resolve names via e_shstrndx. + +Delegate to pyelftools where hand-rolling adds no pedagogical value: +- Program header details, relocation tables, dynamic section, DWARF. + +pyelftools 0.33 is the installed reference and is used to cross-check the +hand-rolled parser in the M1 known-answer tests: the hand-rolled header and +section fields must equal what pyelftools reports for the same binary. + +## Facts an adversarial check confirmed or corrected + +- Confirmed: entry point, e_shoff, e_shnum, and all e_ident bytes match the raw + file bytes exactly (not taken from memory or a generic ELF diagram). +- Confirmed: `.bss` is NOBITS with a nonzero memory size and no file content; + the parser must special-case it. +- Corrected assumption: an EXEC (non-PIE) binary has fixed addresses; a default + `gcc` build is PIE (ET_DYN) with load-relative addresses. The challenge + binaries are compiled `-no-pie` on purpose so addresses in `readelf`, + `objdump`, and the engine all agree with what a learner sees. diff --git a/PROJECTS/advanced/rveng/docs/research/02-x86-64-capstone.md b/PROJECTS/advanced/rveng/docs/research/02-x86-64-capstone.md new file mode 100644 index 00000000..bae52e50 --- /dev/null +++ b/PROJECTS/advanced/rveng/docs/research/02-x86-64-capstone.md @@ -0,0 +1,127 @@ + + + +# x86-64 Disassembly and Capstone Research + +Primary sources: the Intel 64 and IA-32 Architectures Software Developer +Manuals for instruction semantics, and the Capstone engine documentation for +API usage. Cross-checked against `objdump -d -M intel` on the local sample. + +## Minimum x86-64 mental model for these challenges + +A learner solving the five modules needs to recognize a small instruction set, +not the whole ISA. The instructions that actually appear in the challenge +binaries: + +- `mov dst, src` : copy a value. `mov dword ptr [rbp-4], edi` stores the first + argument (edi) into a local stack slot. +- `cmp a, b` : compute `a - b` and set flags, discarding the result. This is + how a program tests a value. The compared constant is the thing a learner + hunts for. +- `test a, b` : bitwise AND setting flags, commonly `test eax, eax` to check + for zero. +- the jcc family (`je`, `jne`, `jg`, `jle`, ...) : conditional jump based on the + flags a preceding `cmp`/`test` set. `jne` after `cmp x, 0x539` means "branch + if x is not 1337". +- `jmp` : unconditional jump. +- `call` / `ret` : function call and return. +- `lea` : load effective address, often used to point at a string in `.rodata`. +- `push` / `pop` : stack, seen in function prologue/epilogue with `rbp`. + +Registers that show up: `rax`/`eax` (return value and scratch), `rbp`/`rsp` +(frame and stack pointers), `edi`/`esi`/`edx` (first three integer arguments in +the System V AMD64 calling convention). + +Intel syntax is used throughout (`mov dst, src`, destination first) rather than +AT&T (`mov src, dst`, `%` and `$` sigils) because it is easier for a beginner +and matches `objdump -d -M intel`. + +## The gate pattern + +The whole disassembly module rests on one recognizable shape: a `cmp` against a +constant followed by a conditional jump. In the sample, `check(int n)` compiles +to exactly this. From `objdump -d -M intel gate`: + +``` +401146 : + 40114d: 81 7d fc 39 05 00 00 cmp DWORD PTR [rbp-0x4],0x539 + 401154: 75 07 jne 40115d + 401156: b8 01 00 00 00 mov eax,0x1 + 40115b: eb 05 jmp 401162 + 40115d: b8 00 00 00 00 mov eax,0x0 +``` + +`0x539` = 1337. The C source compared `n == 1337`. A learner reads the `cmp`, +converts the hex, and has the magic number. The engine's job is to surface this +`cmp` clearly and annotate that the following `jne` is the gate. + +## Capstone API + +Installed reference: capstone 5.0.9 (Python binding). + +Construction and iteration: + +```python +from capstone import Cs, CS_ARCH_X86, CS_MODE_64, CS_OPT_SYNTAX_INTEL +md = Cs(CS_ARCH_X86, CS_MODE_64) +md.syntax = CS_OPT_SYNTAX_INTEL # = 1; Intel is also the x86 default +for ins in md.disasm(code_bytes, start_vaddr): + ins.address # int, virtual address of the instruction + ins.mnemonic # str, e.g. "cmp" + ins.op_str # str, e.g. "dword ptr [rbp - 4], 0x539" + ins.bytes # bytes, the raw machine bytes of this instruction + ins.size # int, length in bytes +``` + +`md.disasm(code, addr)` yields instructions until it hits bytes it cannot +decode. For the engine, `code` is the `.text` bytes and `addr` is `.text`'s +virtual address, so `ins.address` lines up with `readelf`/`objdump` addresses. + +## objdump vs capstone agreement (the M1 KAT) + +Disassembling the exact bytes of `check()` at vaddr 0x401146 with capstone +(Intel syntax) produced, instruction for instruction: + +``` +0x401146: push rbp [55] +0x401147: mov rbp, rsp [4889e5] +0x40114a: mov dword ptr [rbp - 4], edi [897dfc] +0x40114d: cmp dword ptr [rbp - 4], 0x539 [817dfc39050000] +0x401154: jne 0x40115d [7507] +0x401156: mov eax, 1 [b801000000] +0x40115b: jmp 0x401162 [eb05] +0x40115d: mov eax, 0 [b800000000] +0x401162: pop rbp [5d] +0x401163: ret [c3] +``` + +Every address, mnemonic, operand, and byte string matches `objdump -d -M intel`. +The only differences are cosmetic formatting (capstone renders `0x539` where +older objdump may print the same value, and omits the `` symbol +annotation that objdump adds from the symbol table). This byte-and-mnemonic +agreement is the known-answer test the M1 `disasm` module is verified against: +disassembling this fixed byte range must reproduce this instruction sequence. + +## Annotation the engine adds + +Capstone gives raw instructions. The engine adds a thin annotation layer for +teaching: +- mark `cmp`/`test` as comparisons and surface the immediate operand in both + hex and decimal (so `0x539` is shown as 1337 without the learner converting). +- mark jcc instructions as the conditional branch that acts on the preceding + comparison. +- resolve intra-function jump/call targets to a relative label + (`check+0x17`) using the function's start address, matching objdump's + readability, sourced from the symbol table when present. + +## Facts an adversarial check confirmed or corrected + +- Confirmed: capstone 5.0.9 with `md.syntax = 4` reproduces objdump Intel-syntax + disassembly byte-for-byte on the sample; the agreement is real, not assumed. +- Confirmed: `0x539` decodes to 1337 and originates from the source `n == 1337`. +- Corrected: `CS_OPT_SYNTAX_INTEL` is 1, not 4. The value 4 is + `CS_OPT_SYNTAX_MASM`, which happens to render these simple instructions + identically to Intel, so an early proof script using the literal 4 produced + correct-looking output by coincidence. Intel is already the x86 default in + capstone 5.0.9 (verified: default and explicit-Intel yield identical output, + AT&T differs). The engine sets `CS_OPT_SYNTAX_INTEL` by name, never a literal. diff --git a/PROJECTS/advanced/rveng/docs/research/03-pedagogy.md b/PROJECTS/advanced/rveng/docs/research/03-pedagogy.md new file mode 100644 index 00000000..e6adba9d --- /dev/null +++ b/PROJECTS/advanced/rveng/docs/research/03-pedagogy.md @@ -0,0 +1,85 @@ + + + +# Reverse-Engineering Pedagogy Research + +Source: the design of the predecessor rveng plus how the five modules actually +teach. The goal is a machine-checkable version of the same solve-then-reveal +loop, so the platform can grade a learner instead of just handing them tools. + +## The solve-then-reveal loop + +The predecessor's model, kept intact: +1. A module teaches one skill through a short concept note. +2. A challenge gives a compiled binary and a mission ("find the magic number"). +3. The learner uses the tools (now in-browser) to solve it. +4. After solving, the original C source is revealed so the learner connects the + machine-level evidence back to the code that produced it. + +Revealing source only after a correct answer is what makes the loop teach. The +learner must reach the answer from the binary, then gets to see they were right +and why. This requires the answer to be checkable before the reveal, which the +predecessor could not do (it just trusted the learner). The platform grades. + +## Answer-spec categories (what the verifier must grade) + +Every challenge needs a machine-checkable answer. Three categories cover all +five modules, each grounded in the sample `gate` binary: + +- **found-value** : the learner locates a value in the binary and submits it. + Example: the magic number in `check()` is `0x539` = 1337, read from + `cmp DWORD PTR [rbp-0x4],0x539`. The verifier accepts the value in decimal or + hex, normalized before comparison. Also covers finding a string + (`the_flag_is_here` at .rodata 0x402004) or an offset. + +- **identified-symbol** : the learner names a function, section, or symbol. + Example: "which function decides the outcome?" answer `check` (at 0x401146, + size 30 in `.symtab`). The verifier compares against the known symbol name, + case-insensitive. + +- **patched-bytes** : the learner changes bytes to alter behavior and submits + the patched byte range. Example: flip the gate by changing the `jne` (opcode + `75`) at 0x401154 so the unlock path is always taken. The verifier diffs the + submission against a known-good patched target statically. It never runs the + result. + +## Module to category mapping + +| # | Module | Skill | Answer category | +|---|--------|-------|-----------------| +| 01 | hex-reading | read hex dumps, locate values | found-value | +| 02 | elf-anatomy | header, sections, entry point | found-value / identified-symbol | +| 03 | patching | change bytes, change behavior | patched-bytes | +| 04 | strings-symbols | find names and strings | found-value / identified-symbol | +| 05 | disassembly | read assembly, find the gate | found-value | + +## What makes a good challenge per module + +- hex-reading: a value or ASCII string is visible in the hex dump at a + findable offset. The learner practices reading offsets and the ASCII gutter. +- elf-anatomy: the answer is a header field or a section fact (entry point, + which section holds a string, how many sections). Teaches the file's skeleton. +- patching: a single conditional jump or comparison gates behavior; a + one-to-few-byte edit changes the outcome. Teaches that behavior is bytes. +- strings-symbols: a password or a function name is recoverable from `.rodata` + or `.symtab` without disassembly. Teaches the cheap wins before the hard work. +- disassembly: symbols may be stripped so the learner must read the `cmp`/jcc + gate directly. Teaches assembly as ground truth when names are gone. + +## Grading rules + +- found-value: normalize both sides to an integer when numeric (accept `0x539`, + `539h`, `1337`), or exact-match after trim/lowercase when a string. +- identified-symbol: exact match after trim/lowercase against the known name. +- patched-bytes: the submitted bytes, applied at the specified offset to the + original, must equal the known-good patched target under a static byte diff. + No execution, ever. + +## Facts an adversarial check confirmed + +- Confirmed against the real binary: the magic number is `0x539` (1337), the + deciding function is `check` at 0x401146, and the gate is the `jne` at + 0x401154. These are the concrete answers the sample's challenges grade. +- The reveal-after-solve ordering is the load-bearing pedagogy: source is + withheld until a correct submission, which is only possible because every + answer is machine-checkable. diff --git a/PROJECTS/advanced/rveng/docs/research/04-no-execution-posture.md b/PROJECTS/advanced/rveng/docs/research/04-no-execution-posture.md new file mode 100644 index 00000000..7eb4a05b --- /dev/null +++ b/PROJECTS/advanced/rveng/docs/research/04-no-execution-posture.md @@ -0,0 +1,87 @@ + + + +# No-Execution Security Posture Research + +The single load-bearing security decision for the platform: the backend never +executes any binary. This document enumerates every engine operation and +confirms each is read-only static analysis, and records why patch grading is a +static diff rather than a behavioral test. + +## The threat that this design removes + +A naive "web app that analyzes binaries" invites users to upload arbitrary +executables that the server then runs or analyzes with tools that themselves +execute code. That path requires sandboxing untrusted native code (containers, +seccomp, resource limits, escape mitigation) and is a large, permanent attack +surface. The platform sidesteps it entirely: + +- Challenge binaries are curated and pre-compiled by the author and shipped as + static assets. Users do not upload binaries to run. +- Every operation the engine performs is reading and parsing bytes, not + executing them. + +If nothing is ever executed, there is no arbitrary-code-execution surface, no +sandbox to escape, and no resource-exhaustion path through a hostile binary. + +## Every engine operation is read-only + +| Operation | What it does | Executes target? | +|-----------|--------------|------------------| +| hex dump | read bytes, format offset/hex/ascii | no | +| ELF header parse | read fixed-offset fields from bytes | no | +| section/segment walk | read the section/program header tables | no | +| symbol table read | read `.symtab`/`.strtab` bytes | no | +| string extraction | scan bytes for printable runs | no | +| disassembly | capstone decodes bytes to mnemonics | no | +| patch verify | static byte diff of two buffers | no | + +Disassembly is decoding, not running: capstone reads instruction bytes and +returns their textual form. It never transfers control to the decoded code. + +## Patch grading without execution + +The patching module asks the learner to change bytes to alter behavior (for the +sample, flip the `jne` gate so the unlock path always runs). The tempting way to +grade this is to run the patched binary and check its output. That would mean +executing learner-influenced bytes on the server. Rejected. + +Instead, each patch challenge ships a known-good patched target alongside the +original. Grading is: apply the learner's submitted bytes at the specified +offset to the original, then compare the result against the known-good patched +target with a static byte diff. Equal means correct. The patched binary is +never run. This grades the exact skill (produce the right byte edit) with zero +execution. + +## Input constraints at the API boundary + +These are responsibilities of the M2 route layer, not the M1 engine. The engine +grades totally (a malformed submission returns "not correct", never a crash), +but the size and range limits below are enforced where untrusted input enters, +at the API boundary, and are not yet present because the route layer does not +yet exist. + +- Analysis routes accept a challenge id, never an arbitrary uploaded binary to + analyze or run. The binary analyzed is always a curated challenge asset. +- The submit route accepts an answer: a value, a name, or a patched byte range. + Submitted bytes are only ever diffed, never assembled into something executed. +- Byte inputs will be length-bounded at the route so a submission cannot be an + arbitrarily large blob. Until M2 lands this bound, the engine still never + executes the bytes; the open item is DoS size-capping, not code execution. + +## The standing rule + +Any future feature that would require executing a binary (running a challenge to +observe behavior, an interactive debugger, dynamic analysis) is out of scope by +default. It may be reconsidered only with a separate design that preserves this +posture, most likely by moving execution to an isolated, disposable sandbox that +is explicitly not the analysis backend. Until then: the backend reads bytes, it +does not run them. + +## Facts an adversarial check confirmed + +- Enumerated every engine operation; all seven are read-only byte processing. +- Confirmed capstone disassembly is pure decoding with no control transfer to + the target. +- Confirmed patch grading is achievable as a static diff against a known-good + target, so the one operation that "sounds like" it needs execution does not. diff --git a/PROJECTS/advanced/rveng/docs/research/INDEX.md b/PROJECTS/advanced/rveng/docs/research/INDEX.md new file mode 100644 index 00000000..098d099c --- /dev/null +++ b/PROJECTS/advanced/rveng/docs/research/INDEX.md @@ -0,0 +1,38 @@ + + + +# rveng Domain Research Archive + +Reverse-engineering-platform research: the ELF file format, x86-64 disassembly +via capstone, the solve-then-reveal teaching model, and the no-execution +security posture. Every field size, offset, opcode, and disassembly line is +cross-checked against a real binary compiled locally (`gate`, built with +`gcc -no-pie -fno-stack-protector -O0`), not taken from memory or a generic +diagram. Each doc carries the VERIFIED fact where an adversarial check overruled +the first pass. + +Honest grounding to keep across all of these: the engine reads bytes, it never +executes them. Challenge binaries are curated and pre-compiled; all analysis is +read-only static parsing and disassembly; patch challenges are graded by static +byte diff against a known-good target. This is what makes a web app that eats +binaries safe, and it is a hard constraint, not a preference. + +| File | Read when | +|---|---| +| `01-elf-format.md` | implementing `engine/elf.py`: the Elf64 header field table with raw-byte cross-check, section/segment/symbol tables, NOBITS handling, what we hand-roll vs delegate to pyelftools, why the challenges are `-no-pie` | +| `02-x86-64-capstone.md` | implementing `engine/disasm.py`: the minimal x86-64 instruction set these challenges use, the cmp/jcc gate pattern, capstone 5.0.9 API, the objdump-vs-capstone byte-for-byte agreement KAT, the Intel-syntax constant correction | +| `03-pedagogy.md` | implementing `engine/challenge.py` and the runner: the solve-then-reveal loop, the three answer-spec categories (found-value, identified-symbol, patched-bytes), module-to-category mapping, grading rules | +| `04-no-execution-posture.md` | any API or engine work touching binaries: the enumeration proving every operation is read-only, why patch grading is a static diff, the API input constraints, the standing no-execution rule | + +Key verified facts pulled forward: +- ELF header cross-checks exactly to raw bytes: entry `0x401060`, e_shoff + `0x36a0` (13984), e_shnum 30, e_ident `7f 45 4c 46 02 01 01`. +- The sample gate: `check` at `0x401146` (size 30), magic `cmp ...,0x539` = 1337 + at `0x40114d`, gate `jne` at `0x401154`, secret `the_flag_is_here` at .rodata + `0x402004`. +- capstone 5.0.9 reproduces objdump Intel disassembly byte-for-byte; + `CS_OPT_SYNTAX_INTEL` is 1 (not 4, which is MASM), and Intel is already the + x86 default. + +Build contract and full architecture: `../plans/rveng-design.md`. +Implementation plan: `../plans/rveng-implementation-plan.md`. diff --git a/PROJECTS/advanced/rveng/pyproject.toml b/PROJECTS/advanced/rveng/pyproject.toml new file mode 100644 index 00000000..b4c7322c --- /dev/null +++ b/PROJECTS/advanced/rveng/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "rveng" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ + { name = "CarterPerez-dev", email = "cartertechsolution@gmail.com" } +] +requires-python = ">=3.13" +dependencies = [ + "capstone>=5.0.9", + "fastapi>=0.139.0", + "pyelftools>=0.33", + "uvicorn>=0.50.0", +] + +[build-system] +requires = ["uv_build>=0.10.2,<0.11.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=9.1.1", +] diff --git a/PROJECTS/advanced/rveng/src/rveng/__init__.py b/PROJECTS/advanced/rveng/src/rveng/__init__.py new file mode 100644 index 00000000..a3abea1c --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/__init__.py @@ -0,0 +1,2 @@ +def hello() -> str: + return "Hello from rveng!" diff --git a/PROJECTS/advanced/rveng/src/rveng/api/__init__.py b/PROJECTS/advanced/rveng/src/rveng/api/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/api/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/advanced/rveng/src/rveng/api/app.py b/PROJECTS/advanced/rveng/src/rveng/api/app.py new file mode 100644 index 00000000..041f3911 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/api/app.py @@ -0,0 +1,164 @@ +""" +©AngelaMos | 2026 +app.py +""" + +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Query + +from rveng.api import schemas +from rveng.api.limits import ( + DEFAULT_SESSION, + MAX_DISASM_BYTES, + MAX_HEX_BYTES, +) +from rveng.api.store import ( + ChallengeStore, + InMemoryProgress, + ProgressStore, + load_store, +) +from rveng.engine import disasm, hex as hexmod, strings +from rveng.engine.challenge import Challenge, grade +from rveng.engine.elf import ElfImage, NotAnElf + +CHALLENGES_ROOT = Path(__file__).resolve().parents[3] / "challenges" + + +def create_app( + store: ChallengeStore | None = None, + progress: ProgressStore | None = None) -> FastAPI: + """ + Build the rveng API over a challenge store and progress store + """ + store = store or load_store(CHALLENGES_ROOT) + progress = progress or InMemoryProgress() + app = FastAPI(title="rveng") + + def require(cid: str) -> Challenge: + challenge = store.get(cid) + if challenge is None: + raise HTTPException(404, "challenge not found") + return challenge + + def elf_of(challenge: Challenge) -> ElfImage: + try: + return ElfImage(challenge.binary) + except NotAnElf as exc: + raise HTTPException(422, f"not an ELF: {exc}") + + @app.get("/api/challenges") + def list_challenges() -> list[schemas.ChallengeSummary]: + return [ + schemas.ChallengeSummary( + id=c.id, module=c.module, title=c.title) + for c in store.list() + ] + + @app.get("/api/challenges/{cid}") + def get_challenge(cid: str) -> schemas.ChallengeDetail: + c = require(cid) + return schemas.ChallengeDetail( + id=c.id, module=c.module, title=c.title, mission=c.mission, + category=c.category, size=len(c.binary)) + + @app.get("/api/challenges/{cid}/hex") + def hex_view( + cid: str, + offset: int = Query(0, ge=0), + length: int = Query(256, ge=1)) -> schemas.HexView: + c = require(cid) + start = min(offset, len(c.binary)) + take = min(length, MAX_HEX_BYTES) + chunk = c.binary[start:start + take] + return schemas.HexView( + base=start, + length=len(chunk), + lines=hexmod.hexdump(chunk, base=start).splitlines()) + + @app.get("/api/challenges/{cid}/elf") + def elf_view(cid: str) -> schemas.ElfView: + c = require(cid) + image = elf_of(c) + return schemas.ElfView( + type=image.header.e_type, + machine=image.header.e_machine, + entry=image.header.e_entry, + sections=[ + schemas.SectionView( + index=s.index, name=s.name, type=s.type_name, + addr=s.addr, offset=s.offset, size=s.size, + flags=s.flag_str) + for s in image.sections + ], + functions=[ + schemas.FunctionView( + name=f.name, value=f.value, size=f.size) + for f in image.functions() if f.name + ]) + + @app.get("/api/challenges/{cid}/disasm") + def disasm_view( + cid: str, symbol: str = Query(...)) -> schemas.DisasmView: + c = require(cid) + image = elf_of(c) + sym = image.symbol(symbol) + if sym is None: + raise HTTPException(404, "symbol not found") + try: + instructions = disasm.disassemble_symbol(image, sym) + except ValueError as exc: + raise HTTPException(422, str(exc)) + if sum(i.size for i in instructions) > MAX_DISASM_BYTES: + raise HTTPException(413, "symbol too large to disassemble") + gate = disasm.find_gate(instructions) + gate_addr = gate.address if gate else None + return schemas.DisasmView( + symbol=symbol, + gate_address=gate_addr, + instructions=[ + schemas.InstructionView( + address=i.address, mnemonic=i.mnemonic, + op_str=i.op_str, bytes=i.raw.hex(), + immediate=i.immediate, + branch_target=i.branch_target, + is_gate=(i.address == gate_addr)) + for i in instructions + ]) + + @app.get("/api/challenges/{cid}/strings") + def strings_view( + cid: str, + min_length: int = Query(4, ge=1, le=64)) -> schemas.StringsView: + c = require(cid) + found = strings.extract(c.binary, min_length=min_length) + return schemas.StringsView( + strings=[ + schemas.StringView(offset=s.offset, text=s.text) + for s in found + ]) + + @app.post("/api/challenges/{cid}/submit") + def submit(cid: str, body: schemas.SubmitRequest) -> schemas.SubmitResult: + c = require(cid) + if not body.within_limits(): + raise HTTPException(413, "answer too long") + result = grade(c, body.answer) + if result.correct: + progress.mark_solved(body.session, c.id) + return schemas.SubmitResult( + correct=result.correct, + message=result.message, + revealed_source=result.revealed_source) + + @app.get("/api/progress") + def get_progress( + session: str = Query(DEFAULT_SESSION)) -> schemas.ProgressView: + solved = progress.solved(session) + return schemas.ProgressView( + session=session, + solved=sorted(solved), + total=len(store.list())) + + return app diff --git a/PROJECTS/advanced/rveng/src/rveng/api/limits.py b/PROJECTS/advanced/rveng/src/rveng/api/limits.py new file mode 100644 index 00000000..1bf9f70a --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/api/limits.py @@ -0,0 +1,9 @@ +""" +©AngelaMos | 2026 +limits.py +""" + +MAX_HEX_BYTES = 4096 +MAX_DISASM_BYTES = 65536 +MAX_ANSWER_LEN = 8192 +DEFAULT_SESSION = "local" diff --git a/PROJECTS/advanced/rveng/src/rveng/api/schemas.py b/PROJECTS/advanced/rveng/src/rveng/api/schemas.py new file mode 100644 index 00000000..8ed12bff --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/api/schemas.py @@ -0,0 +1,98 @@ +""" +©AngelaMos | 2026 +schemas.py +""" + +from pydantic import BaseModel + +from rveng.api.limits import DEFAULT_SESSION, MAX_ANSWER_LEN + + +class ChallengeSummary(BaseModel): + id: str + module: str + title: str + + +class ChallengeDetail(BaseModel): + id: str + module: str + title: str + mission: str + category: str + size: int + + +class HexView(BaseModel): + base: int + length: int + lines: list[str] + + +class FunctionView(BaseModel): + name: str + value: int + size: int + + +class SectionView(BaseModel): + index: int + name: str + type: str + addr: int + offset: int + size: int + flags: str + + +class ElfView(BaseModel): + type: int + machine: int + entry: int + sections: list[SectionView] + functions: list[FunctionView] + + +class InstructionView(BaseModel): + address: int + mnemonic: str + op_str: str + bytes: str + immediate: int | None + branch_target: int | None + is_gate: bool + + +class DisasmView(BaseModel): + symbol: str + instructions: list[InstructionView] + gate_address: int | None + + +class StringView(BaseModel): + offset: int + text: str + + +class StringsView(BaseModel): + strings: list[StringView] + + +class SubmitRequest(BaseModel): + answer: str + session: str = DEFAULT_SESSION + + def within_limits(self) -> bool: + return len(self.answer) <= MAX_ANSWER_LEN + + +class SubmitResult(BaseModel): + correct: bool + message: str + revealed_source: str | None + + +class ProgressView(BaseModel): + session: str + solved: list[str] + total: int diff --git a/PROJECTS/advanced/rveng/src/rveng/api/server.py b/PROJECTS/advanced/rveng/src/rveng/api/server.py new file mode 100644 index 00000000..e60d153d --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/api/server.py @@ -0,0 +1,8 @@ +""" +©AngelaMos | 2026 +server.py +""" + +from rveng.api.app import create_app + +app = create_app() diff --git a/PROJECTS/advanced/rveng/src/rveng/api/store.py b/PROJECTS/advanced/rveng/src/rveng/api/store.py new file mode 100644 index 00000000..fb30e5f3 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/api/store.py @@ -0,0 +1,118 @@ +""" +©AngelaMos | 2026 +store.py +""" + +import json +from pathlib import Path +from typing import Protocol + +from rveng.engine import patch +from rveng.engine.challenge import ( + Challenge, + FoundValue, + IdentifiedSymbol, + PatchedBytes, +) + +MANIFEST = "challenge.json" +TARGET = "target" +SOURCE = "source.c" + +CAT_FOUND_VALUE = "found_value" +CAT_IDENTIFIED_SYMBOL = "identified_symbol" +CAT_PATCHED_BYTES = "patched_bytes" + + +class ChallengeError(ValueError): + """ + Raised when a challenge asset directory is malformed + """ + + +def _build_answer(spec: dict, binary: bytes): + category = spec.get("category") + if category == CAT_FOUND_VALUE: + return FoundValue(spec["expected"]) + if category == CAT_IDENTIFIED_SYMBOL: + return IdentifiedSymbol(spec["name"]) + if category == CAT_PATCHED_BYTES: + offset = spec["offset"] + replacement = bytes.fromhex(spec["patch"]) + known_good = patch.apply(binary, offset, replacement) + return PatchedBytes(offset=offset, known_good=known_good) + raise ChallengeError(f"unknown answer category: {category}") + + +def load_challenge(directory: Path) -> Challenge: + """ + Load one challenge from its asset directory + """ + manifest_path = directory / MANIFEST + if not manifest_path.is_file(): + raise ChallengeError(f"no manifest in {directory}") + manifest = json.loads(manifest_path.read_text()) + binary = (directory / TARGET).read_bytes() + source = (directory / SOURCE).read_text() + return Challenge( + id=manifest["id"], + module=manifest["module"], + title=manifest["title"], + mission=manifest["mission"], + binary=binary, + source=source, + answer=_build_answer(manifest["answer"], binary), + ) + + +class ChallengeStore: + """ + An in-memory registry of loaded challenges keyed by id + """ + + def __init__(self, challenges: list[Challenge]): + self._by_id = {c.id: c for c in challenges} + + def list(self) -> list[Challenge]: + return sorted(self._by_id.values(), key=lambda c: c.id) + + def get(self, challenge_id: str) -> Challenge | None: + return self._by_id.get(challenge_id) + + +def load_store(root: Path) -> ChallengeStore: + """ + Load every challenge directory under root into a store + """ + challenges = [] + for directory in sorted(root.iterdir()): + if (directory / MANIFEST).is_file(): + challenges.append(load_challenge(directory)) + return ChallengeStore(challenges) + + +class ProgressStore(Protocol): + """ + Persistence boundary for solved-challenge tracking + """ + + def mark_solved(self, session: str, challenge_id: str) -> None: + ... + + def solved(self, session: str) -> set[str]: + ... + + +class InMemoryProgress: + """ + A process-local progress store, swapped for SQLite in M4 + """ + + def __init__(self): + self._solved: dict[str, set[str]] = {} + + def mark_solved(self, session: str, challenge_id: str) -> None: + self._solved.setdefault(session, set()).add(challenge_id) + + def solved(self, session: str) -> set[str]: + return set(self._solved.get(session, set())) diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/__init__.py b/PROJECTS/advanced/rveng/src/rveng/engine/__init__.py new file mode 100644 index 00000000..e1add2a9 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/challenge.py b/PROJECTS/advanced/rveng/src/rveng/engine/challenge.py new file mode 100644 index 00000000..01185fa0 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/challenge.py @@ -0,0 +1,163 @@ +""" +©AngelaMos | 2026 +challenge.py +""" + +from dataclasses import dataclass + +from rveng.engine import patch + +Category = str +FOUND_VALUE: Category = "found_value" +IDENTIFIED_SYMBOL: Category = "identified_symbol" +PATCHED_BYTES: Category = "patched_bytes" + + +class AnswerError(ValueError): + """ + Raised when a submission cannot be interpreted for its category + """ + + +@dataclass(frozen=True) +class FoundValue: + """ + The learner must locate a numeric value or a string + """ + + expected: int | str + + +@dataclass(frozen=True) +class IdentifiedSymbol: + """ + The learner must name a function, section, or symbol + """ + + name: str + + +@dataclass(frozen=True) +class PatchedBytes: + """ + The learner must edit bytes to match a known-good patched target + """ + + offset: int + known_good: bytes + + +AnswerSpec = FoundValue | IdentifiedSymbol | PatchedBytes + + +@dataclass(frozen=True) +class Challenge: + """ + A curated challenge: a binary, a mission, an answer, and hidden source + """ + + id: str + module: str + title: str + mission: str + binary: bytes + source: str + answer: AnswerSpec + + @property + def category(self) -> Category: + match self.answer: + case FoundValue(): + return FOUND_VALUE + case IdentifiedSymbol(): + return IDENTIFIED_SYMBOL + case PatchedBytes(): + return PATCHED_BYTES + case _: + raise AnswerError("unknown answer spec") + + +@dataclass(frozen=True) +class GradeResult: + """ + The outcome of grading, revealing source only when correct + """ + + correct: bool + message: str + revealed_source: str | None + + +def normalize_int(text: str) -> int: + """ + Parse a submitted integer in hex, trailing-h, or decimal form + """ + token = text.strip().lower() + if not token: + raise AnswerError("empty value") + if token.startswith("0x"): + return int(token, 16) + if token.endswith("h"): + return int(token[:-1], 16) + return int(token, 10) + + +def _grade_found_value(spec: FoundValue, submitted: str) -> bool: + if isinstance(spec.expected, int): + try: + return normalize_int(submitted) == spec.expected + except (AnswerError, ValueError): + return False + return submitted.strip().lower() == spec.expected.strip().lower() + + +def _grade_identified_symbol( + spec: IdentifiedSymbol, submitted: str) -> bool: + return submitted.strip().lower() == spec.name.strip().lower() + + +def _grade_patched_bytes( + spec: PatchedBytes, + original: bytes, + submitted: bytes) -> bool: + return patch.verify_patch( + original, spec.offset, submitted, spec.known_good) + + +def grade(challenge: Challenge, submission: str | bytes) -> GradeResult: + """ + Grade a submission against the challenge answer spec + """ + spec = challenge.answer + match spec: + case FoundValue(): + correct = _grade_found_value(spec, _as_text(submission)) + case IdentifiedSymbol(): + correct = _grade_identified_symbol(spec, _as_text(submission)) + case PatchedBytes(): + try: + submitted = _as_bytes(submission) + except AnswerError: + return GradeResult(False, "invalid patch bytes", None) + correct = _grade_patched_bytes( + spec, challenge.binary, submitted) + case _: + raise AnswerError("unknown answer spec") + if correct: + return GradeResult(True, "correct", challenge.source) + return GradeResult(False, "not correct yet", None) + + +def _as_text(submission: str | bytes) -> str: + if isinstance(submission, bytes): + return submission.decode("utf-8", "replace") + return submission + + +def _as_bytes(submission: str | bytes) -> bytes: + if isinstance(submission, str): + try: + return bytes.fromhex(submission.strip().replace(" ", "")) + except ValueError as exc: + raise AnswerError("submission is not valid hex") from exc + return submission diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/disasm.py b/PROJECTS/advanced/rveng/src/rveng/engine/disasm.py new file mode 100644 index 00000000..850259c4 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/disasm.py @@ -0,0 +1,115 @@ +""" +©AngelaMos | 2026 +disasm.py +""" + +from dataclasses import dataclass + +from capstone import CS_ARCH_X86, CS_MODE_64, CS_OPT_SYNTAX_INTEL, Cs +from capstone.x86 import X86_OP_IMM + +from rveng.engine.elf import ElfImage, Symbol + +COMPARE_MNEMONICS = frozenset({"cmp", "test"}) +CONDITIONAL_JUMPS = frozenset({ + "je", "jne", "jz", "jnz", "jg", "jge", "jl", "jle", + "ja", "jae", "jb", "jbe", "js", "jns", "jo", "jno", + "jp", "jnp", "jc", "jnc", +}) +CALL_JUMP = frozenset({"call", "jmp"}) + + +@dataclass(frozen=True) +class Instruction: + """ + One decoded x86-64 instruction with teaching annotation + """ + + address: int + mnemonic: str + op_str: str + raw: bytes + immediate: int | None + branch_target: int | None = None + + @property + def size(self) -> int: + return len(self.raw) + + @property + def is_compare(self) -> bool: + return self.mnemonic in COMPARE_MNEMONICS + + @property + def is_conditional_branch(self) -> bool: + return self.mnemonic in CONDITIONAL_JUMPS + + @property + def is_flow(self) -> bool: + return (self.mnemonic in CONDITIONAL_JUMPS + or self.mnemonic in CALL_JUMP) + + def render(self) -> str: + return f"{self.address:x}: {self.mnemonic} {self.op_str}".rstrip() + + +def _new_engine() -> Cs: + md = Cs(CS_ARCH_X86, CS_MODE_64) + md.syntax = CS_OPT_SYNTAX_INTEL + md.detail = True + return md + + +def _immediate(ins) -> int | None: + value = None + for operand in ins.operands: + if operand.type == X86_OP_IMM: + value = operand.imm + return value + + +def disassemble(code: bytes, base: int = 0) -> list[Instruction]: + """ + Decode a byte range into annotated instructions at virtual base + """ + md = _new_engine() + out = [] + for ins in md.disasm(code, base): + imm = _immediate(ins) + flow = (ins.mnemonic in CONDITIONAL_JUMPS + or ins.mnemonic in CALL_JUMP) + out.append(Instruction( + address=ins.address, + mnemonic=ins.mnemonic, + op_str=ins.op_str, + raw=bytes(ins.bytes), + immediate=None if flow else imm, + branch_target=imm if flow else None, + )) + return out + + +def disassemble_symbol( + image: ElfImage, symbol: Symbol) -> list[Instruction]: + """ + Disassemble one function symbol from its owning in-file section + """ + shndx = symbol.shndx + if shndx <= 0 or shndx >= len(image.sections): + raise ValueError("symbol is not defined in an in-file section") + section = image.sections[shndx] + start = symbol.value - section.addr + section.offset + if start < 0 or start + symbol.size > len(image.data): + raise ValueError("symbol maps outside the file") + code = image.data[start:start + symbol.size] + return disassemble(code, symbol.value) + + +def find_gate(instructions: list[Instruction]) -> Instruction | None: + """ + Return the first comparison instruction that carries an immediate + """ + for ins in instructions: + if ins.is_compare and ins.immediate is not None: + return ins + return None diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/elf.py b/PROJECTS/advanced/rveng/src/rveng/engine/elf.py new file mode 100644 index 00000000..d3e1e886 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/elf.py @@ -0,0 +1,301 @@ +""" +©AngelaMos | 2026 +elf.py +""" + +import struct +from dataclasses import dataclass + +ELF_MAGIC = b"\x7fELF" +EI_CLASS = 4 +EI_DATA = 5 +ELFCLASS64 = 2 +ELFDATA_LSB = 1 +ELFDATA_MSB = 2 + +EHDR_SIZE = 64 +E_TYPE = 0x10 +E_MACHINE = 0x12 +E_ENTRY = 0x18 +E_PHOFF = 0x20 +E_SHOFF = 0x28 +E_SHENTSIZE = 0x3A +E_SHNUM = 0x3C +E_SHSTRNDX = 0x3E + +SHDR_SIZE = 64 +SH_NAME = 0x00 +SH_TYPE = 0x04 +SH_FLAGS = 0x08 +SH_ADDR = 0x10 +SH_OFFSET = 0x18 +SH_SIZE = 0x20 +SH_LINK = 0x28 +SH_ENTSIZE = 0x38 + +SYM_SIZE = 24 +ST_NAME = 0x00 +ST_INFO = 0x04 +ST_SHNDX = 0x06 +ST_VALUE = 0x08 +ST_SIZE = 0x10 + +SHT_SYMTAB = 2 +SHT_NOBITS = 8 + +SHF_WRITE = 0x1 +SHF_ALLOC = 0x2 +SHF_EXECINSTR = 0x4 + +STT_FUNC = 2 +STB_GLOBAL = 1 + +SECTION_TYPES = { + 0: "NULL", + 1: "PROGBITS", + 2: "SYMTAB", + 3: "STRTAB", + 4: "RELA", + 6: "DYNAMIC", + 7: "NOTE", + 8: "NOBITS", + 11: "DYNSYM", +} + +SYMBOL_TYPES = {0: "NOTYPE", 1: "OBJECT", 2: "FUNC", 3: "SECTION"} +SYMBOL_BINDS = {0: "LOCAL", 1: "GLOBAL", 2: "WEAK"} + + +class NotAnElf(ValueError): + """ + Raised when the bytes are not a supported ELF64 image + """ + + +def _cstring(blob: bytes, offset: int) -> str: + end = blob.find(b"\x00", offset) + if end == -1: + end = len(blob) + return blob[offset:end].decode("utf-8", "replace") + + +@dataclass(frozen=True) +class ElfHeader: + """ + Parsed Elf64 header fields + """ + + endian: str + e_type: int + e_machine: int + e_entry: int + e_phoff: int + e_shoff: int + e_shentsize: int + e_shnum: int + e_shstrndx: int + + +@dataclass(frozen=True) +class Section: + """ + One entry of the section header table + """ + + index: int + name: str + type: int + flags: int + addr: int + offset: int + size: int + link: int + entsize: int + + @property + def type_name(self) -> str: + return SECTION_TYPES.get(self.type, f"0x{self.type:x}") + + @property + def is_nobits(self) -> bool: + return self.type == SHT_NOBITS + + @property + def flag_str(self) -> str: + out = "" + if self.flags & SHF_WRITE: + out += "W" + if self.flags & SHF_ALLOC: + out += "A" + if self.flags & SHF_EXECINSTR: + out += "X" + return out + + def file_bytes(self, image: bytes) -> bytes: + if self.is_nobits: + return b"" + return image[self.offset:self.offset + self.size] + + +@dataclass(frozen=True) +class Symbol: + """ + One entry of a symbol table + """ + + name: str + value: int + size: int + type: int + bind: int + shndx: int + + @property + def type_name(self) -> str: + return SYMBOL_TYPES.get(self.type, f"0x{self.type:x}") + + @property + def bind_name(self) -> str: + return SYMBOL_BINDS.get(self.bind, f"0x{self.bind:x}") + + +class ElfImage: + """ + A parsed ELF64 image: header, sections, and symbols + """ + + def __init__(self, data: bytes): + self.data = data + self.header = parse_header(data) + self.sections = parse_sections(data, self.header) + self.symbols = parse_symbols( + data, self.sections, self.header.endian) + + def section(self, name: str) -> Section | None: + for sec in self.sections: + if sec.name == name: + return sec + return None + + def symbol(self, name: str) -> Symbol | None: + for sym in self.symbols: + if sym.name == name: + return sym + return None + + def functions(self) -> list[Symbol]: + return [s for s in self.symbols if s.type == STT_FUNC] + + +def parse_header(data: bytes) -> ElfHeader: + """ + Parse and validate the Elf64 header + """ + if len(data) < EHDR_SIZE or not data.startswith(ELF_MAGIC): + raise NotAnElf("missing ELF magic") + if data[EI_CLASS] != ELFCLASS64: + raise NotAnElf("not ELF64") + endian = "<" if data[EI_DATA] == ELFDATA_LSB else ">" + read_h = lambda off: struct.unpack_from(endian + "H", data, off)[0] + read_q = lambda off: struct.unpack_from(endian + "Q", data, off)[0] + header = ElfHeader( + endian=endian, + e_type=read_h(E_TYPE), + e_machine=read_h(E_MACHINE), + e_entry=read_q(E_ENTRY), + e_phoff=read_q(E_PHOFF), + e_shoff=read_q(E_SHOFF), + e_shentsize=read_h(E_SHENTSIZE), + e_shnum=read_h(E_SHNUM), + e_shstrndx=read_h(E_SHSTRNDX), + ) + _validate_section_table(data, header) + return header + + +def _validate_section_table(data: bytes, hdr: ElfHeader) -> None: + if hdr.e_shnum == 0: + return + if hdr.e_shentsize < SHDR_SIZE: + raise NotAnElf("section entry size too small") + table_end = hdr.e_shoff + hdr.e_shnum * hdr.e_shentsize + if hdr.e_shoff < EHDR_SIZE or table_end > len(data): + raise NotAnElf("section table out of bounds") + if hdr.e_shstrndx >= hdr.e_shnum: + raise NotAnElf("section name table index out of range") + + +def _raw_sections(data: bytes, hdr: ElfHeader) -> list[dict]: + en = hdr.endian + rows = [] + for i in range(hdr.e_shnum): + base = hdr.e_shoff + i * hdr.e_shentsize + rows.append({ + "index": i, + "name_off": struct.unpack_from(en + "I", data, base + SH_NAME)[0], + "type": struct.unpack_from(en + "I", data, base + SH_TYPE)[0], + "flags": struct.unpack_from(en + "Q", data, base + SH_FLAGS)[0], + "addr": struct.unpack_from(en + "Q", data, base + SH_ADDR)[0], + "offset": struct.unpack_from(en + "Q", data, base + SH_OFFSET)[0], + "size": struct.unpack_from(en + "Q", data, base + SH_SIZE)[0], + "link": struct.unpack_from(en + "I", data, base + SH_LINK)[0], + "entsize": struct.unpack_from(en + "Q", data, base + SH_ENTSIZE)[0], + }) + return rows + + +def parse_sections(data: bytes, hdr: ElfHeader) -> list[Section]: + """ + Parse the section header table and resolve section names + """ + rows = _raw_sections(data, hdr) + if not rows: + return [] + shstr = rows[hdr.e_shstrndx] + names = data[shstr["offset"]:shstr["offset"] + shstr["size"]] + sections = [] + for row in rows: + sections.append(Section( + index=row["index"], + name=_cstring(names, row["name_off"]), + type=row["type"], + flags=row["flags"], + addr=row["addr"], + offset=row["offset"], + size=row["size"], + link=row["link"], + entsize=row["entsize"], + )) + return sections + + +def parse_symbols( + data: bytes, + sections: list[Section], + endian: str = "<") -> list[Symbol]: + """ + Parse the .symtab entries and resolve their names via the linked strtab + """ + symtab = next( + (s for s in sections if s.type == SHT_SYMTAB), None) + if symtab is None or symtab.entsize == 0: + return [] + if symtab.link >= len(sections): + return [] + strtab = sections[symtab.link] + names = data[strtab.offset:strtab.offset + strtab.size] + body = data[symtab.offset:symtab.offset + symtab.size] + en = endian + symbols = [] + for base in range(0, len(body) - SYM_SIZE + 1, SYM_SIZE): + name_off = struct.unpack_from(en + "I", body, base + ST_NAME)[0] + info = body[base + ST_INFO] + symbols.append(Symbol( + name=_cstring(names, name_off), + value=struct.unpack_from(en + "Q", body, base + ST_VALUE)[0], + size=struct.unpack_from(en + "Q", body, base + ST_SIZE)[0], + type=info & 0xF, + bind=info >> 4, + shndx=struct.unpack_from(en + "H", body, base + ST_SHNDX)[0], + )) + return symbols diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/hex.py b/PROJECTS/advanced/rveng/src/rveng/engine/hex.py new file mode 100644 index 00000000..0d795e6c --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/hex.py @@ -0,0 +1,63 @@ +""" +©AngelaMos | 2026 +hex.py +""" + +from dataclasses import dataclass + +BYTES_PER_LINE = 16 +GROUP_SIZE = 8 +NON_PRINTABLE = "." +PRINTABLE_LOW = 0x20 +PRINTABLE_HIGH = 0x7E +OFFSET_WIDTH = 8 + + +@dataclass(frozen=True) +class HexLine: + """ + One rendered line of a hex dump + """ + + offset: int + data: bytes + + def hex_cells(self) -> str: + cells = [] + for index in range(BYTES_PER_LINE): + if index < len(self.data): + cells.append(f"{self.data[index]:02x}") + else: + cells.append(" ") + if index == GROUP_SIZE - 1: + cells.append("") + return " ".join(cells) + + def ascii_gutter(self) -> str: + chars = [] + for byte in self.data: + if PRINTABLE_LOW <= byte <= PRINTABLE_HIGH: + chars.append(chr(byte)) + else: + chars.append(NON_PRINTABLE) + return "".join(chars) + + def render(self) -> str: + offset = f"{self.offset:0{OFFSET_WIDTH}x}" + return f"{offset} {self.hex_cells()} |{self.ascii_gutter()}|" + + +def iter_lines(data: bytes, base: int = 0): + """ + Yield HexLine rows for data starting at virtual base + """ + for start in range(0, len(data), BYTES_PER_LINE): + chunk = data[start:start + BYTES_PER_LINE] + yield HexLine(offset=base + start, data=chunk) + + +def hexdump(data: bytes, base: int = 0) -> str: + """ + Render data as a canonical offset hex dump with ascii gutter + """ + return "\n".join(line.render() for line in iter_lines(data, base)) diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/patch.py b/PROJECTS/advanced/rveng/src/rveng/engine/patch.py new file mode 100644 index 00000000..600bdc48 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/patch.py @@ -0,0 +1,61 @@ +""" +©AngelaMos | 2026 +patch.py +""" + +from dataclasses import dataclass + + +class PatchError(ValueError): + """ + Raised when a patch cannot be applied within bounds + """ + + +@dataclass(frozen=True) +class ByteDiff: + """ + A single differing byte between two equal-length buffers + """ + + offset: int + before: int + after: int + + +def diff(original: bytes, modified: bytes) -> list[ByteDiff]: + """ + Return the per-byte differences between two equal-length buffers + """ + if len(original) != len(modified): + raise PatchError("buffers differ in length") + out = [] + for offset, (a, b) in enumerate(zip(original, modified)): + if a != b: + out.append(ByteDiff(offset=offset, before=a, after=b)) + return out + + +def apply(original: bytes, offset: int, new_bytes: bytes) -> bytes: + """ + Return original with new_bytes written at offset, length preserved + """ + if offset < 0 or offset + len(new_bytes) > len(original): + raise PatchError("patch out of bounds") + return (original[:offset] + + new_bytes + + original[offset + len(new_bytes):]) + + +def verify_patch( + original: bytes, + offset: int, + submitted: bytes, + known_good: bytes) -> bool: + """ + True when applying submitted at offset reproduces the known-good target + """ + try: + return apply(original, offset, submitted) == known_good + except PatchError: + return False diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/strings.py b/PROJECTS/advanced/rveng/src/rveng/engine/strings.py new file mode 100644 index 00000000..f4b2468a --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/strings.py @@ -0,0 +1,75 @@ +""" +©AngelaMos | 2026 +strings.py +""" + +from dataclasses import dataclass + +from rveng.engine.elf import ElfImage, Section + +DEFAULT_MIN_LENGTH = 4 +PRINTABLE_LOW = 0x20 +PRINTABLE_HIGH = 0x7E + + +@dataclass(frozen=True) +class FoundString: + """ + A printable run located at a file offset + """ + + offset: int + text: str + + @property + def length(self) -> int: + return len(self.text) + + +def _is_printable(byte: int) -> bool: + return PRINTABLE_LOW <= byte <= PRINTABLE_HIGH + + +def extract( + data: bytes, + min_length: int = DEFAULT_MIN_LENGTH, + base: int = 0) -> list[FoundString]: + """ + Find printable byte runs of at least min_length in data + """ + found = [] + run = bytearray() + start = 0 + for index, byte in enumerate(data): + if _is_printable(byte): + if not run: + start = index + run.append(byte) + continue + if len(run) >= min_length: + found.append(FoundString(base + start, run.decode("ascii"))) + run.clear() + if len(run) >= min_length: + found.append(FoundString(base + start, run.decode("ascii"))) + return found + + +def extract_in_section( + image: ElfImage, + section: Section, + min_length: int = DEFAULT_MIN_LENGTH) -> list[FoundString]: + """ + Extract strings from one section, offsets relative to the file + """ + body = section.file_bytes(image.data) + return extract(body, min_length, base=section.offset) + + +def find(data: bytes, needle: str) -> FoundString | None: + """ + Return the first extracted string equal to needle + """ + for item in extract(data, min_length=len(needle)): + if item.text == needle: + return item + return None diff --git a/PROJECTS/advanced/rveng/tests/conftest.py b/PROJECTS/advanced/rveng/tests/conftest.py new file mode 100644 index 00000000..ea9b2193 --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/conftest.py @@ -0,0 +1,20 @@ +""" +©AngelaMos | 2026 +conftest.py +""" + +from pathlib import Path + +import pytest + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture(scope="session") +def gate_path() -> Path: + return FIXTURES / "gate" + + +@pytest.fixture(scope="session") +def gate_bytes(gate_path: Path) -> bytes: + return gate_path.read_bytes() diff --git a/PROJECTS/advanced/rveng/tests/fixtures/gate b/PROJECTS/advanced/rveng/tests/fixtures/gate new file mode 100755 index 0000000000000000000000000000000000000000..bc1436b17427672fa70b3c2a0d73bb41cf236307 GIT binary patch literal 15904 zcmeHOeQXrh5r22a*deifhC&=Dupp4!Dn4v576Ae4k2CAafD0xes?zoPZtYv;zI3;j z*l9^f-47iom7_+Ls(z(^^y8nZsG<^WrPhflR-#IER8{4VO0g;;nF6V!rWHvot~2k= zoYxy$P5Va`sXNl{yqPz@dGqG&?!CQz@3~liZ#WbZOkr_EpfnWGrAnQJc)3H0q&pz$ zg)X*FU1^VTr{8iDSpHAr93WgPIc}x=GjFH%m~(rd1{~(|!xCQ}r>MQv#;=yf zjd_R~z;@9GUTeoroDt#kVhVNM`j3H(>BjoM{OD)z9sKtDZ9o6ct92*e3^iq5{M}FX zq@3Z7_C2XYOUlU<$6Ce?bhLD|M+@2LUg-yYu|Ft&D7qi4)~{fRepb?NT}2-Qy#|M$ zFvlzT5xJsU5SE*DglR&*;%33M@~)Y-oQ%ljos2soP>sFo5Xbuay1LE1(Y?|AmGZuJ zK#r!f8A6)SQW)}A1v$VYEOvp0$E~>8xev38w-4$rzxZ_sEi4W~8R_~_U2=@qo5MMe zp06Egs4M##5NODyy4ItoTSw|69z8vZq&_$W83-~EWFW{ukbxirK?Z^h1R3}!WWboX zU29yd{=*(2j4w^O;qt68akX~35?Xox>rhqR{aZLUY}cWLauVxr&Xs|7zlt(WrEktk z`4=eTRQcu<$ZdD}N^gADDE--(xUn!W7;Bqqd(D`cNi>41jgNupyCczt?O#NH(*Lwq z>=EKhGfb$B!+3T#TtO=g^S)oBqC7V;IX&V9=;Bw>{`t2hD=iqWynWPosxQ<-KT5HMnb;zBY2w`N(6gJ2i?KOl zVhV->>SpGDdaqnA#TGBd7D4zX1Offt3N{Ny=^D5=D_zXUCgz_4+glBw9Z=f&GoX-F zufb5tF!8{7E};P}^NtC!Wq{GoxCjm>#zCBDAII5PoCDZ(9q0Ete;Ow=R^+1HgxX ze;)Xzcht*gJMiluBjGnsq|h^`L})A$+VVh6?PRDXg5T)y8~=6iwG$11R<>2&^U(HB zV9Vd`@Y`Q29A`nN0~hs?-uk9bHQfJn?Rjx@^Px}f+a=v1?kpUSLX6wcuCqS!<#2c1 z+Q%Uf5V3v?jzOq@7I>vTJgy8th2I4+UThn`-6G+)zpyL?ryv7C27(L(83-~EWFW{u zkbxirK?Z^hd^9tl!v|QT2yyTG6zU(q6B4bHu2Yh@LG}-k%!~1hXT-`VglE*A~h){HWFvXYcl6)T_?Yz z8Dzlkn7FPJ=|RPWR;|kt>lY{cq>w__mXN)~DY~dcM9vl!W6S5)TPnAArQ;gV$9E|9PP6 zRaV03b8uk0e7~^%D^LiD4T7(K*8czsEA@A^e*IYbX%x*nbaqkUEBX1QwBIQF<9Su0 zB7)b$vy(qc`v(Qz2duvX1&qm0han*v+*M7KAFf{q`jzarfIb5DoKLpjiT2_8%JYKt z&8zr1M1K738lcx0?BS1rzA@Yc+c9*HR)_@ZMFW789{;mJDu6x$88ziDx0wjV4298 zqp9q$l`<1oyYsSK@I<=nK6TEjh<7&Jl21*tFzxc z+1qq;om`^^M(P~B!C+YDl%>$FztlpT0#a+TMd@in^>=Q6@cqyTMQsc zg{+yhGKrKe%)XOgm2fgzN zW(qr>6Vfng6_O&F7|(!H#ofHBdCJZgoNQ*9U_u@A#zFv zl<_>DTkzqzeSiN;gzqAUJU`S3V2~a~oJ0EYUjrTH1mly$&+mv5dEQC2GFC1l|0*0_ zyheD9@H!fKh}7~s1**RRhZoPkXG3K81>b-%o?U*|hv)hKjYeg`^I*28w6Z9^Pk7Eh z|1JwnnY30cRl$!}^P{El=!B^&|_+SEJJ0&g2tiJ^nc +#include + +int check(int n) { + if (n == 1337) { + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + char *secret = "the_flag_is_here"; + int n = 0; + if (argc > 1) { + n = atoi(argv[1]); + } + if (check(n)) { + printf("unlocked: %s\n", secret); + } else { + printf("wrong number\n"); + } + return 0; +} diff --git a/PROJECTS/advanced/rveng/tests/test_api.py b/PROJECTS/advanced/rveng/tests/test_api.py new file mode 100644 index 00000000..2c5571fb --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_api.py @@ -0,0 +1,132 @@ +""" +©AngelaMos | 2026 +test_api.py +""" + +import pytest +from fastapi.testclient import TestClient + +from rveng.api.app import create_app +from rveng.api.limits import MAX_HEX_BYTES + +ELF_MAGIC_LINE = ( + "00000000 7f 45 4c 46 02 01 01 00 " + "00 00 00 00 00 00 00 00 |.ELF............|" +) + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(create_app()) + + +def test_list_has_three_seed_challenges(client: TestClient): + body = client.get("/api/challenges").json() + ids = {c["id"] for c in body} + assert ids == { + "03-flip-the-gate", "04-name-the-function", "05-find-the-gate"} + + +def test_detail_does_not_leak_source_or_answer(client: TestClient): + body = client.get("/api/challenges/05-find-the-gate").json() + assert body["category"] == "found_value" + assert "source" not in body + assert "answer" not in body + assert "expected" not in body + + +def test_missing_challenge_is_404(client: TestClient): + assert client.get("/api/challenges/nope").status_code == 404 + + +def test_hex_first_line_is_elf_magic(client: TestClient): + body = client.get( + "/api/challenges/05-find-the-gate/hex?offset=0&length=16").json() + assert body["lines"][0] == ELF_MAGIC_LINE + + +def test_hex_length_is_capped(client: TestClient): + body = client.get( + "/api/challenges/05-find-the-gate/hex?length=999999").json() + assert body["length"] == MAX_HEX_BYTES + + +def test_elf_view_reports_entry_and_functions(client: TestClient): + body = client.get("/api/challenges/05-find-the-gate/elf").json() + assert body["entry"] == 0x401060 + names = {f["name"] for f in body["functions"]} + assert "check" in names and "main" in names + + +def test_disasm_marks_the_gate(client: TestClient): + body = client.get( + "/api/challenges/05-find-the-gate/disasm?symbol=check").json() + assert body["gate_address"] == 0x40114D + gate = next(i for i in body["instructions"] if i["is_gate"]) + assert gate["mnemonic"] == "cmp" + assert gate["immediate"] == 1337 + + +def test_disasm_unknown_symbol_is_404(client: TestClient): + r = client.get("/api/challenges/05-find-the-gate/disasm?symbol=nope") + assert r.status_code == 404 + + +def test_strings_finds_the_secret(client: TestClient): + body = client.get("/api/challenges/05-find-the-gate/strings").json() + texts = {s["text"] for s in body["strings"]} + assert "the_flag_is_here" in texts + + +def test_submit_found_value_correct_reveals_source(client: TestClient): + r = client.post( + "/api/challenges/05-find-the-gate/submit", + json={"answer": "0x539"}).json() + assert r["correct"] is True + assert "1337" in r["revealed_source"] or "the_flag" in r["revealed_source"] + + +def test_submit_found_value_wrong_hides_source(client: TestClient): + r = client.post( + "/api/challenges/05-find-the-gate/submit", + json={"answer": "42"}).json() + assert r["correct"] is False + assert r["revealed_source"] is None + + +def test_submit_identified_symbol(client: TestClient): + r = client.post( + "/api/challenges/04-name-the-function/submit", + json={"answer": "check"}).json() + assert r["correct"] is True + + +def test_submit_patched_bytes_correct(client: TestClient): + r = client.post( + "/api/challenges/03-flip-the-gate/submit", + json={"answer": "9090"}).json() + assert r["correct"] is True + + +def test_submit_bad_hex_does_not_500(client: TestClient): + r = client.post( + "/api/challenges/03-flip-the-gate/submit", + json={"answer": "zz"}) + assert r.status_code == 200 + assert r.json()["correct"] is False + + +def test_submit_oversized_answer_rejected(client: TestClient): + r = client.post( + "/api/challenges/05-find-the-gate/submit", + json={"answer": "9" * 100000}) + assert r.status_code == 413 + + +def test_progress_tracks_solves(client: TestClient): + client.post( + "/api/challenges/05-find-the-gate/submit", + json={"answer": "1337", "session": "s1"}) + body = client.get("/api/progress?session=s1").json() + assert body["solved"] == ["05-find-the-gate"] + assert body["total"] == 3 diff --git a/PROJECTS/advanced/rveng/tests/test_challenge.py b/PROJECTS/advanced/rveng/tests/test_challenge.py new file mode 100644 index 00000000..12093369 --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_challenge.py @@ -0,0 +1,69 @@ +""" +©AngelaMos | 2026 +test_challenge.py +""" + +import pytest + +from rveng.engine import challenge as ch +from rveng.engine import patch + +JNE_FILE_OFFSET = 0x1154 +NOP_NOP = b"\x90\x90" + +SOURCE = "int check(int n){ return n == 1337; }" + + +def _challenge(answer, binary=b"") -> ch.Challenge: + return ch.Challenge( + id="t", module="m", title="t", mission="find it", + binary=binary, source=SOURCE, answer=answer) + + +def test_normalize_int_accepts_hex_dec_and_h(): + assert ch.normalize_int("0x539") == 1337 + assert ch.normalize_int("539h") == 1337 + assert ch.normalize_int(" 1337 ") == 1337 + + +def test_found_value_numeric_correct_reveals_source(): + c = _challenge(ch.FoundValue(1337)) + result = ch.grade(c, "0x539") + assert result.correct + assert result.revealed_source == SOURCE + + +def test_found_value_wrong_hides_source(): + c = _challenge(ch.FoundValue(1337)) + result = ch.grade(c, "1234") + assert not result.correct + assert result.revealed_source is None + + +def test_found_value_string(): + c = _challenge(ch.FoundValue("the_flag_is_here")) + assert ch.grade(c, "the_flag_is_here").correct + assert not ch.grade(c, "nope").correct + + +def test_identified_symbol_case_insensitive(): + c = _challenge(ch.IdentifiedSymbol("check")) + assert ch.grade(c, "Check").correct + assert not ch.grade(c, "main").correct + + +def test_category_property(): + assert _challenge(ch.FoundValue(1)).category == ch.FOUND_VALUE + assert _challenge( + ch.IdentifiedSymbol("x")).category == ch.IDENTIFIED_SYMBOL + assert _challenge( + ch.PatchedBytes(0, b"")).category == ch.PATCHED_BYTES + + +def test_patched_bytes_graded_by_static_diff(gate_bytes: bytes): + known_good = patch.apply(gate_bytes, JNE_FILE_OFFSET, NOP_NOP) + c = _challenge( + ch.PatchedBytes(JNE_FILE_OFFSET, known_good), binary=gate_bytes) + assert ch.grade(c, NOP_NOP).correct + assert ch.grade(c, "9090").correct + assert not ch.grade(c, b"\x75\x07").correct diff --git a/PROJECTS/advanced/rveng/tests/test_disasm.py b/PROJECTS/advanced/rveng/tests/test_disasm.py new file mode 100644 index 00000000..11014929 --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_disasm.py @@ -0,0 +1,54 @@ +""" +©AngelaMos | 2026 +test_disasm.py +""" + +import pytest + +from rveng.engine import disasm, elf + +CHECK_BYTES = bytes.fromhex( + "554889e5897dfc817dfc390500007507" + "b80100000 0eb05b80000000 05dc3".replace(" ", "")) + +EXPECTED = [ + (0x401146, "push", "rbp"), + (0x401147, "mov", "rbp, rsp"), + (0x40114A, "mov", "dword ptr [rbp - 4], edi"), + (0x40114D, "cmp", "dword ptr [rbp - 4], 0x539"), + (0x401154, "jne", "0x40115d"), + (0x401156, "mov", "eax, 1"), + (0x40115B, "jmp", "0x401162"), + (0x40115D, "mov", "eax, 0"), + (0x401162, "pop", "rbp"), + (0x401163, "ret", ""), +] + + +def test_matches_objdump_instruction_for_instruction(): + got = disasm.disassemble(CHECK_BYTES, 0x401146) + assert [(i.address, i.mnemonic, i.op_str) for i in got] == EXPECTED + + +def test_gate_immediate_is_1337(): + got = disasm.disassemble(CHECK_BYTES, 0x401146) + gate = disasm.find_gate(got) + assert gate.mnemonic == "cmp" + assert gate.immediate == 1337 + assert gate.address == 0x40114D + + +def test_conditional_branch_flagged(): + got = disasm.disassemble(CHECK_BYTES, 0x401146) + jne = next(i for i in got if i.mnemonic == "jne") + assert jne.is_conditional_branch + assert jne.is_flow + + +def test_disassemble_symbol_from_image(gate_bytes: bytes): + image = elf.ElfImage(gate_bytes) + check = image.symbol("check") + got = disasm.disassemble_symbol(image, check) + assert got[0].address == 0x401146 + assert disasm.find_gate(got).immediate == 1337 + assert got[-1].mnemonic == "ret" diff --git a/PROJECTS/advanced/rveng/tests/test_elf.py b/PROJECTS/advanced/rveng/tests/test_elf.py new file mode 100644 index 00000000..274df6fd --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_elf.py @@ -0,0 +1,79 @@ +""" +©AngelaMos | 2026 +test_elf.py +""" + +import pytest + +from rveng.engine import elf + + +@pytest.fixture(scope="module") +def image(gate_bytes: bytes) -> elf.ElfImage: + return elf.ElfImage(gate_bytes) + + +def test_header_matches_readelf(image: elf.ElfImage): + hdr = image.header + assert hdr.e_type == 2 + assert hdr.e_machine == 0x3E + assert hdr.e_entry == 0x401060 + assert hdr.e_shoff == 13984 + assert hdr.e_shentsize == 64 + assert hdr.e_shnum == 30 + assert hdr.e_shstrndx == 29 + + +def test_rejects_non_elf(): + with pytest.raises(elf.NotAnElf): + elf.ElfImage(b"not an elf at all, definitely not") + + +def test_text_section(image: elf.ElfImage): + text = image.section(".text") + assert text is not None + assert text.addr == 0x401060 + assert text.offset == 0x1060 + assert text.size == 0x182 + assert text.flag_str == "AX" + + +def test_rodata_holds_the_secret(image: elf.ElfImage): + rodata = image.section(".rodata") + assert rodata.addr == 0x402000 + assert b"the_flag_is_here" in rodata.file_bytes(image.data) + + +def test_bss_is_nobits_no_file_bytes(image: elf.ElfImage): + bss = image.section(".bss") + assert bss.is_nobits + assert bss.file_bytes(image.data) == b"" + + +def test_check_symbol(image: elf.ElfImage): + check = image.symbol("check") + assert check.value == 0x401146 + assert check.size == 30 + assert check.type_name == "FUNC" + assert check.bind_name == "GLOBAL" + + +def test_main_symbol(image: elf.ElfImage): + main = image.symbol("main") + assert main.value == 0x401164 + assert main.size == 126 + + +def test_matches_pyelftools(gate_bytes: bytes, image: elf.ElfImage): + from io import BytesIO + + from elftools.elf.elffile import ELFFile + + ref = ELFFile(BytesIO(gate_bytes)) + assert image.header.e_entry == ref.header.e_entry + assert image.header.e_shnum == ref.num_sections() + assert image.section(".text").addr == ref.get_section_by_name( + ".text").header.sh_addr + ref_check = ref.get_section_by_name(".symtab").get_symbol_by_name( + "check")[0] + assert image.symbol("check").value == ref_check.entry.st_value diff --git a/PROJECTS/advanced/rveng/tests/test_hex.py b/PROJECTS/advanced/rveng/tests/test_hex.py new file mode 100644 index 00000000..f6990b8d --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_hex.py @@ -0,0 +1,40 @@ +""" +©AngelaMos | 2026 +test_hex.py +""" + +from rveng.engine import hex as hexmod + +ELF_MAGIC_LINE = ( + "00000000 7f 45 4c 46 02 01 01 00 " + "00 00 00 00 00 00 00 00 |.ELF............|" +) + + +def test_first_line_matches_known_elf_header(gate_bytes: bytes): + first = hexmod.hexdump(gate_bytes[:16]) + assert first == ELF_MAGIC_LINE + + +def test_ascii_gutter_marks_non_printable(): + line = hexmod.HexLine(offset=0, data=b"\x7fELF\x00") + assert line.ascii_gutter() == ".ELF." + + +def test_base_offset_is_applied(): + line = next(hexmod.iter_lines(b"\x90", base=0x401060)) + assert line.render().startswith("00401060 90") + + +def test_short_final_line_pads_hex_but_not_ascii(): + dump = hexmod.hexdump(b"\x41\x42") + assert dump.startswith("00000000 41 42 ") + assert dump.endswith("|AB|") + + +def test_full_and_partial_line_count(): + data = bytes(range(20)) + lines = list(hexmod.iter_lines(data)) + assert len(lines) == 2 + assert lines[0].offset == 0 + assert lines[1].offset == 16 diff --git a/PROJECTS/advanced/rveng/tests/test_patch.py b/PROJECTS/advanced/rveng/tests/test_patch.py new file mode 100644 index 00000000..80d8a4ec --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_patch.py @@ -0,0 +1,55 @@ +""" +©AngelaMos | 2026 +test_patch.py +""" + +import pytest + +from rveng.engine import disasm, elf, patch + +JNE_FILE_OFFSET = 0x1154 +JNE_BYTES = b"\x75\x07" +NOP_NOP = b"\x90\x90" + + +def test_diff_finds_changed_bytes(): + diffs = patch.diff(b"\x75\x07\x90", b"\x90\x07\x90") + assert len(diffs) == 1 + assert diffs[0].offset == 0 + assert diffs[0].before == 0x75 + assert diffs[0].after == 0x90 + + +def test_diff_rejects_length_mismatch(): + with pytest.raises(patch.PatchError): + patch.diff(b"\x00", b"\x00\x00") + + +def test_apply_preserves_length(): + out = patch.apply(b"AAAA", 1, b"BB") + assert out == b"ABBA" + + +def test_apply_out_of_bounds_raises(): + with pytest.raises(patch.PatchError): + patch.apply(b"AAAA", 3, b"BB") + + +def test_gate_byte_is_jne(gate_bytes: bytes): + assert gate_bytes[JNE_FILE_OFFSET:JNE_FILE_OFFSET + 2] == JNE_BYTES + + +def test_verify_flip_the_gate(gate_bytes: bytes): + known_good = patch.apply(gate_bytes, JNE_FILE_OFFSET, NOP_NOP) + assert patch.verify_patch( + gate_bytes, JNE_FILE_OFFSET, NOP_NOP, known_good) + assert not patch.verify_patch( + gate_bytes, JNE_FILE_OFFSET, b"\x75\x07", known_good) + + +def test_patched_gate_removes_conditional_branch(gate_bytes: bytes): + known_good = patch.apply(gate_bytes, JNE_FILE_OFFSET, NOP_NOP) + image = elf.ElfImage(known_good) + check = image.symbol("check") + ins = disasm.disassemble_symbol(image, check) + assert not any(i.is_conditional_branch for i in ins) diff --git a/PROJECTS/advanced/rveng/tests/test_robustness.py b/PROJECTS/advanced/rveng/tests/test_robustness.py new file mode 100644 index 00000000..508f8c63 --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_robustness.py @@ -0,0 +1,94 @@ +""" +©AngelaMos | 2026 +test_robustness.py +""" + +import struct + +import pytest + +from rveng.engine import challenge as ch +from rveng.engine import disasm, elf, patch + +JNE_FILE_OFFSET = 0x1154 +NOP_NOP = b"\x90\x90" +E_SHOFF = 0x28 +E_SHNUM = 0x3C +E_SHSTRNDX = 0x3E + + +def _corrupt(data: bytes, offset: int, fmt: str, value: int) -> bytes: + buf = bytearray(data) + struct.pack_into("<" + fmt, buf, offset, value) + return bytes(buf) + + +def test_bad_shstrndx_raises_notanelf(gate_bytes: bytes): + bad = _corrupt(gate_bytes, E_SHSTRNDX, "H", 0xFFFF) + with pytest.raises(elf.NotAnElf): + elf.ElfImage(bad) + + +def test_section_table_past_eof_raises_notanelf(gate_bytes: bytes): + bad = _corrupt(gate_bytes, E_SHOFF, "Q", len(gate_bytes) + 0x1000) + with pytest.raises(elf.NotAnElf): + elf.ElfImage(bad) + + +def test_huge_shnum_raises_notanelf(gate_bytes: bytes): + bad = _corrupt(gate_bytes, E_SHNUM, "H", 0xFFFF) + with pytest.raises(elf.NotAnElf): + elf.ElfImage(bad) + + +def test_truncated_file_raises_notanelf(gate_bytes: bytes): + with pytest.raises(elf.NotAnElf): + elf.ElfImage(gate_bytes[:0x1000]) + + +@pytest.mark.parametrize("bad_hex", ["zz", "909", "0xff", "not hex"]) +def test_bad_hex_patch_submission_does_not_crash( + gate_bytes: bytes, bad_hex: str): + known_good = patch.apply(gate_bytes, JNE_FILE_OFFSET, NOP_NOP) + c = ch.Challenge( + id="t", module="m", title="t", mission="m", binary=gate_bytes, + source="src", answer=ch.PatchedBytes(JNE_FILE_OFFSET, known_good)) + result = ch.grade(c, bad_hex) + assert result.correct is False + assert result.revealed_source is None + + +def test_disassemble_symbol_rejects_undefined_symbol(gate_bytes: bytes): + image = elf.ElfImage(gate_bytes) + undefined = elf.Symbol( + name="imported", value=0x401146, size=30, + type=elf.STT_FUNC, bind=elf.STB_GLOBAL, shndx=0) + with pytest.raises(ValueError): + disasm.disassemble_symbol(image, undefined) + + +def test_disassemble_symbol_rejects_out_of_range_shndx(gate_bytes: bytes): + image = elf.ElfImage(gate_bytes) + weird = elf.Symbol( + name="weird", value=0x401146, size=30, + type=elf.STT_FUNC, bind=elf.STB_GLOBAL, shndx=0xFFF1) + with pytest.raises(ValueError): + disasm.disassemble_symbol(image, weird) + + +def test_interior_space_is_not_concatenated_into_a_match(): + c = ch.Challenge( + id="t", module="m", title="t", mission="m", binary=b"", + source="src", answer=ch.FoundValue(1337)) + assert ch.grade(c, "13 37").correct is False + assert ch.grade(c, "1337").correct is True + + +def test_flow_instruction_target_not_labeled_immediate(): + code = bytes.fromhex("817dfc390500007507") + got = disasm.disassemble(code, 0x40114D) + cmp_ins = got[0] + jne_ins = got[1] + assert cmp_ins.immediate == 0x539 + assert jne_ins.immediate is None + assert jne_ins.branch_target == 0x40115D diff --git a/PROJECTS/advanced/rveng/tests/test_strings.py b/PROJECTS/advanced/rveng/tests/test_strings.py new file mode 100644 index 00000000..c681273b --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_strings.py @@ -0,0 +1,34 @@ +""" +©AngelaMos | 2026 +test_strings.py +""" + +from rveng.engine import elf, strings + + +def test_extract_basic_runs(): + data = b"\x00\x01hello\x00\xffworld!!\x00" + got = strings.extract(data, min_length=4) + assert [s.text for s in got] == ["hello", "world!!"] + + +def test_min_length_filters_short_runs(): + data = b"ab\x00abcd\x00" + got = strings.extract(data, min_length=4) + assert [s.text for s in got] == ["abcd"] + + +def test_secret_found_in_rodata_at_known_offset(gate_bytes: bytes): + image = elf.ElfImage(gate_bytes) + rodata = image.section(".rodata") + got = strings.extract_in_section(image, rodata) + texts = [s.text for s in got] + assert "the_flag_is_here" in texts + secret = next(s for s in got if s.text == "the_flag_is_here") + assert secret.offset == 0x2004 + + +def test_find_returns_offset(gate_bytes: bytes): + hit = strings.find(gate_bytes, "wrong number") + assert hit is not None + assert hit.text == "wrong number" diff --git a/PROJECTS/advanced/rveng/uv.lock b/PROJECTS/advanced/rveng/uv.lock new file mode 100644 index 00000000..42f6ca82 --- /dev/null +++ b/PROJECTS/advanced/rveng/uv.lock @@ -0,0 +1,352 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "capstone" +version = "5.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/01/6516910f546fbb996068207b9dd0229b14bc8dae223114d5e0e27d3cad11/capstone-5.0.9.tar.gz", hash = "sha256:0429af292ddc604d3c9344696807e281d5e728db029f00e6a4ea9e3bff1aac9e", size = 2947300, upload-time = "2026-05-28T16:05:57.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/f3/43f4dbfc36c7740d512621a9d42b276ac21bb6ca7cb974d4c8a19060d387/capstone-5.0.9-py3-none-macosx_10_9_universal2.whl", hash = "sha256:58a874d9a6cb15122135b1385a782e5b54c8a4d9396161b6498465046d2e0442", size = 2195391, upload-time = "2026-05-28T16:05:45.515Z" }, + { url = "https://files.pythonhosted.org/packages/70/74/d00de02c62ce864c6b43796c524516ea064e3e6fc1327d452f78270e8323/capstone-5.0.9-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:8c4a1d7134d7d5290e5d77066500385f2c7a4bf66835ccbfde5180042d508b3c", size = 1190392, upload-time = "2026-05-28T16:05:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/42/9a/4eecfbc94961cda70301fd13de69042be1943bee456e37bc1369840601b1/capstone-5.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc269c5082e17d7f9f265cd6980e2fdc6ab572df913951d76db0357e06b8001d", size = 1202361, upload-time = "2026-05-28T16:05:48.1Z" }, + { url = "https://files.pythonhosted.org/packages/45/d9/dbeadb9fcd461e608ce79b1cb647fed6baa812fa6e1c68b0ba5ef81291af/capstone-5.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41772ad0c71aa5d3e97d541e802593c2e71fb94ac7e20d4202f97ce2a3eb8ed5", size = 1461016, upload-time = "2026-05-28T16:05:49.554Z" }, + { url = "https://files.pythonhosted.org/packages/d1/39/17747862222bb062e86b501f1f148d5ff589b77908b080d30f7f085cbfb7/capstone-5.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:273fd8d747d2e35c88f91450be51a603ecfaafb00d96d9f315dcb8689c86193e", size = 1485316, upload-time = "2026-05-28T16:05:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/9a694f60f5b26612039d975c1c84d2ad8fd0ad839b0b1058dbe68dd32b0b/capstone-5.0.9-py3-none-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f5871b57e987d5bf23b531fbe868211b74ad750fa9b830a3d3cdc4956bc0ae3", size = 1483221, upload-time = "2026-05-28T16:05:51.925Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/c178ac241892bb3668b633065bdbe6dc719c2aa54668d68a8e23a6753525/capstone-5.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:69064219c814ad64af35469a7c4ddd19b730cacb96a1a796435ccde0e1567d05", size = 1459140, upload-time = "2026-05-28T16:05:53.182Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f3/3865a0371603994f8bf521595b3072080bfe1b715f8bd080988d026dd2a1/capstone-5.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0cc3b1ec319ab0530efc6b181491b77dd3373c81b6dbcf2f05a80e3d8dd61d5e", size = 1488205, upload-time = "2026-05-28T16:05:54.31Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/39a575aa27a35b43b0ad93065a1803dba39038060eccfb29031d82e3fb2c/capstone-5.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cfec34e6e01472fe60850c87c5bea9918c274fa2a605b510e6489bf87c0c9960", size = 1488865, upload-time = "2026-05-28T16:05:55.603Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/6f06fdb6a9ed32b2f7cd9c036b92d5324112c3ef7080f2c71efc367d40dd/capstone-5.0.9-py3-none-win_amd64.whl", hash = "sha256:732cedbbb56d42e723f14d7af6387f1454194a820b4b96b56d1e53f865ef85d0", size = 1273459, upload-time = "2026-05-28T16:05:56.73Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pyelftools" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/11/767522582afab1b884d277de0e6e011640cb9d7292a38694b4b1a1df1ae8/pyelftools-0.33.tar.gz", hash = "sha256:660d82dcbeb8e83d1702bd97f223f761625da06111c0cc988eac6b8ab0c1b61f", size = 15068655, upload-time = "2026-05-29T12:56:22.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2a/f9697576603dae937727827505a6126a066affb227034e77e6f9068910da/pyelftools-0.33-py3-none-any.whl", hash = "sha256:f215ad5f47d3f1373a21496a6c9e0707c622840d0622f23ff7ce08678b020036", size = 201178, upload-time = "2026-05-29T12:56:20.587Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "rveng" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "capstone" }, + { name = "fastapi" }, + { name = "pyelftools" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "capstone", specifier = ">=5.0.9" }, + { name = "fastapi", specifier = ">=0.139.0" }, + { name = "pyelftools", specifier = ">=0.33" }, + { name = "uvicorn", specifier = ">=0.50.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.1.1" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/41/06cce5dbb9f77591512957710ac709e60b12e6216a2f2d0d607fd49706e8/uvicorn-0.50.0.tar.gz", hash = "sha256:0c92e1bc2259cb7faa4fcef774a5966588f2e88542744550b66799fba10b76f1", size = 93257, upload-time = "2026-07-04T05:03:26.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3a/eb70620ca2bf8213603d5c731460687c49fee38b0072f0b4a637781f0a53/uvicorn-0.50.0-py3-none-any.whl", hash = "sha256:05f0eb19edf38208f79f43df8a63081b48df31b0cd1e5997be957a4dc97d1b19", size = 72716, upload-time = "2026-07-04T05:03:24.848Z" }, +] From 01dca717650a780bf8724dcbb29a5f558ea9418c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 5 Jul 2026 15:36:46 -0400 Subject: [PATCH 2/3] feat: rveng M3 web app + M3.5 static-analysis engine depth M3 React web app over the existing FastAPI: hex viewer, disassembly pane, section map, strings, challenge runner with reveal-after-solve, and progress. Scaffolded from the open no-auth react-scss template; components kept self-contained for CertGames extraction. M3.5 static-analysis depth, all still no-execution and KAT-traced to the gate binary: PLT/GOT import resolution (plt.py), RIP-relative cross-references and callers (xref.py), basic-block control-flow graphs (cfg.py), and prologue-scan function discovery for stripped binaries (discover.py). Adds the 06-stripped-gate challenge. The API now resolves call names, exposes rip targets, and serves /cfg, /xrefs, and discovered functions. M2 API hardening from a read-only audit: challenge-loader error isolation, a request body-size middleware, session length bounds, CORS, and the disasm gate annotation gated behind solved state so it no longer pre-solves the challenge. 96 backend tests green; frontend typecheck, build, and lint clean. --- .../06-stripped-gate/challenge.json | 7 + .../challenges/06-stripped-gate/source.c | 23 + .../rveng/challenges/06-stripped-gate/target | Bin 0 -> 14416 bytes .../rveng/docs/research/05-static-analysis.md | 191 ++ .../advanced/rveng/docs/research/INDEX.md | 1 + .../advanced/rveng/frontend/.dockerignore | 15 + PROJECTS/advanced/rveng/frontend/.env.example | 6 + PROJECTS/advanced/rveng/frontend/.gitignore | 25 + .../advanced/rveng/frontend/.stylelintignore | 22 + PROJECTS/advanced/rveng/frontend/biome.json | 94 + PROJECTS/advanced/rveng/frontend/index.html | 48 + PROJECTS/advanced/rveng/frontend/package.json | 50 + .../advanced/rveng/frontend/pnpm-lock.yaml | 2603 +++++++++++++++++ .../public/assets/android-chrome-192x192.png | Bin 0 -> 21898 bytes .../public/assets/android-chrome-512x512.png | Bin 0 -> 112195 bytes .../public/assets/apple-touch-icon.png | Bin 0 -> 19669 bytes .../frontend/public/assets/favicon-16x16.png | Bin 0 -> 454 bytes .../frontend/public/assets/favicon-32x32.png | Bin 0 -> 1136 bytes .../rveng/frontend/public/assets/favicon.ico | Bin 0 -> 15406 bytes .../frontend/public/assets/site.webmanifest | 1 + PROJECTS/advanced/rveng/frontend/src/App.tsx | 36 + .../advanced/rveng/frontend/src/api/client.ts | 107 + .../rveng/frontend/src/api/hooks/index.ts | 119 + .../advanced/rveng/frontend/src/api/index.ts | 7 + .../rveng/frontend/src/api/types/index.ts | 136 + .../src/components/AnswerRunner.module.scss | 77 + .../frontend/src/components/AnswerRunner.tsx | 75 + .../src/components/CfgPane.module.scss | 95 + .../rveng/frontend/src/components/CfgPane.tsx | 79 + .../src/components/ChallengeCard.module.scss | 60 + .../frontend/src/components/ChallengeCard.tsx | 30 + .../src/components/DisasmPane.module.scss | 133 + .../frontend/src/components/DisasmPane.tsx | 112 + .../src/components/HexViewer.module.scss | 108 + .../frontend/src/components/HexViewer.tsx | 122 + .../src/components/MissionPanel.module.scss | 65 + .../frontend/src/components/MissionPanel.tsx | 36 + .../src/components/ProgressBar.module.scss | 33 + .../frontend/src/components/ProgressBar.tsx | 31 + .../src/components/SectionMap.module.scss | 67 + .../frontend/src/components/SectionMap.tsx | 48 + .../src/components/SourceReveal.module.scss | 39 + .../frontend/src/components/SourceReveal.tsx | 18 + .../src/components/StringsPane.module.scss | 74 + .../frontend/src/components/StringsPane.tsx | 49 + .../src/components/Workspace.module.scss | 104 + .../frontend/src/components/Workspace.tsx | 139 + .../rveng/frontend/src/components/index.tsx | 16 + .../advanced/rveng/frontend/src/config.ts | 102 + .../rveng/frontend/src/core/api/api.config.ts | 22 + .../rveng/frontend/src/core/api/errors.ts | 116 + .../rveng/frontend/src/core/api/index.ts | 8 + .../frontend/src/core/api/query.config.ts | 93 + .../rveng/frontend/src/core/app/routers.tsx | 28 + .../frontend/src/core/app/shell.module.scss | 62 + .../rveng/frontend/src/core/app/shell.tsx | 44 + .../frontend/src/core/app/toast.module.scss | 4 + .../rveng/frontend/src/core/lib/index.ts | 6 + .../frontend/src/core/lib/shell.ui.store.ts | 63 + .../advanced/rveng/frontend/src/lib/format.ts | 48 + .../rveng/frontend/src/lib/session.ts | 35 + PROJECTS/advanced/rveng/frontend/src/main.tsx | 15 + .../src/pages/browser/browser.module.scss | 44 + .../frontend/src/pages/browser/index.tsx | 53 + .../frontend/src/pages/workspace/index.tsx | 28 + .../src/pages/workspace/workspace.module.scss | 24 + .../advanced/rveng/frontend/src/styles.scss | 31 + .../rveng/frontend/src/styles/_fonts.scss | 9 + .../rveng/frontend/src/styles/_index.scss | 8 + .../rveng/frontend/src/styles/_mixins.scss | 42 + .../rveng/frontend/src/styles/_reset.scss | 75 + .../rveng/frontend/src/styles/_tokens.scss | 85 + .../rveng/frontend/stylelint.config.js | 107 + .../advanced/rveng/frontend/tsconfig.app.json | 31 + .../advanced/rveng/frontend/tsconfig.json | 7 + .../rveng/frontend/tsconfig.node.json | 24 + .../advanced/rveng/frontend/vite.config.ts | 70 + PROJECTS/advanced/rveng/src/rveng/api/app.py | 109 +- .../advanced/rveng/src/rveng/api/limits.py | 4 + .../rveng/src/rveng/api/middleware.py | 63 + .../advanced/rveng/src/rveng/api/schemas.py | 43 +- .../advanced/rveng/src/rveng/api/store.py | 57 +- .../advanced/rveng/src/rveng/engine/cfg.py | 106 + .../advanced/rveng/src/rveng/engine/disasm.py | 54 +- .../rveng/src/rveng/engine/discover.py | 41 + .../advanced/rveng/src/rveng/engine/plt.py | 101 + .../advanced/rveng/src/rveng/engine/xref.py | 55 + PROJECTS/advanced/rveng/tests/conftest.py | 10 + .../rveng/tests/fixtures/gate_stripped | Bin 0 -> 14416 bytes PROJECTS/advanced/rveng/tests/test_api.py | 126 +- PROJECTS/advanced/rveng/tests/test_cfg.py | 45 + .../advanced/rveng/tests/test_discover.py | 42 + PROJECTS/advanced/rveng/tests/test_plt.py | 51 + PROJECTS/advanced/rveng/tests/test_xref.py | 40 + 94 files changed, 7286 insertions(+), 46 deletions(-) create mode 100644 PROJECTS/advanced/rveng/challenges/06-stripped-gate/challenge.json create mode 100644 PROJECTS/advanced/rveng/challenges/06-stripped-gate/source.c create mode 100755 PROJECTS/advanced/rveng/challenges/06-stripped-gate/target create mode 100644 PROJECTS/advanced/rveng/docs/research/05-static-analysis.md create mode 100644 PROJECTS/advanced/rveng/frontend/.dockerignore create mode 100644 PROJECTS/advanced/rveng/frontend/.env.example create mode 100644 PROJECTS/advanced/rveng/frontend/.gitignore create mode 100644 PROJECTS/advanced/rveng/frontend/.stylelintignore create mode 100644 PROJECTS/advanced/rveng/frontend/biome.json create mode 100644 PROJECTS/advanced/rveng/frontend/index.html create mode 100644 PROJECTS/advanced/rveng/frontend/package.json create mode 100644 PROJECTS/advanced/rveng/frontend/pnpm-lock.yaml create mode 100644 PROJECTS/advanced/rveng/frontend/public/assets/android-chrome-192x192.png create mode 100644 PROJECTS/advanced/rveng/frontend/public/assets/android-chrome-512x512.png create mode 100644 PROJECTS/advanced/rveng/frontend/public/assets/apple-touch-icon.png create mode 100644 PROJECTS/advanced/rveng/frontend/public/assets/favicon-16x16.png create mode 100644 PROJECTS/advanced/rveng/frontend/public/assets/favicon-32x32.png create mode 100644 PROJECTS/advanced/rveng/frontend/public/assets/favicon.ico create mode 100644 PROJECTS/advanced/rveng/frontend/public/assets/site.webmanifest create mode 100644 PROJECTS/advanced/rveng/frontend/src/App.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/api/client.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/api/hooks/index.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/api/index.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/api/types/index.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/AnswerRunner.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/AnswerRunner.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/CfgPane.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/CfgPane.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/ChallengeCard.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/ChallengeCard.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/DisasmPane.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/DisasmPane.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/HexViewer.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/HexViewer.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/MissionPanel.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/MissionPanel.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/ProgressBar.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/ProgressBar.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/SectionMap.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/SectionMap.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/SourceReveal.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/SourceReveal.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/StringsPane.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/StringsPane.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/Workspace.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/Workspace.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/components/index.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/config.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/api/api.config.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/api/errors.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/api/index.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/api/query.config.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/app/routers.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/app/shell.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/app/shell.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/app/toast.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/lib/index.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/core/lib/shell.ui.store.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/lib/format.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/lib/session.ts create mode 100644 PROJECTS/advanced/rveng/frontend/src/main.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/pages/browser/browser.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/pages/browser/index.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/pages/workspace/index.tsx create mode 100644 PROJECTS/advanced/rveng/frontend/src/pages/workspace/workspace.module.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/styles.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/styles/_fonts.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/styles/_index.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/styles/_mixins.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/styles/_reset.scss create mode 100644 PROJECTS/advanced/rveng/frontend/src/styles/_tokens.scss create mode 100644 PROJECTS/advanced/rveng/frontend/stylelint.config.js create mode 100644 PROJECTS/advanced/rveng/frontend/tsconfig.app.json create mode 100644 PROJECTS/advanced/rveng/frontend/tsconfig.json create mode 100644 PROJECTS/advanced/rveng/frontend/tsconfig.node.json create mode 100644 PROJECTS/advanced/rveng/frontend/vite.config.ts create mode 100644 PROJECTS/advanced/rveng/src/rveng/api/middleware.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/cfg.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/discover.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/plt.py create mode 100644 PROJECTS/advanced/rveng/src/rveng/engine/xref.py create mode 100755 PROJECTS/advanced/rveng/tests/fixtures/gate_stripped create mode 100644 PROJECTS/advanced/rveng/tests/test_cfg.py create mode 100644 PROJECTS/advanced/rveng/tests/test_discover.py create mode 100644 PROJECTS/advanced/rveng/tests/test_plt.py create mode 100644 PROJECTS/advanced/rveng/tests/test_xref.py diff --git a/PROJECTS/advanced/rveng/challenges/06-stripped-gate/challenge.json b/PROJECTS/advanced/rveng/challenges/06-stripped-gate/challenge.json new file mode 100644 index 00000000..ac6c9c4a --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/06-stripped-gate/challenge.json @@ -0,0 +1,7 @@ +{ + "id": "06-stripped-gate", + "module": "disassembly", + "title": "Gate in the dark", + "mission": "This binary is stripped: no symbol table, so there are no function names to look up. Discover the functions by their prologue, disassemble the one that checks a number, and read the constant it compares against. Answer in decimal or hex.", + "answer": { "category": "found_value", "expected": 1337 } +} diff --git a/PROJECTS/advanced/rveng/challenges/06-stripped-gate/source.c b/PROJECTS/advanced/rveng/challenges/06-stripped-gate/source.c new file mode 100644 index 00000000..1c42d31b --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/06-stripped-gate/source.c @@ -0,0 +1,23 @@ +#include +#include + +int check(int n) { + if (n == 1337) { + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + char *secret = "the_flag_is_here"; + int n = 0; + if (argc > 1) { + n = atoi(argv[1]); + } + if (check(n)) { + printf("unlocked: %s\n", secret); + } else { + printf("wrong number\n"); + } + return 0; +} diff --git a/PROJECTS/advanced/rveng/challenges/06-stripped-gate/target b/PROJECTS/advanced/rveng/challenges/06-stripped-gate/target new file mode 100755 index 0000000000000000000000000000000000000000..602810968153700212a4848bef59d31c006723ce GIT binary patch literal 14416 zcmeHOU2IfE6rSBuy8N_VM6gnRE(Q`bblXB{pai&3OD{^a@+UsvcH7-{H@3UY?pDS|a*jNSu@0 z`^`CXzBzNxy)*kWb6=_3zSZmT2o|r{C{Szko6@AtLNruLm27K8kub$VQ7Wc`Wf~l& ztU**IMt`2FVDyn6nvr*c>QCgEvW9V?)C9y(!aYPp-W@Tis4$wZR7T-fI`U){>4L70 z@dgE8bn;|8L$xCeQxyoe6OYl!!`w`iDffvDipN;tRra-n%tIAL!P01LgsZ4uY-7u%gB!r{ZW{=a;ef+E;VI=(NN}GJm&4Z zYKO7>Py5+Gyb5Z#g7VK;Me!Ip-gCgieBQ6{5`NC(v@sz}mU;ePXE!I?BRo)gZYm3Fxon@VC ztIMjZ0;xn`m23yw;`X3;Uos249-6`v+nLI~=pObuup4l=1#>)AJE9|Pr$oq3#6&O% z*VPRKV^+{(yXFz8_XFz8_XFz8_XFzA*LCApBbKPeh%m1=m2`5daU%fEng@e&hCs7JAWY&Z+D{5Yt#FUzNxDK(D;9Epz2X zEAy?@Gcw+=tFE%I@{%<$5S{}~&3hhPe{KmBFL@2ylkN9A)hC5Gy8^yYTO06f&pnG? z*v%QAp&>im(%awSB$(n;>3`&=gv_{g?&c=z+(e$`xoBOvZO_TIP(UrX`9BpezIAv* zA#_Zn=kB&LS4R)RRb0&f9ez-F_VxcI9{OK9ijJA?$s^s9>)=a&y5KD4<66Fqwyz&+ zAknN9GY((s~fg@`rvjz-N5Kax3k$y-Q=;l zNl@N}PQZ090-td!a}k=@E1MXQAx8Iu@3msEHYn@pZZOEIOK>aYJ-+L%Vo)tQ13Cjb z13Cjb13Cjb13Cjb13Cjb13Cl$rwriwnH`M;TiQac!B{F7jU*!?9dAoCzZ41A7|T;L z#erlZ-fG0t?M;!S@Ra7Q$2C@5+Zz8qo9zXx7|&)K02={c2Auzc`tex>I0QNhu6d$( z4(<@1PQRygc7dz*2au_K)p{QKeht37c86#hYn+Yh2{9B7k zo+_Soz;{?|TCo0!)yrhF$lD9Y9_Zs@^xItIf5Tf_IQ<3a2&h=!3CAv|KLnVo_jV}< zFyXo&_7`L0+ARvM{dr|cpL7Ov26P5=26P5=26P5=26P5=26P4<%nX>Y0E^-$bpD<~ z`)oX+&_3xxrHK*pf0T6I&s<76?<-zFy4YyS68^qI;rd^0XA`KO$}aN+)z3r)WS0Vg`&m&+6o`3*47?|El=gM_%|l*>qL1(fiUt-Sz`M*^@sb7 zzco={lc^Mr59_V+diuy8)@!BCd4->qQa?@gME}o#_m8^+R@436O1Ov6?N?u0TVt$v zGSU84al1ez1=?UA_c&e^}w@ws;HHEy8ipZCifu15K}WHbJ^;pFo^e%{}Y|Ccxg z|I=^^zUG0?ERPp{AA{f1;Bm|xe!f{>2ZdkZS!X_v?^84&na_Kp-TkixKIVY=JpKoX z-%Ej}17d9E^EiAOZ0O2-o=+O-LV5lG7b))k?*uE3UFP$6JxzR`7rFoJ$JhvboC{dz z`9Qt|0ChZnxcd)PQgD*MP|WA~XPn|QpX0l)zl-==WNRWfpBY53$c`$`d2W6W*f1y9 zKPfyP_?61@B-P5kav2f-J9F|4;v2*_(a9lX&h6jNK%JA{Nc=`}eARWsn9ohV<>K?_ z)yN#>!SgozQ(9RSZxf&Mf9HK4In*r|^3#Re!_(z{QUmres773F3l+ z+?nqm8_qz%IsW;59DYy7U`IlC|NFrXdx+#1LKpsg=l5XT@mOcP44h&IpFjWQ3mIT% zKPXCJo$)KM; + + +# Static Analysis Depth Research + +The M3.5 depth features that lift rveng from a hex-and-disasm viewer to a real +static reverse-engineering workbench: import resolution through the PLT, cross +references with RIP-relative operand math, control-flow-graph reconstruction, +and function discovery in stripped binaries. Every offset, opcode, and address +below is traced to the sample `gate` binary (`gcc -no-pie -fno-stack-protector +-O0`) and its stripped copy `gate_stripped` (`strip gate`), captured with +`readelf` and `objdump -M intel`. Nothing here executes a binary: PLT, GOT, and +relocation tables are inspected as data, exactly like the header and sections. + +## Import resolution through the PLT + +A dynamically linked call does not jump straight to libc. It jumps to a small +stub in `.plt` that reads a pointer from `.got.plt`, which the dynamic linker +fills in. To name a call the way objdump prints `call 401030 `, the +engine resolves the stub back to the imported symbol. + +Sections that carry the mapping, from `readelf -SW gate`: + +- `.plt` PROGBITS at `0x401020`, entry size `0x10` (16 bytes). +- `.got.plt` PROGBITS at `0x403fe8`, entry size 8. +- `.rela.plt` RELA at `0x400520`, entry size `0x18` (24 bytes), 3 entries. +- `.dynsym` DYNSYM at `0x4003d0`, entry size `0x18`, its linked string table is + `.dynstr`. + +The `.plt` layout from `objdump -d -j .plt`: + +``` +401020 : push/jmp/nop the PLT0 resolver stub (not an import) +401030 : ff 25 ca 2f 00 00 jmp QWORD PTR [rip+0x2fca] -> 404000 +401040 : ff 25 c2 2f 00 00 jmp QWORD PTR [rip+0x2fc2] -> 404008 +401050 : ff 25 ba 2f 00 00 jmp QWORD PTR [rip+0x2fba] -> 404010 +``` + +The first `.plt` slot (`0x401020`) is the resolver trampoline (PLT0), not an +import. Real entries begin at `0x401030` and step by 16. + +Resolution algorithm, robust across PIE and non-PIE: + +1. For a PLT entry, decode its leading `jmp QWORD PTR [rip+disp32]` (opcode + `ff 25`). The GOT slot it reads is `entry_addr + 6 + disp32`. For `0x401030`: + `0x401036 + 0x2fca = 0x404000`. +2. `.rela.plt` maps each GOT slot to a dynamic symbol. Each `Elf64_Rela` is + `{ r_offset: u64, r_info: u64, r_addend: i64 }`. The symbol index is + `r_info >> 32`; the type is `r_info & 0xffffffff` (7 = `R_X86_64_JUMP_SLOT`). + From `readelf -rW`: + + ``` + r_offset 0x404000 sym 2 JUMP_SLOT puts + r_offset 0x404008 sym 3 JUMP_SLOT printf + r_offset 0x404010 sym 5 JUMP_SLOT atoi + ``` +3. Look the symbol index up in `.dynsym` (same 24-byte `Elf64_Sym` layout as + `.symtab`, names via `.dynstr`) to get `puts`, `printf`, `atoi`. + +So `0x401030 -> 0x404000 -> dynsym[2] -> puts`. A `call` whose target is a `.plt` +entry is then annotated with that name. + +KAT (call-site to import, from `main` in `objdump -d`): + +- `401199: call 401050` resolves to `atoi`. +- `4011a6: call 401146` is `check`, a local function, not a PLT entry. +- `4011c5: call 401040` resolves to `printf`. +- `4011d6: call 401030` resolves to `puts`. + +VERIFIED, and load-bearing for the stripped module: `.plt`, `.got.plt`, +`.rela.plt`, and `.dynsym` all SURVIVE `strip`. `readelf -SW gate_stripped` +still shows them; `readelf -rW gate_stripped` still lists the three JUMP_SLOT +relocations to `puts`, `printf`, `atoi`. Stripping removes `.symtab` and +`.strtab` (local names like `check`, `main`), never the dynamic-linking tables. +A stripped binary can still name its library calls. This is a real teaching +point, not an accident. + +Robustness note an adversarial check flagged: binaries built with +`-fcf-protection` (default on many modern toolchains) grow an `endbr64` and a +`.plt.sec` section, so the `jmp [rip+x]` may live in `.plt.sec` rather than +`.plt`. Resolving by decoding the stub's `jmp [rip+disp]` wherever it sits (and +matching the GOT slot against `.rela.plt`) handles both layouts; resolving by +the PLT entry's pushed relocation index does not. The engine decodes the jmp. +Our curated `-no-pie -O0` samples use the classic single `.plt`. + +## RIP-relative cross references + +x86-64 addresses data PC-relative. `lea rax,[rip+disp]` computes an absolute +address as `address_of_next_instruction + disp`, where the next-instruction +address is `insn.address + insn.size`. This is how a function points at a string +without a relocation in a non-PIE binary. + +KAT (from `main`, `objdump -d -j .text`): + +- `401173: lea rax,[rip+0xe8a]` with size 7 resolves to `0x40117a + 0xe8a = + 0x402004`, the `.rodata` string `the_flag_is_here`. +- `4011b6: lea rax,[rip+0xe58]` (size 7) resolves to `0x4011bd + 0xe58 = + 0x402015`, the `unlocked: %s` format string. +- `4011cc: lea rax,[rip+0xe50]` (size 7) resolves to `0x4011d3 + 0xe50 = + 0x402023`, the `wrong number` string. + +capstone exposes the displacement on the memory operand whose base register is +RIP. The engine computes `insn.address + insn.size + disp` and, when that +address falls inside a known section, labels the reference (a `.rodata` hit is a +string; a `.text` hit is code). + +Cross references are the inverse index: for a target address, the list of +instructions that reach it. Two reference kinds cover these challenges: + +- control-flow refs, from `call`/`jmp`/`jcc` branch targets. Example: `check` at + `0x401146` is referenced by `4011a6: call 401146` in `main`. +- data refs, from resolved RIP-relative operands. Example: the flag string at + `0x402004` is referenced by `401173: lea` in `main`. + +"What calls this function" and "what reads this string" are the two questions a +learner asks constantly; both are this same reverse map. + +## Control-flow graph reconstruction + +A basic block is a straight run of instructions with one entry and one exit: no +branch lands in the middle, no branch leaves except at the end. The standard +leader algorithm: + +1. The first instruction of the function is a leader. +2. Any branch target inside the function is a leader. +3. The instruction following any conditional or unconditional jump is a leader. + +A block runs from a leader up to the instruction before the next leader. `call` +does NOT end a block: it returns, so control falls through. Blocks end at +conditional jumps, unconditional jumps, and `ret`. This matches how IDA and +Ghidra draw function graphs. + +KAT: `check` (`0x401146`..`0x401163`), from `objdump -d`: + +``` +401146 push rbp | B0 (entry) +401147 mov rbp,rsp | +40114a mov [rbp-4],edi | +40114d cmp [rbp-4],0x539 | +401154 jne 40115d | -> ends B0 +401156 mov eax,1 | B1 (fallthrough of jne) +40115b jmp 401162 | -> ends B1 +40115d mov eax,0 | B2 (jne taken target) +401162 pop rbp | B3 (jmp target, also B2 fallthrough) +401163 ret | -> ends B3 +``` + +Leaders: `0x401146` (start), `0x401156` (after the `jne`), `0x40115d` (the `jne` +target), `0x401162` (the `jmp` target). Four basic blocks. Edges: + +- B0 ends in `jne`: to B2 (taken, `0x40115d`) and to B1 (fallthrough, `0x401156`). +- B1 ends in `jmp`: to B3 (`0x401162`). +- B2 ends by fallthrough (its successor `0x401162` is a leader): to B3. +- B3 ends in `ret`: no successors. + +A clean diamond, which is exactly the shape that teaches "the gate splits the +flow and both sides rejoin". Edge kinds worth labeling for the learner: taken, +fallthrough, unconditional. + +## Function discovery in stripped binaries + +`strip` removes `.symtab`, so `check` and `main` have no names and the symbol +table is empty. Disassembly must start from something other than a symbol. + +For the curated `-O0` challenges, every C function opens with the frame-pointer +prologue `push rbp; mov rbp,rsp`, bytes `55 48 89 e5`. Scanning executable +sections for that pattern recovers function entry points. Verified in +`gate_stripped`: `0x401146` (was `check`) and `0x401164` (was `main`) both begin +`55 48 89 e5`; `_start` at `0x401060` does not (it has a different prologue), so +the scan cleanly separates user functions from the runtime start-up code. + +Honest limits an adversarial check must record: this heuristic only finds +functions that keep a frame pointer. Anything built with `-O2` +(frame-pointer-omitted), or hand-written assembly, will be missed; real tools +add call-target recovery, symbol hints, and unwind-table (`.eh_frame`) parsing. +For rveng's curated, frame-pointer-preserving samples the prologue scan is +sufficient and honest, and it is the right first lesson in "how do you even find +a function when the names are gone". The stripped challenge disassembles a +discovered region by address, never by name, and grades a found-value answer, so +no symbol is ever leaked. + +## Facts pulled forward for the KATs + +- PLT entries: `0x401030 -> puts`, `0x401040 -> printf`, `0x401050 -> atoi`; + GOT slots `0x404000/8/10`; `.rela.plt` symbol index is `r_info >> 32`. +- RIP-relative: `target = insn.address + insn.size + disp`; `0x401173 lea -> + 0x402004` (the flag string). +- `check` CFG: 4 basic blocks, leaders `0x401146/0x401156/0x40115d/0x401162`, + diamond edges as above. +- Stripped discovery: prologue `55 48 89 e5` finds `check` and `main`, not + `_start`; `.dynsym`/`.rela.plt`/`.plt` survive `strip` so imports stay named. diff --git a/PROJECTS/advanced/rveng/docs/research/INDEX.md b/PROJECTS/advanced/rveng/docs/research/INDEX.md index 098d099c..52c3c015 100644 --- a/PROJECTS/advanced/rveng/docs/research/INDEX.md +++ b/PROJECTS/advanced/rveng/docs/research/INDEX.md @@ -23,6 +23,7 @@ binaries safe, and it is a hard constraint, not a preference. | `02-x86-64-capstone.md` | implementing `engine/disasm.py`: the minimal x86-64 instruction set these challenges use, the cmp/jcc gate pattern, capstone 5.0.9 API, the objdump-vs-capstone byte-for-byte agreement KAT, the Intel-syntax constant correction | | `03-pedagogy.md` | implementing `engine/challenge.py` and the runner: the solve-then-reveal loop, the three answer-spec categories (found-value, identified-symbol, patched-bytes), module-to-category mapping, grading rules | | `04-no-execution-posture.md` | any API or engine work touching binaries: the enumeration proving every operation is read-only, why patch grading is a static diff, the API input constraints, the standing no-execution rule | +| `05-static-analysis.md` | implementing the M3.5 depth engine (`plt.py`, `xref.py`, `cfg.py`, stripped discovery): PLT/GOT/.rela.plt/.dynsym import resolution, RIP-relative operand math, the basic-block leader algorithm, prologue-scan function discovery, all KAT-traced to `gate` and `gate_stripped` | Key verified facts pulled forward: - ELF header cross-checks exactly to raw bytes: entry `0x401060`, e_shoff diff --git a/PROJECTS/advanced/rveng/frontend/.dockerignore b/PROJECTS/advanced/rveng/frontend/.dockerignore new file mode 100644 index 00000000..a0256ec7 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/.dockerignore @@ -0,0 +1,15 @@ +node_modules +build +dist +.git +.gitignore +*.md +.env* +.vscode +.idea +*.log +npm-debug.log* +pnpm-debug.log* +.DS_Store +coverage +.nyc_output diff --git a/PROJECTS/advanced/rveng/frontend/.env.example b/PROJECTS/advanced/rveng/frontend/.env.example new file mode 100644 index 00000000..f2f18850 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/.env.example @@ -0,0 +1,6 @@ +# ©AngelaMos | 2026 +# .env.example + +# Backend the Vite dev server proxies /api to. +# Run the API with: uv run uvicorn rveng.api.server:app --port 8000 +VITE_API_TARGET=http://localhost:8000 diff --git a/PROJECTS/advanced/rveng/frontend/.gitignore b/PROJECTS/advanced/rveng/frontend/.gitignore new file mode 100644 index 00000000..61cb0c2e --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.vite + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/PROJECTS/advanced/rveng/frontend/.stylelintignore b/PROJECTS/advanced/rveng/frontend/.stylelintignore new file mode 100644 index 00000000..37da03e1 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/.stylelintignore @@ -0,0 +1,22 @@ +# ©AngelaMos | 2025 +# .stylelintignore + +# Dependencies +node_modules/ + +# Production builds +dist/ +build/ +out/ + +# JS/TS files +**/*.js +**/*.jsx +**/*.ts +**/*.tsx + +# Generated files +*.min.css + +# Error system styles - ignore from linting +src/core/app/_toastStyles.scss diff --git a/PROJECTS/advanced/rveng/frontend/biome.json b/PROJECTS/advanced/rveng/frontend/biome.json new file mode 100644 index 00000000..7f29029a --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/biome.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 82, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded", + "trailingCommas": "es5", + "arrowParentheses": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 25 } + }, + "noForEach": "off", + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "noUndeclaredVariables": "error" + }, + "style": { + "useImportType": "error", + "useConst": "error", + "useTemplate": "error", + "useSelfClosingElements": "error", + "useFragmentSyntax": "error", + "noNonNullAssertion": "error", + "useConsistentArrayType": { + "level": "error", + "options": { "syntax": "shorthand" } + }, + "useNamingConvention": "off" + }, + "suspicious": { + "noExplicitAny": "error", + "noDebugger": "error", + "noConsole": "warn", + "noArrayIndexKey": "warn", + "noAssignInExpressions": "error", + "noDoubleEquals": "error", + "noRedeclare": "error", + "noVar": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "error" + }, + "a11y": { + "useAltText": "error", + "useAnchorContent": "error", + "useKeyWithClickEvents": "error", + "noStaticElementInteractions": "error", + "useButtonType": "error", + "useValidAnchor": "error" + } + } + }, + "overrides": [ + { + "includes": ["src/main.tsx"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] +} diff --git a/PROJECTS/advanced/rveng/frontend/index.html b/PROJECTS/advanced/rveng/frontend/index.html new file mode 100644 index 00000000..840bb5f5 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/index.html @@ -0,0 +1,48 @@ + + + + + + + + + + + rveng - reverse-engineering lab + + + + +
+ + + diff --git a/PROJECTS/advanced/rveng/frontend/package.json b/PROJECTS/advanced/rveng/frontend/package.json new file mode 100644 index 00000000..b7d1721b --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "rveng-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc -b", + "lint:scss": "stylelint '**/*.scss'", + "lint:scss:fix": "stylelint '**/*.scss' --fix" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.12", + "axios": "^1.13.0", + "react": "^19.2.1", + "react-dom": "^19.2.0", + "react-error-boundary": "^6.0.0", + "react-icon": "^1.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.1.1", + "sonner": "^2.0.7", + "zod": "^4.1.13", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.8", + "@tanstack/react-query-devtools": "^5.91.1", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "sass": "^1.95.0", + "stylelint": "^16.26.1", + "stylelint-config-prettier-scss": "^1.0.0", + "stylelint-config-standard-scss": "^16.0.0", + "typescript": "~5.9.3", + "vite": "npm:rolldown-vite@7.2.5", + "vite-tsconfig-paths": "^5.1.0" + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.2.5" + } + } +} diff --git a/PROJECTS/advanced/rveng/frontend/pnpm-lock.yaml b/PROJECTS/advanced/rveng/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..d04a7163 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/pnpm-lock.yaml @@ -0,0 +1,2603 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:rolldown-vite@7.2.5 + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.12(react@19.2.1) + axios: + specifier: ^1.13.0 + version: 1.13.2 + react: + specifier: ^19.2.1 + version: 19.2.1 + react-dom: + specifier: ^19.2.0 + version: 19.2.1(react@19.2.1) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@19.2.1) + react-icon: + specifier: ^1.0.0 + version: 1.0.0(babel-runtime@5.8.38)(react@19.2.1) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.2.1) + react-router-dom: + specifier: ^7.1.1 + version: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + zod: + specifier: ^4.1.13 + version: 4.1.13 + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.2.7)(react@19.2.1) + devDependencies: + '@biomejs/biome': + specifier: ^2.3.8 + version: 2.3.8 + '@tanstack/react-query-devtools': + specifier: ^5.91.1 + version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1) + '@types/node': + specifier: ^24.10.2 + version: 24.10.2 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + sass: + specifier: ^1.95.0 + version: 1.95.0 + stylelint: + specifier: ^16.26.1 + version: 16.26.1(typescript@5.9.3) + stylelint-config-prettier-scss: + specifier: ^1.0.0 + version: 1.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard-scss: + specifier: ^16.0.0 + version: 16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.2.5 + version: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + vite-tsconfig-paths: + specifier: ^5.1.0 + version: 5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.3.8': + resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.8': + resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.8': + resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.8': + resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.8': + resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.8': + resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.8': + resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.8': + resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.8': + resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.0.6': + resolution: {integrity: sha512-7e8SScMocHxcAb8YhtkbMhGG+EKLRIficb1F5sjvhSYsWTZGxvg4KIDp8kgxnV2PUJ3ddPe6J9QESjKvBWRDkg==} + + '@cacheable/utils@2.3.2': + resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==} + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': + resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.0': + resolution: {integrity: sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.5.4 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@napi-rs/wasm-runtime@1.1.0': + resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/runtime@0.97.0': + resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.97.0': + resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.50': + resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} + + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@tanstack/query-core@5.90.12': + resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} + + '@tanstack/query-devtools@5.91.1': + resolution: {integrity: sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==} + + '@tanstack/react-query-devtools@5.91.1': + resolution: {integrity: sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.10 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.12': + resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/node@24.10.2': + resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@vitejs/plugin-react@5.1.2': + resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + babel-runtime@5.8.38: + resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + baseline-browser-mapping@2.9.5: + resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} + hasBin: true + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.3.0: + resolution: {integrity: sha512-HHiAvOBmlcR2f3SQ7kdlYD8+AUJG+wlFZ/Ze8tl1Vzvz0MdOh8IYA/EFU4ve8t1/sZ0j4MGi7ST5MoTwHessQA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001759: + resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js@1.2.7: + resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-functions-list@3.2.3: + resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} + engines: {node: '>=12 || >=16'} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.1: + resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.19: + resolution: {integrity: sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.3.0: + resolution: {integrity: sha512-fWltioiy5zsSAs9ouEnvhsVJeAXRybGCNNv0lvzpzNOSDbULXRy7ivFWwCCv4I5Am6kSo75hmbsCduOoc2/K4w==} + engines: {node: '>=20'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookified@1.14.0: + resolution: {integrity: sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.5.5: + resolution: {integrity: sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + mdn-data@2.25.0: + resolution: {integrity: sha512-T2LPsjgUE/tgMmRXREVmwsux89DwWfNjiynOeXuLd2mX6jphGQ2YE3Ukz7LQ2VOFKiVZU/Ee1GqzHiipZCjymw==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + qified@0.5.3: + resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} + engines: {node: '>=20'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.1: + resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + peerDependencies: + react: ^19.2.1 + + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + + react-icon@1.0.0: + resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==} + peerDependencies: + babel-runtime: ^5.3.3 + react: '>=0.12.0' + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.10.1: + resolution: {integrity: sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.10.1: + resolution: {integrity: sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown-vite@7.2.5: + resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.50: + resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.95.0: + resolution: {integrity: sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==} + engines: {node: '>=14.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stylelint-config-prettier-scss@1.0.0: + resolution: {integrity: sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==} + engines: {node: 14.* || 16.* || >= 18} + hasBin: true + peerDependencies: + stylelint: '>=15.0.0' + + stylelint-config-recommended-scss@16.0.2: + resolution: {integrity: sha512-aUTHhPPWCvFyWaxtckJlCPaXTDFsp4pKO8evXNCsW9OwsaUWyMd6jvcUhSmfGWPrTddvzNqK4rS/UuSLcbVGdQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.24.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@17.0.0: + resolution: {integrity: sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-config-standard-scss@16.0.0: + resolution: {integrity: sha512-/FHECLUu+med/e6OaPFpprG86ShC4SYT7Tzb2PTVdDjJsehhFBOioSlWqYFqJxmGPIwO3AMBxNo+kY3dxrbczA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.23.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@39.0.1: + resolution: {integrity: sha512-b7Fja59EYHRNOTa3aXiuWnhUWXFU2Nfg6h61bLfAb5GS5fX3LMUD0U5t4S8N/4tpHQg3Acs2UVPR9jy2l1g/3A==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-scss@6.13.0: + resolution: {integrity: sha512-kZPwFUJkfup2gP1enlrS2h9U5+T5wFoqzJ1n/56AlpwSj28kmFe7ww/QFydvPsg5gLjWchAwWWBLtterynZrOw==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.8.2 + + stylelint@16.26.1: + resolution: {integrity: sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==} + engines: {node: '>=18.12.0'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@biomejs/biome@2.3.8': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.8 + '@biomejs/cli-darwin-x64': 2.3.8 + '@biomejs/cli-linux-arm64': 2.3.8 + '@biomejs/cli-linux-arm64-musl': 2.3.8 + '@biomejs/cli-linux-x64': 2.3.8 + '@biomejs/cli-linux-x64-musl': 2.3.8 + '@biomejs/cli-win32-arm64': 2.3.8 + '@biomejs/cli-win32-x64': 2.3.8 + + '@biomejs/cli-darwin-arm64@2.3.8': + optional: true + + '@biomejs/cli-darwin-x64@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64@2.3.8': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-x64@2.3.8': + optional: true + + '@biomejs/cli-win32-arm64@2.3.8': + optional: true + + '@biomejs/cli-win32-x64@2.3.8': + optional: true + + '@cacheable/memory@2.0.6': + dependencies: + '@cacheable/utils': 2.3.2 + '@keyv/bigmap': 1.3.0(keyv@5.5.5) + hookified: 1.14.0 + keyv: 5.5.5 + + '@cacheable/utils@2.3.2': + dependencies: + hashery: 1.3.0 + keyv: 5.5.5 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@dual-bundle/import-meta-resolve@4.2.1': {} + + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.0(keyv@5.5.5)': + dependencies: + hashery: 1.3.0 + hookified: 1.14.0 + keyv: 5.5.5 + + '@keyv/serialize@1.1.1': {} + + '@napi-rs/wasm-runtime@1.1.0': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@oxc-project/runtime@0.97.0': {} + + '@oxc-project/types@0.97.0': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + dependencies: + '@napi-rs/wasm-runtime': 1.1.0 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.50': {} + + '@rolldown/pluginutils@1.0.0-beta.53': {} + + '@tanstack/query-core@5.90.12': {} + + '@tanstack/query-devtools@5.91.1': {} + + '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1)': + dependencies: + '@tanstack/query-devtools': 5.91.1 + '@tanstack/react-query': 5.90.12(react@19.2.1) + react: 19.2.1 + + '@tanstack/react-query@5.90.12(react@19.2.1)': + dependencies: + '@tanstack/query-core': 5.90.12 + react: 19.2.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/node@24.10.2': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.53 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-runtime@5.8.38: + dependencies: + core-js: 1.2.7 + + balanced-match@2.0.0: {} + + baseline-browser-mapping@2.9.5: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.5 + caniuse-lite: 1.0.30001759 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.2(browserslist@4.28.1) + + cacheable@2.3.0: + dependencies: + '@cacheable/memory': 2.0.6 + '@cacheable/utils': 2.3.2 + hookified: 1.14.0 + keyv: 5.5.5 + qified: 0.5.3 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001759: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + core-js@1.2.7: {} + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + css-functions-list@3.2.3: {} + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + detect-libc@1.0.3: + optional: true + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@11.1.1: + dependencies: + flat-cache: 6.1.19 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.19: + dependencies: + cacheable: 2.3.0 + flatted: 3.3.3 + hookified: 1.14.0 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.3.0: + dependencies: + hookified: 1.14.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookified@1.14.0: {} + + html-tags@3.3.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-object@5.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + keyv@5.5.5: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lines-and-columns@1.2.4: {} + + lodash.truncate@4.4.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.12.2: {} + + mdn-data@2.25.0: {} + + meow@13.2.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + qified@0.5.3: + dependencies: + hookified: 1.14.0 + + queue-microtask@1.2.3: {} + + react-dom@19.2.1(react@19.2.1): + dependencies: + react: 19.2.1 + scheduler: 0.27.0 + + react-error-boundary@6.0.0(react@19.2.1): + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.1 + + react-icon@1.0.0(babel-runtime@5.8.38)(react@19.2.1): + dependencies: + babel-runtime: 5.8.38 + react: 19.2.1 + + react-icons@5.5.0(react@19.2.1): + dependencies: + react: 19.2.1 + + react-refresh@0.18.0: {} + + react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-router: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + + react-router@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + cookie: 1.1.1 + react: 19.2.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.1(react@19.2.1) + + react@19.2.1: {} + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0): + dependencies: + '@oxc-project/runtime': 0.97.0 + fdir: 6.5.0(picomatch@4.0.3) + lightningcss: 1.30.2 + picomatch: 4.0.3 + postcss: 8.5.6 + rolldown: 1.0.0-beta.50 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.2 + fsevents: 2.3.3 + sass: 1.95.0 + + rolldown@1.0.0-beta.50: + dependencies: + '@oxc-project/types': 0.97.0 + '@rolldown/pluginutils': 1.0.0-beta.50 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-x64': 1.0.0-beta.50 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.50 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.50 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.50 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.50 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.95.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stylelint-config-prettier-scss@1.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-recommended-scss@16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.6) + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-scss: 6.13.0(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-recommended@17.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-standard-scss@16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended-scss: 16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard: 39.0.1(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-standard@39.0.1(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + + stylelint-scss@6.13.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + css-tree: 3.1.0 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.25.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + stylelint: 16.26.1(typescript@5.9.3) + + stylelint@16.26.1(typescript@5.9.3): + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-syntax-patches-for-csstree': 1.0.20 + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + '@dual-bundle/import-meta-resolve': 4.2.1 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + css-functions-list: 3.2.3 + css-tree: 3.1.0 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.1 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 7.0.5 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + supports-hyperlinks: 3.2.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + svg-tags@1.0.0: {} + + table@6.9.0: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.2(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite-tsconfig-paths@5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + - typescript + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + yallist@3.1.1: {} + + zod@4.1.13: {} + + zustand@5.0.9(@types/react@19.2.7)(react@19.2.1): + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.1 diff --git a/PROJECTS/advanced/rveng/frontend/public/assets/android-chrome-192x192.png b/PROJECTS/advanced/rveng/frontend/public/assets/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..188b12551b1620ae587f6c687b7b8e9b60755bdb GIT binary patch literal 21898 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 literal 0 HcmV?d00001 diff --git a/PROJECTS/advanced/rveng/frontend/public/assets/android-chrome-512x512.png b/PROJECTS/advanced/rveng/frontend/public/assets/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..063c71c3d8fe1b51ac1f2bfc8b16e0e0d2c552ac GIT binary patch literal 112195 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®)I1TBcCYlnJuM?`fefXZ%s3 zlj&Fw)PZ6Tjxf}`r60syKo{J^$c zy{bI=7vv1r-N&?WA1nB&Diu-t&vJQgFO2KO#T28CzT&roq0`9%AQ zwfU=8gPuKI>-n>d4qs&rF@S{!@$^y06`!qu~HJrzR&6w9ayOEm+BW_*%LgnHp50k8kC8cxs)0vqDPj+g9u8c z1S)$)^y<2Wx&Y8nM}TFVLI9ees~24(LVVDlkk4DttMa=p&>QMKCS}XM9Uc=qr!mV8 zt;V~a(f(Fg2QtX!Y+Z$Sv{{K2k}(XKquQkPW(M`4sQ%pmhkoi32!W%($1#$daw^9p>wW-zQYIWD?RM%VY) z)+sybOh`G<9U79j@5~9K{r3)L4A|!XDa~u({5lovx`NCI22+zE?%_`%u&=v<9$RZsNXl z9rwBNYOBOND+>K)R9?_#K-%Y%@qnM~Pi8W0BJbvbLUbrrQHpYLSP>io%_i&Qc&%fw zUKgcAFo{lG-zf;j5uph4g5a3t;8OJ{q!|RQZF%NZsyAF{U?Jkh2+qqwXEf50b37(R33cST{bmb{%OqPc&r)bUBC+)aSzL=RJ#|Z;0tr6dxYPe{40~Psk>dq zj;sQaz=zu6vaGg#oB86N7IgdmC2o*0B;SL;&zF(wb0Kv5IS}nG2lXCIJj@?Tsf(NF z7u1$IJChFC{rhis_$^)GL?Af96N|S}Z5DPL0o?`NU;6}{Zi34*V71RD6y+>67n7GJ(KWr2x*_@fonX_UBhdv&(Z?~3IduG>U=CnG|ZHVUphWq>B0S1y7S{p zJ$dW^BLWl0`5Z5HeuDh_0LTguBMCktuxh-l)2KDZK&BSAUo+2i%e=mTNCaO~Ys5v_ ztcXy=yTiKvegs_%NS&|CBpMt@ZonT!iG|S?9OX#+Uh z$;QZZG%fJvqHXzVo9k~yO@xL1GxAiSP+Y>9nA8AH}$vfgzt?)C8IP$T{ddnyb0zN)f|`kBjO;fU=XmF`otuo1!!X{thLciNw!d=4WPvMq8O=#!m5bT4+B#$@RDW@BDD6 zhxgC;+04BA@05M?&muxqR_$sYxYh&RwcN)13_TFcIoK6}B91}}p`(+7%x2x`=#}*J z(V!;}Pj&F(oOg}`K`JSexpEokhde4GLJ?7Q@pa8-Zi5n}M1YehSLp2*Vi9~zQV|zr zvm!zfKL5{6SROUb1PO$i!}PU&|Z^O*0jqD{Ama(cq6SzrI4tQuZayk zPV^NSe$L2eA)<1rUQ}Ra+Gm<+cNunV8_T2ITo#vgSKI1X(r(#pBj8W%sMQQ7$+^6x z%n>tGn1hi?P8r@epZLhd$5!>aT z8y4sO4gb@VK`)-KFiYOFp>^mam;v@H4I6qYzR0Tl}JKNT_5UrFA(HEU3<9!)F1{G@NkC9 z*Y=s2$WFzGi}J+!S;XCb4kR1&CU_{fneM_iT>()$#_Ae|7#a;PSu+KxQKJ6RQ|LHD|7s}!EwoS;mv!a4`O49ggo%Sothwh9ess=DcAZ$ zx8qLrQHRh2F4>JGt5eiE@>=~f_CP6w3=sGrNLXFhO9Y-m@Jd6c^{Uh9NvW>@5{m){tXwhy6cBJC=xGQl zX%1v)9n0p`workN=yuBH({E~n$p1zeQxs8Z+4Ws`IAfQ0eOW*IiU`Jg6HpTXtNr~p zL|8E7|72%W;o$oM_N|feaYn`{&_dnFE~7y#9ttjSJwshC@C*F0j_(S-n;^GQo@te* z?t7f+K(1j|Iwi4McliRAhpl2gAWwD_?8-$XFm2mPZ6E7nTcOSM83p-Lc^SpFp-7eI zy5aiUfH?hwKJF);xd5gY7{{iLJq3HWbt{Ia9Tdu?Sk?$>TSsp#$26AdWfZsLcl*9n zfA9?R-rWhfuoy7!0vCM?HjRCw&c)@7t}JZeS)o}5RYhPEpIV+HH9rW2afd8z>hBH& zuMB#H_SAc^?!`AyXI~f9p98h4dEj~vbl0;R_w(~WQP+hQi(VbO_^d&jyjISMAX}h? zztR!{%F?-b}N=HyIUqR#*ldNN$8>w5KlPdP?JH&Q~G zwjd~K!o0|Uno}yJkxvmLp(Y{}@is_-T@i1S8Qb&|7gBYsU9nQIWQ)vPle(^}i0Bj% zHeDqbg7%0+Bp)Ws<&=ca%9Na;$Pes{4cuAzmbn1BaI4$v(K515@N+qlDBRCH%)>5C zC>ra9`YzngxY(6aX1ocMZENMZ4eajL3p-$sVZ5f-j5_`CUezQ=T_AiWm&7b4!DW=L`6g|MJI^?faR;*(;PO~KEr-nZw8)eo^4LGHej{hP~UJLGV?E$ z&hqO`qAlrsHSjY5)&tLWAt6g^-s7XHmN)o)gRJ$L4(Ro1g6)`nW&CTb5HJ*LfqfVUthE>$w%n6nUJ2aW_b!&+?gfbJ?_mAMh0AWWmp(Zkh`-qh8==s8QGfZrf|p4P|znvdp$_-URo9U)=6;1$xnwYdP?8 zr)?RYX!H2vILKBz#D#1v&va~4wU;vEi2en-1@dm6clo>2j#32!X( zjG(J9mhH1n!*ye~oC~(R0lRJ1V}BZX_7ApagL!0-vhWT5ftNs)gO*7?anzsqxtRM{ zCeg=Woxv-BP^O--$0*o8LMqxpaSa9^W-9S)nKSywivhz60{SraFoe!|73Y7ZyIRr4 z7{Np9lQN@g(OvC&y9e4M@^=4xhOG`gnZV@GL)k%TEY=|AKLqsZV5NiSD;>Vv==8KG z@oK;UE_ng4S+g+MAnSl+6q4EqDWnPqFl{dnF;`r6!kp{xpL#MsN2}%D1ox*c0G?tt zFs_zi@$p<<1APiRwGo9H8gNY^;0yoQ2gGtD;FrLE##nYzslFxwWl*;5L81|USCw~L zM#*k_t?%$^eW!mRsTdiWIE5%oDd&RDg3baol`rhEjBz1n8C6;HFEHxJ3~GOvUPYoU zG4R`ws}1wXLz+%IGIl0lx{GnFGWKCUqyH}Ibir27X97Fx;>AxzuWVnixEcoG-;ftI zxhDIrwq@E1KD3@ucZg|Jm~Yg2l?m=O;6793{uY_O4ju;V5GWuckV;|vOrOjn!q|DC z74QC!UTt*9&*SOYhEsjWlk-$ujYZ*JkS;Zues!NW)P4FF@$f`_))(c zKn%FVp7YbBJGY--M z+=ox43@`Jwt#dCf%Aw3+z(~+Un~^8#@`xab%(6}*u1nboX6lzc4)p>qCsgEU9BnAF z?u)YPS7G@PJPw!c`PF`YPK>MkVq2y$T`rVI2z`tP)6d7aLpJXMS9Q6bb`}ex{Zg*; zRP{^32e|^@koa9r_F?T8ytWAqER51D!$QF23kW@>B4c~?AJk=oBKO!2Kw!VP>rffO zjzV1*Kx|h&SdesGuP*#ce*0RTFLdxC>*?c_ofGS-knqZ_}vSA|IKsVzk5s|+He$^wC3gKhOJWK zBp~Ii3<`m$Oo5uBgswu+rHOV;7vm6UMVqF}eWv0+oJ275x1n>pga{~a#qMi`TCO2p zZ@e2UkM-i9?W2~j1Pl_mMSXWG83vinI~U@=rbc!oXp4*?pxfCq~@~6 zb3fn53K*}`Qj=Xm|4*UnoW^-mxnN60bwF2bb6q53(_;H7#&K<5E`|B*z{$EID{=ll z@E(4kZu_$5fK$>I=%LN+Gc0Fp>+Jk5>zI8f<{pgQ0n-A_6@GS&Y8&=(8@a0z#R+Aq zLt*mad5EcPmUG)!4t^GaFaq5G01yC4L_t)$ZlSJQ!dNn|J-vLM<`mSa@_*|yX*vIkk9b1wji&uOQr5aJ0;4iGVX6)A;}hobJ@JP z>@jPZwsDMt{-1-4+?!Ci@7h5@_5umRjoA(!!EJlBW%fa}IktLW%dkwfn>kmC?bbD7 zv}x9#ned2GpJylcgkRHS!@#0pv&ke=W0Xm?y=Yt%2rBwU^)=ll8yJ+7%PhH_W4S&X zb+H&cCxEiT5;4w!N~zGOgP*qo6f7!=SYTw?)~XhM$^H1RaE?O{@15y~Z(iyCoin|9 zS*k4sU1E&B5A}Vgj#mL{SM$JIJmBd5mgd&7O@I&U2ub^eBn-?S# z!BeH%#E$uv&)kDNmAQqoPV3*n#Iw#qIak=$T^ZLi#>{jsyFSVV{jkG3eUCTUO_$2| zvvmm=b*YYV3E^Zu$Oe<0e;**zHlxZgsC}1_vkiHt-DMw_`#Bvr*LUJi=pR5rE^%^@ zSPUfV$IgTf9_ec5d@m4{%ht_wRCpdr^b>hKH5EYdQ5zYfKG7I?YJbW{J1{=q#9^zW1{3r3x^ zQO-s5aar`4;d;Oy`)1@ywF6!)cU89-x43={$h&gFHF9R2oMen;#>k=#K&|%z=(WyE zh&ongp;5tDnSQy}JLPOxXN)@i+5_IeGwmc_!T3(_21WWvl@=(s!sSv5agO*OE+FMw#O<*B1Y@}0O!5toLbaa$icuVVb zr=q3ymN(S(owd^^DY2+YHXcseeek`&a%*3w7tzC44 z*}m>H`ff>(2_3%L=<&l7J$`Ve<5%J+azXIwYOqX1(qS01S+7UblYv2{{^{T(!n7hn zogwR{EK!;fl+i`Bb(bYImZ6KqO@79D{Er=s>7P$qmpCaX zjM;_g6Y6-F0^L%mUCje;@<8{dhQ|NgJTM0eQxHTagW%=>Q-d94d!>|25ESntLoZ)! zbnniQ?*8~n&!26yUK31HuVD&eiJ+HJmH8S7+6ferMG&0A!KvzUR4lGpeWh+20jtcw z68dQuRhssH=*I?m!0^|*!)gp!?~J#rEzH}<&wQBDZC}uDA>(pU$DQyEiAou|-i%V_QmtP3a@&Ym&eUBBc2qhx zbH~sPPX=SUje(>rRay9X`r85n<%GQ@FB$KKO1VMu?*X#zh$@x>bf+`l2vz;CU2JF` zc&i860^qIg`J5W3=;SCAZ0$^^fHHbvYNsvbHOV4t7z!rO+u zU$77;+N_h(kO^8vUI3&7k3IuOttIzFuz;VGIT1(|30c^*l^XPd`W(t8YBNTIM5Gha z;JVMQ;C1aE8t?ts`*XBdCg-fnC`u_`;wA1^N(FxT8IXoaeqhHvqR7vPlfQTFY=Y6F zH=@ec=YxJcg6?OY`O?!vFJn(aKJ%>6 zfyW{u6al(U9e_2pxqU=&@^!-mSFb_}`f0*NIWq^#=3G=N#Q0?dKFru!yda_Mg>>Si zBBy2jxjH)zog5XNpG$rXC*&^135f-TFwy!|FDjL`ujYZ*J*QYu^gl~_{&Yid>NITj2~IuWo(>zb$rX1XRu(XbK!ZalxHeY&ZUra^A`5W?4^kDUbw!?cbRTxSB&*C zJ+lFwA@ss$EX!9=#yZ7?%-j*#ll3HD|dDe|ic+noxFDAm)p!&gaVLq6jceSf|;93u~1;Dj#`HYza5P<;N#t#P=1HVB0-qFD`x&`kW z{fmDKou5fBpC!F`KIr_kCz!DS7)-qdSJdD4{!MpCOM?s{9ZGj2NFyOINGsjV5Yiwi z-AGDEHv@vSG>r7n-OVt}J)i$?eb>65!kKxWefG6q*R{px?`n@;ommb@j2Gt@vfisX zm`lQ_a!64p<(5%@VfE`;!>MWUhU!Vf56f$7>Tx;(>Fui+j{+DgM%N}Y{`xUypVKp@ zXgnC7CwY*Lna3ND?~F?cH7F82em0uuJ$WlPI})?*y2+^Oh<=BhI_~wCn+8uNU~^Jm!Cl&riH`Ki6(L&bLwiMB{mJzN|!X>%gM{yj?aT z@GFWvG8&&r9Y7jdsR!Ac2@%J-GG`gRPluDJR};;ksI_kerJChKN{NiQ`?q&|UVAWf zWSjly^Kw95EbO9nFoAd`!l@+Uq|*;7{@-bNnLlXw$ra}@k5WyQ{yg$m!cdcE(*L&z zb4ig2@UO-8NxaET4QWiBiliU4WvwYfAIj9cCW>E+APjU8ZpXR_HAF)fr@MA1Oi?1T zVBsZ%`{+k^yp2B7^&tiYX-DJCBQ(~-3S64@o@@_rlyhA3(%|M)7JwOlPlWKOb_}2u z^Dgn@PvqwaXx1moUhjKGzRFX0tr^j7YW`(q}nc6{r5o3O!pwj5=8B~R2+X)DqE za)9CeohkG~4;9XBINVBn7gG48VpO}5v6UhaYA2DJB((ELmqP=%TI~qh>iDdbr`PwP z_c!~e3dH9UAhFSejLJ}yF_Kz2oFHcRzqY7hIi#grz(g3bWQIyoFADd3S#2uvgZ^c( z#}5&K?Fuf z%FTyX6rzmQzRp%0$9%)K)UuLe>afJ?O(eEc_>G|JG|IJJiOOXY*?0Ty6ULo5nq=`kVKB@uvwhX|JJh(g@>OpB;Lt zG#Z)rw+X%kmWsZ9dh-hHF0pOmbS7N}+bWb>jHALA-J9=UsRfbhi%pZtXQw!oRmFTjJtcOuS|8qLjK( zF21O1J~B0`PDsvTny6l4e#AeC5WW%&wBn8uJP}hM9DROh3wh%xUZbfnR5!R?*EIxH zr`WD1Wf{d#g=gaPnTBS+c|&Zvg+zMV245Gj$H|fyS3xyW_|?*qIU}@WsW>pUaM&Hf zG}Q2rd30!hyEVVb%LMvVyCA$P0$R1iaEHaESVAs1ExhuZY*C>~z5Y^j?xPVXM*6we zW4bBf%VGY843ZTd*no|0?DSHjfRfz!IcN;4+%vKJ%1tYt3Kvm z67xrmc9hTI9o$z} zbS&OpUMccRn3|VIA=JTTPu#Zb>r0XEc@3{QHSg1eCLP`>Nyk)x+x8)57DAu+=>a`hCW<&hVK<8%v$m@&dj*8~plkKK-!1A-`hqWIqIeUMTuXNaBW^$c z|3)IcXd&9g+2>|Nf&Af{r%_PjBwEDrXXG}OAM)DoJ{QfVRu2(t`cb%+4G}}cp7=6R`O^#keDv%las`j0k@cu^&%F&mF@~+%- ztjoL!Hf{+w$hn@D0^UJFAtwFf*SbUb-J!fcHH@Lt-Ji(!bytl^h|<5k^0LFrk%izIQjf0q&n7QEo#Ff^9{;a0 znPz|=F-c*_;I`i)k&Yw zJl`Gr<4KfhXsctWsxM(i{Z9)xOCMY( zc5lkKtkUCGivUzp<>k*bTWAD5LIU!O=pUu^p97^La`tA!FT_ee_=Z3eyn6hY?uU=@ zPCIvo8kPxVB#2VY0Vw%0XA?qIPjUW{TJ^b2-KmU49VI@CosXe;J!5YxAx*r z7~HsN^m05QvOOW_)$1j@ta9~wws&dt+#4A@VZtG(m{#|$5L3e)D~-}-C&%iOT`*ze zla`Lz7Fp1%i~r;$;zp@sNSJdeF zKf5p|nFQ%nN6sz1@V{MkhpgVJOzezWefn5nHanWA(>k_xaHZj5v%#8w^@+vF_Y4$h zbf0C6oJF(j$9zi^x#r1q@qHyE$z@fW)8X8o^nlNn%uF_sY~Bv|VHH(D0vEPb^_l_9Nwmd$Sm5RI zE@IX*+uwC-IjC(Bf#ABtpxfG)PW3Q8sP(x0b}WaD`UO1JT^#3) zZx=qZFvG-Fq$@4oo6P+PL5el}`A4NAfzdw5RmYbLL zYrn~l>|$uO@$DkE&X+P1h-ZrbJ@Y79r}G#Wn~0t90d7Uu?JwfJ~Tj56SQcHpW%OG`Ot{+ zsU8Y&^9v|z;0VH(q$Bj?vS~jWGb^HWD$#lq)%~1*?w!p(_YbhZ=?v>W- ze;cn;d8vfCD}fqezX6*?#QTVz@A5_L!=9P)8}F%;3Q)uCeqVa5+9>RwUuhS--WHrx zR2II9w?4e8?9FV7!@@&HCb-zttMW+3Da*3nRc3l=SBg(J&yV~J`OEehYAkYyU>dQ} ze1WyZ`47I=Re_kBM5Nswl)N30IfyP`Mo5KNcH+OY`MMoZN3~Cp&3j)95zb)uX8DV5 z_@9PsNO$@00hj#SYj4>$*>uA2Xru7=PEh=IC)K;HEk$t1*=omzchwCUV0lVbTSDwL zofR7;3er>sPdVjrO^g z@mhIT?)saK8L%inR7x3|8M~C2)5S}zpP!(N8@brS$^Ur~g-$A~_635dEe{LTm71=JN`7iBUxG~nu$NQ6z2c*+EQ>Q z!LL|tLoO~U0?nenMKMB&G5$|HI@h}#99%dT^Rpo0%x@f1l<~e?`YqWi1|5GBT~Zr< z;v6o>9NQMF&0(pNtXDoObZ|gMiRxp7@#OkL|!d^UP^*ag}&lV zf*8_iK8Q4#)56~M=i{-XDdE5N$Fx!!%Dq?BFxS;UeD0B?^7Uw*MhfPBy;?}-o(AD@ zC5zlk!eN50&JV-=uHn>_*w;F&zsC9fD>ujRi$J_#@}F#v>HGRP=B3O88>jOL<4zj$pFK zf>=ojLp!3@w2?4l(XHLYVOJo32x`nTxX>ey8z)2_ZO7}oYkki74lsqr6U?i6`ezb% zGLkL(uZV7)}{ z8V&2!f@(b+Zh{~Fox}|=qFeoXAuV%wv$>cO;a-=)haZQ0RyX9Ejc}5XdXQ!8@dYy| z<>*7F*upJqa)gm0+#>pFPL9dg-U%ww3DymTro0|^UClfaiZH~H#V{a=wyph)dylb& zCXDfKR__1_QaU4Ct2F%5H5}G* zm1?xlx!V>Fh+8X{IWoABQHN?XCjqg%7tOUfEq`*^9sQk)`;t=Z{H4c>5&{fw8e_CM zNOao%NX^OMffh`fannPAgyqsqxjLPOB1J}e?b;qITLEo$%NW03Hc_$J6)L&ISXJk( z$JccSYZ}UgfK(YOt~L6rwFzw%v$PAQDg-irTaIT;A5=?-qL!QoOSo8I0gkU<+S0Gk zoaQN$&qArEaAar!cI?}Z@1*1iA%0|~7?V5WVa!)6Kj`-5RlXnD`xtLEj=XwXlZy4r zn3WQR*X}T+@u+d<%yhFifC$ z#>`Imy7A)}hC>NX`j>N7>2%zzLenwp_)S5#w=N{^mQ^uyMPlup!BW3Sr0QC2+Uv|L zg68q9p-jfqwL-Kw%EtR97HJQm|31nrTzo)P8lWwte>R{O5YR}d>|jMAY>{`co8Q0f zuy}RlCSEQpTJlOV*YT#wbLzj)6!Pf3&CTwnm@6Ut|B7_ zA_Y)RXdesmsJi{%?q6(?t#-ro6B&A@`hMyKD`Q!gLjTExwF|hw)PspUZ+;i;CR$d- zi9r4v!X5qB>_K3qZtHQ_Wg(uQGc4CCBrJ@?B|ME3kdEt~U>q*;SNs zuD&rXaj_WUERS3`mxJXS?^6z>~>=z#1DYsG6oXuV;Oz zsHI2PusBlijyaTkG>oQM^FfdmLx_U?YAo}ijF~-%56jzTa4~WHZJFMR6G)F*K$?K_ z1tjEya?QF6k~~rJjKvwSR4=Wk6VQe5DVSc^LRoSmd%tTzfwB^(5FE(c^owS{TNm4e zD`E07saN__DioX#!Sfw{{6Q|QcT0E6YA>zR_DmSh5VC^c>y;^USk_Du0Uve8$N-E!2rgLvBYA?hn|=)gUexfpwyoqGDtI4~M2& z`E+hipz7IFub{z`@yt^~A+H;}OjoZb1AnDzgBSj$e+` zCFD)&hVdUna)IwbV&@aYDjk!rpgWD#^NabHu6kJzRH9#hP$?*gppiaqm00IQ)Ut&& zaxbYjo1?EN&{_?-~aiEIzO<93lHLm7i063zx&?M z_HJIHwL9X&qP)9bf$YJl?O>}cY zBd=1sRA@%8X>}K%m?$y54IkTx1V@;17JBwQf;jCm*vWmDA|wkSIxqE_-veA&*f?zl z-Dawr(wm(;z-IO;#Jc`M_Kz`%24*t}PtNdS(_eN}@v&qMmo437xfN>!^M1P{F4P4! zkF1A-M#R=AP7$yhVe7{SvGaq;z;$d&)Ddw%tS_`pYlc@zlT!pWf@KEMhm9#^{@xAi za#cGzgH{O=i0U^QOmf6WimxVf(NI$EP;@cj!bOB5f$ukg9x2V@M_v44*P;@`ZX%$X%FxV6mMF=7+k6r9YuS-_u(UYl&P9Hjzs#$ZiT4UK>r1+j!_`uL zz1aVw>pWecBHmZM6244jnHclDw%=YMN7YPGGo5QOL{A|!=cCxq8&B!Y3u0WLfu+kH$xi__y z%gW-eb+Z^5-TSm>>(;>TsJs+1;m#*iaaVGgF*eGK@YEnnb0Hhe=kkCNuW+)EqOb^A znlw@sSbrfZEA9O=ea2Pe)Z&4B`A~rWL-j0NBK-78bbshfxYx6Wu}3D#XD|RE_pG-V zytA{%6zHP%%u97@JYn1e?C_j+tvns&b!k0riQEmvCl(bnn=`5OgLeBdZTGTb3z+$c z@lz9hQKRq7?g6?6F4c35`un<+o*@+=#vU;M2jv5TUz8z%k~Pd@B0{nE1%@B{Pepmx zBrOcLez`aOW8rTs(KfOq`vyzGiT*7>((*+safzsG?!X2D9fTtp*&2x=xDj>)&TWxgS=?p zgqn_Z%&5==t_RlnVjaSNrol`eoUyMNpT=EfeoDp!@$2foqb8pZPz?t+&OIfZK zG0CgzQx4Y5yVueb&&$0Qmo`7#w^s|Bd^`evyUCEDKbQoGPXnLHo*ooIu(9MG#I0)Z z&CTY6^3R8+I6quaOhHj`9s*CZtQN0(V{@>p8BVxL18cktDVeo&J;0F56!L$~cUX^zD_6Ta%dJ=NFAdKKzx)L-<{m7W|L$X=oPsX&B@HVSYDC%5 z9$P$+Tx}D{sgfs~ulfQVw+|fC9V#csP!-YM&|^ZzBfZGj<;^t-Qh~gDUpPwC^Y=R(b75 z>T=3~UZD;C5;O}WC7uPW8aK*6im-Nhj@GPA^dPVa6zxBuve`Z!TKY!Ub16Uh^51(G zQD>3BGeLJNnQadCb?gdw&c59j>co%LlfoU{#r=2J3KI?CsZ#IYw2!#T6Vw4h-s$*- zG6t$G?DNuU=x{9abK-vG8;ZUu$lmR|`oTqsTq47L!8Pk@XXk>MX2WTHMbN;(!fZLM z*fcmW>-v!C;M}4@bm!Wk|L3pYF^bLfohK!~r7pB0sYW98f02|e%}bs;Gq_s&i4fNK zT=h0H;IvuLmYbqT#D#1QmsRh_x>MtfE7~Y-=Lv=ebf02hnZiUD zA`Ljp#aYm1ztp(H^Y&bXN;#GSZjUihW2Bj_;yVLK1|oQiBsf{iAnt595+mft!*AVG zSqLe7BX8nySVyAcO%2B zo`ml>sD)$h9>W(LdgVRPS2gfoFrEa|@5A;U)Vn+W>Ak~$D5zW5Mp#U(GEUyGsKAi# z(lSPn=uDyv!{I@rkf-+z6++kee49^oC&c~J^v6QfY9XY}sVP87vDqi%?$*7lyS+L1 zmZ9zGYtZefJ<+|FB((Zj{7GF?8adhdUYh&m#RP^G_O>oJpWo1IBl<*F$!fJi|2vFp zO`^0uXF4}Ad)lnkP90Uw2w7kk4wHZ{iS`lT2CZQ^q%xU<{n1)mm7G^1nE;TAn#T*< zhN4ASTUY3|25h*Zwk8aF=yu9SoUn+KY7ea_Fa&(%(CQ^*N%SUlc);B@P1D6H`)!y# z8X+jTo2K^rV;0<%CQNORu5N6C&I zZzWEJefntGHFhqVC+U-DL%%FNJ`@yGr`v5PU*Kil>(rB2mQD||-5m>IEfU$tQJm4r zsPQ9Nrq&umDn0P2QeYz{~txZ9=1DDFz-w$>wU9MY+@JR)AP4^@Fh&J#- zKQ?p&IW{&s*y%H|vy1pKDXDsDOfU0=1#c2dOZ;XkbE7C*dtj%IS|140Xz(cDX)avW zpv}fMzY8hog|sIiS#(VX*Qu-vHYKY7MJ*H}Cb5BpbtNM z77UqF==diqx84+{4t#TG!UdHLSr5@$`j zMj%(F{o8~{e#P{CT~+$6K}p5!k1-qpljnq1b(q>|@i z5MVpb;*nw>sw!^|OMOXk)2$pC#I*|Z8%(;)%yqA9K##*?zm3n2uW6srwGqT+mH70b z5Iplw?N?f|T5n5Xc1vw>;m^XKvW=k{-^1-Bctm!Jc&z>~?bUbZD=utrDyh(Fhq&dHm%_UcTAFo6iube}^H zKL)GohyATD*ewaj&C|*8UqrlSO%nY<4zwMqeg7)a+L2IPlsO+IzM$8dqT6L>R6a|o6aWVy7*FP zwL!z}t^(zv_=A!51)s7@(4Nb6h`97*vzP9$DKW^jwTE8g&M=-|9;vbZ53%^1a5jWa zq$~O5t!%sG?~##xOJ)Ukd+y71$up{%zaM7|foBIkV7M-DrK<=N@c_zA=`IoHTgtV0 z096a}qWsWLbV}8IzhwM6;U&&>OvoAFcw128N&msBm|E3yH|LSBZzr4IK8F&m8SEEA z$kL(o%1*;GzG2P`%OL%t<*~l}&?cUTSnTd+p<{6VqdvoyzLfUz$!)f2Wk#$vGh;kQ znq4Nbs29hvD84iwpx;dVkZ=C^csr_R{GT$t$4A@eR3`?|*Gw4ZdQihncs#>h>xyIJ zSd3ri=`ClXCis^6s0F>3wyIzdw$}ioK0W%QJRh+l8F_T`U1gAAi_pm+QtY9pLkqLT zc|qyO;KQqnJwG{x%Sk3D@%);p$1aF%clm5;=~FP`phS-NH2-KV(e6eH(ai|y@PV27 zoaG-}Y<)MUx6S)l0K7OqtU}k_c(y>{Lm6ZT1tvf$V=DfcYJY&V!fj7E&~lA%5{yRncA6I)m>a8GjCa)A+U0bOh40 zM#3y@!m9ubY`3@r>46b&o**{|t$-jcmnAhva}0vxXNVVS63&z{`q`JLzlEVzD}SB;Sv`5NA!!4jF#KLJ#u`E?h+^ckr#v4nlWy6=DO%!d}505N+RmIq1cmD;Z*P&-I`?) zWX7O%53I8$>_N#iOQimY(a73)B3)eU_$K=9f%vrA>n`eh@Y89$8KUxZRcv-Eit9g$Bk2d?kpJMT z&lByb)~6w$Z<6}R54T*Uu#7R;p5(|uuB4ah&qN@zM2>UzIY9$s26FxkI}^Y3&q(^H z5xYY~q&a;z6nGda|B_Q8FWg^yJpv~vYf3f5Qx+XH_X+&yUMZ7uk2nGLEP$UkxK8+f za13AIu9Yeu8btOy%NK%P^Vg>?R|`o9lWM{foX{QzGbu3up#mf}-!+D7I2uOfN!^OQ z7Dr}IZoI-$)ueQW0WBK3Sm!J0stl22W4z<)n*0{KAIr&{koKN>yo@Y$Cl)a~?IBis zs!?hfuL8jS*c-;1#ATSYI2}+On=Q%e&O5ZF?FnfZDwF&c>g;Tge~1aE&Rg^vT_|vn zsv@MadF;Cr?Pp81$B*Ny80&prlU@A)a+{DA_*Kc~H%iN&fgLC~^&#~s-9H}pBQ3Sv zCCE~6ey=${(@r;k>^mjv>0-3T;>#VmE6tH9aV(OL^z^&^AO z5NMvGYK3za$U7=;)%N=#kxP&?M{?TJXyNH4$0UAKs5_f+w1CD>Ixh_ca9OZ1b^GX* zXcGj(C{>LO7j5aU$0m2Kcbi6jia?Sh)}te5LoO4bH{}iR_SCOmQQJAkLk9K z9D*{l`A$Hk&0uy4Urb{G^YeI58)gKE4NpzV4Itw>2R0_;roldCbzN^ze4;!%a@o)P z3hM2IXJF@mZ09863spGFTvJo@6>(d)0hc2D3|g1hL--jf{!c?daznIqv;UCx=C52z zufKAju+kKBk4ZeWbIZG+X3@`Ri+Y?i_B?&jLfqDYr$KN&Fa-2;;Q-%=lUj}DUc*T` zM$c2$s#q;KUB|sg^H)#4qxf9Ov>*4wP;f~NE9UF=Ko9e>;~`g=sZV9hR~rIA9qMx= zX4q$jSpFygkTZ8*se$ucsDt#S&AabEM`9H%O;SnKIE0+dzFLHPu|7(;WiJ3!1Kx-2 zdp6{aHRnld#`3bKT-vCnumAaH2w-91d_NAV2$Enm&(NnoSLXD!l;eN(X<0J=b8{Wr zgRV`kBNvp_v9U{MCsV~qL8WRdf)qd2(KSktV})J;P+bJe-(7F;ZTALA%4fMcDwJElPdyPz+Mh^%vvsYF4ZPOb3@(pZ0KHGS zZCYBHLLGV{W_0n4@`1Ig-3tYG1r@{YG`&2@#ifpnIIam=EyQ~}pY|($zT&|4sH4dD z+k1?LwiC}ZZM-5kCrSHK`O*eM>xQPi*sx}amD%=xT7dII`jG`SuU_tQnDkTbK4*(K z_cBs8@4$X~(>>voyBq3rj80QI!ylwh)faV-pjk$6Sn>m`7 zyka&LScLkSWF%2+#Q-Mh^^1a0A1yytzEH+y8g~W%hR(nL#YIf&nN&#l$%XkKPMX)H z@*#&Qw8T;f_2bj1@;}a^oPg7MQaCoHy#u9(!`eYXK>@M^E${<03BM-L8@Uow(JbJb zQ}Hy3FaHEjBcgpCe1ausPZ&im*LUs;cDmtP$@$V^kP)>H7cWg@33p%B9y}QQOQQa# zFnd$*)2a4s^U<8e$I!V|vZdwh|J9g-&(Ec+AHn}A?AkaJf8=JGVl_hJ@=`eENm395 zu9JHA^Hl0^{4*Hhyp!*GhYDuG8^A+}e*;hHT~6p!CjA;7wD?{WU<6URe2XWzcH>tr za(W&|Dghp!n8YTG0VJ!AZ&mj83UnoJ4}f>0eK8kuUlM3SKazW|Kal1+VqF7BbqBFZ z^gC6J03`#W3!k1TTTV>KNe!l}f3gW(cYyuScNAwd+roRjrD9Dc^CyYuqUdgK{GfMNbx zt+x_h)=}ee%j4d&aCK%95{Eb)MeQF&q~Z%m^G+Ptjv)!WxboD(Z{C51MmX00T!AOe;ADwD%F#m>PCT zYI&-J0vXipx5?%u`@spVTy(Awa2ufla1% zg2}s)io4jmd*`tg6+L!%!WoM@x++7-Qi|Fbs<@_!zb0DP58(Bt-=^8KvX4uhCoD>6 zzmmaq0C9csZYZjXov?4#F8YtsI#2Wd9jou=$b2B?gaXWHl_xRxc2)Ac-nS5DMBwBd ztiix^PfC?%?dbJFl1ufGByX@`jzl%18YLKZqYHXk#Tjx;6L|`-8CIt+CjFHUnn^){@@)FIhiK2NjPoR4qr(8(*L2wpfe?JAI<% zG{x5Rq&89Y{XS{bF2B#SZztX~DLI=hO3nUhs7-ameerC9EQoN-AP)dKnw*z*^eTwe z->d($hJM8m%5eS7J34^0;hP)kU{D2qeU>|9`UF!mwBg%qq*$G?k$$1a-_1VhW)}#+ z&8q*O(6P#T+*e1-l{bq$ePvg(2@{$SaVfKdr@;R1;VoQ}Nx99mPZk@HKkwta)Rq~B z?xjX#AToaga!Q`hz!DIj_1p#=(SD zbqht#LRceK#!&)C!`Jl*`Qc>l%_Bk6TlL+)xBySTyKCSpwo}qanFqb1;i({+CNziB z1FY((A8&gEK76;W5gQZ{)=tbjk2v9f>ZFaNBDCA%N8TO@p@pD2Mi?C}khD`_)5HUNlA5{3e``i%V zrwe>|1_+iHe0TGnr&B|4EjoxwqF#IG041;K{WJ3gHR^2L3-KQ%*Q9;R(G7sbvqq2t zaK%#2?PogGPFaUL`Y#-)V&O6`M*4X=p0Afq?{B8Yq+tLW%ipvgnYr5{fZAU$UyxRvRs)!z@VvY{&BJNu4^Y`~& ztAz~QD2gaVVSeST%}Ve0^KLn`M)J4ULZS1rNl>#>OJLB-)Ng&>v&y=l+c{}b_lVyyRn>?n*?(oKmwe#zW2t;tVw>ciEsig^I`5ZSdMVpPA#gZIS=;t$ z{Ig1yC&l*4hZQ?{_gA;MPK>y%OU6ew8cXL+U@Fw_$VdMn+2nGs7W&*v*xq5(R~JF} zUs|?2YT6mo!JaTD+M^X5Aonj&q(Vk7TH2^kg=Li#5S?NR-&QBw;4byv-37U#kzr;y`X0Ne(o z5O*5+_?cv?9>oX)AB7Jfs)nW8q|@wzkf82`J-&mr0VoGCQplbtLU%{(zmX^HJ`^=fA2l%<;K|K;>O!TCiCdte5rM!M z#I)5JQgF!3@USdO_FcFn+V8s>$7aywc+D+omhqp6cmGdPR{!arhVyMO0Yfy-h0Y6$ z0KBoD+2V}!py>qoR0fdyX1=C0{kY+g>HZZqbJfBmxdm>Ubwe0sW#d;3rS41BZ-Ny$ zRLDrsge3dC9^8e#X(Y~2{7c)Sd_C*Kb(PIk7Yd3LZb=kxZws9|j}agf=rv`yUmb_R z_ac`-(k6U_hgj?FZNEcVk3$EquO&JDU3dK)-h?Dr<&XTt(&>Xc}g!epj1sS1+o-_T$z!8`UUVv6Ha=$aDW;2SstQ# z^-ma*)%egdHrHn4Xdx0DQZdle?M8YFb z)B?@PJ&&IPv{R*Hf;W_U6*JN@a_NrcxERI8u-Wk}H?)17w6$<^Ul?N$yp>DgR@
P6t z^NO)sgF#?^Mk-k6%xlBkqd*8hd>mI}B0yY8)bpQ_d)Sk!QxlD;$_MOh{CkvoeHKkEu+8CXGqw#e^aW~;;{5PDIDkmj2l8hrH zv8M6Oom30(Hxyt-#u?MeW^DidH$<0I`suBzf~}AFxlex5p<1uAr6%C<>q?hZU0S_V za3~FL`4?<|jbz4s5p7NM$D(F}_f4!z)kCUHhQFYdY5o%tW`4R=FB8DA^P8>xlnh^#tT=%2}SuPM!wq)R$w91Z{i;QlW`7L00F2 zSE`Yz{3zO}qL;P%68mdj`=c~bF^ifXilp9~ zbgETHUc2_v29B!dUWU>>*TVeBNx|#&FdwmB@)SziEQjBTJ_J|Mwh>Z~N5E@`*O{truWbmn;&06Bob5&k<*zgmEfd%wUl`YLn z%OdJ#@mpGBUSN1l?y~a8e_fA&e!BS2SDf^uTZA7SpTxs3du_4%leQIk1Fd8z zp8yv5V?v6ZnMLQoBGdD{F%}uXQY@^!q-{jGw@~R?0BI91A_CuZwEvsU7^8hW`O9}R zR0qO_zY|eC#=rLQBz#nY{9Fz_lG`lMnHzbbKb%-7WV#qh9URH2W$HXh7BQ!F?j-wE zAALaSEYuSJ!JgsX9+_ABCS6!ThLe~)>& zp9?Y&q4Df**`iS4Ww7&(hPb|s!h?UDNt)n>jUNM$JhY`EC(4P4-BjU!5;4( zLC<60BM_n$F@?CrrbIfG8Ve8g4?$|vcG_RvTpI*cNO89aGj-{i3p)#Ao4iRDl5M0i z?mKoHj?!36OLYbyev0OhJRm&`flac$&<5t@>UNTGS@6 zIP`=)ekD-Ki9Pr#ZwIlvNH(E@S6FM?u(fYm{|-~^jj3ff=CSHK1&tW@%B!cWQ7YkEfE_o)_v`)obl}nw z4(o^e)?cpFkIh|nI6eAR(;rV{%NX0E2MO)bTlX3_H7N$EMPZI+ za2P4u%YBsVd+=j>VBEj$Mqdr^ts2Tj>k{3+ekxUcq}4?E;D6~m6%M0a1&i$eiHn$+ zaCReg)f-~Rr|(KJXCUtuKlF6(m^*j~1Ayzsf4@roMYbhYNmaqV5fO5v96Dm_>&bje z!E~DOi>olt|M={}VThn`4v7;3ZaI%o+X%DmwQb)k7g+4A)#L1EBEpoW%>N`fCkAxI z1+4-W*AHFxKY-MM+&xn+E`c>N!D{YyJ_r{e+#J;P(EMzJaDX2r)LlpIyoWP9Z46R9 zZq=+0HvqZ8b2acf&!dHmC9sq>$IV6#9NZv!J3ddMCM0J3< zEt@X#OiJnBNzr5~PdR_(>r^j^&+4{QL(jkgrDr{UuV;~P;|%dS z{Q=AcC`XJ$=HEp{DLFim^ zL7t#)L`1O9kpp}T!i^IQ3xFTF1pizQx+v{g0hb1uV=8icxl8Yl#2NaZUt?a&4%zJ? zy{Ynsyv`wK2b6}{t?=qCWP0i@Eiaf*=IzAtV@u0!C~-gfYSbs?#!ahBS>}gIETfM> zBsg)Te)UO7KWN9M41p_CXTh-VNPVEr(*u;)M1D!bv`sdA1NO5*c79%s(d6g>!*B|?w>J^-Qb7(U&8^yKS8B{DGL#KcQ zVJ1w7PiwvIZnK`)ofuc7radT8*`td7ei@Os0&c#M1z06#qbE`h^0ER(`(B4mr*=_U zj9B*J1|$x2s01Yr1mm7(2&o>&IRo)iU}zl}EUU8&JFHKvBda#xOMZGPRM=AaY8wfx zDlW8;SeRF@E;z~346sYjokDqkqzs;;f{9!&^YzR?pu2xR9{N(ajvmE#Y84#u$*fJ^ z-7y|s^c77kgzwjQza>T@3Z2*;K8Dk80;L&T_gN1}!iGp6hEfxg{XfYP&8;=ey7a6; z7!l_Y!H8wPo1MS3Cf=)=P044)?8iRMWM&)oj{jd(*8$Z;w6v*GrB@|%rGtP10)c>Z zY0^=efS`1d8hURE(xmrZqzHoaZU8|*I!aNBAwg;&^!!P9?>zpq=Wxz0H{bns?%dhg z*?VW&iVe*41^m;<(=ON%=yK@Hn$kW;Oe zz_6o^xx5e5-!vHOZKp_5(%cwdsP{suZoe{l@$n(^LwWO811^CkJ({LKxitsLj$dt;1<;;8V5T;fRP`|yXJWhR@!Pw!&{p8*> zp3E;h-D`Ws-Uyj$q_tAM~qw_M!Tx_;e#&I9dmu{`7U;5mE7?cSBprM%y1 zTCZc>^_s+j{g-v3)rt8p5QO;DLlVwur5?J|F^6N}5zffAbtFS|NMPLq-2S|xRA8g9 zG8t8-KbjlHi~TNLd35E z`8^{rVDTjKtN5;HihdCxp-jP|P-IJ+PmX>3bG|a&a(YE!bPae{?Y-Ft!Io#K{6F`A zMcx@L)8G=$p~6BOIqgf5)(zs7>7f=@AaI<^t6Iboy%E?otMKmG_c%gdm@@SfUt4** zfAo0nCA=>lakuFX<&dg3mZ#-stbyIL5nG!rta4J*9WueQ_>|i@o9hR?+hDe#tc8Tz z3vm}hM|@`lRw73NuFI{BTq&sCDxS*PNlbYQf4xe-@gF{9BJrSvcT2^1tvM{ z7D>U4ItQSG?ly3~pE2P_RM{FXp5vZ>{#{oPI-#>e6(dfh0CmBDbwL)($=L-et@%T1 zJ>%2;6d{?H%ZWQo!CaP2!R`i>C!stx!dda~7mZ*3!r1ab!5kR>*;Uq5hk>AXtbIxA+CyQxDE=`~^R-2L-g0 zch?d3sAY<~A4J@QH&0EhUz3p(5iH#EOL2ka_X6RsS8J}cf*F$IEcZ=s0;<0JR1Vd1 zE=qOR-R=%8UhTI@XVlO7R-R0|AKCxlJ^7frNv*aKIMA)KX!Tv?dM>$)@x+*C!simB zZYF9D!{O-T%SD5X|HcAzECjpbgy{f6-A*%1q)dQ_&S&E@vj9Zo>kE0ReI~!HwF{R( zw${J>I-H(=cIr6P7J9W6!q!+fw4f`-%v;rOvNwHHxg(TXK&rYzg$%Lj?63WJ#{#sJ z0X~%j2Gi^Q3HJ^9?<>@?A}(Nb7p!xT5#XJzF~inX84xad-3U6YEGN5?SBWer+dM9o zT>-&KMiK50d;N+2=qgRE03+YvNy*H!m&bXx0oIl277|C7kTCOT) zmq2NTZjWkjK_+R>Mo)8|PqEcqu+J`^)U}FJ?UM3)m>LU87i=r@Z|4;X?$n9@9enw* zZG`Vu%|hdHfY`lkGp=Z5((wQlxAC7oIIC>}=uYprL~ zu;+93w@^Ihh)mD%vyVL?( zw}NTTEtVVD?=g%Iq<5RJ3qDky%NkF1lcpJq)-{Hg>Y1}ml`-8`<0K3(j%Hey=G`?R z3+F0#B$`^BnV5O9q|DA!!!BC*AxXZ6kt91MoA1RT_wxAOW`0`jcLdDm4vMy0ak=K% zY1THIh#_G12+_oYqtYNF(F4xo&#eCJ9R)s?2J69uVq$UW$VLH1zmj?-Fw7#$C-j$c*Rnxt%$K zW42wdL>Pcr8nolq4Vwq7BjM-&Is~TEOL^c0ThaaM`x>Pus19mO}{X?@bbSYYS zvPzJ%YH%${+Xut24mf)2Ykr?vn7Iq`)`v#FdMk%{Iq;!*KEB!mB*75@SrVy;h8dAf zlR-!5PUpfzOm^&Mv6L;}`{0)z)?7o=q$Gh4Yym8}@Sz4SpkH8hQS!TU`5ive>2Px> ze)Vh}tBq!SqbZ}?;)CXQU-fJRJDc<3AKp+=Mv%UIVu0S8m73vu!UPm$`(VM6!PwGC zc-~?L^LZPtux3%9(n)G5UU_qhIb>u17F5fzqWrX)*4C*r_6NtrM3{v1Tecg*eD}#q za?`V`CBVK-KZ??#S}&j(+p<1-DEjV`c@?jqD8h~%9=Zlq06Myg_Rw-<$2;_QW=_P> z$&VCw(usv-Y(6}A5R0d$pt!5ZE;Um#u}Gaua3@UZF6@E2zJ#ZuWzw#LyCmU;P<|@q zBAj7iNA*iPx2f<{1<%AimfG)+l~XoT!#%|<%<0~LaU%0`AbinEnwkRSN@8>mWivv@ zhjpojiF0yY5r3vDa?v4>U7vBVO^7yiL3R@TVD>{~iy8myEuM7K5;IB_es=s>djgV0 zK+(S8I^Q$qqZjeGH;nM+SCe?(u!)hrc%7mq=7rG?LF}YDs)va1g>YZwUatFAkga@fE_|XVaU1AcL8L=D%j{_Ic7YfgD{6E%o9t*q*zlX!T7_-GSwsaP#cVQ16Ae#U!RMf{q;Cat#Hzw>2Fl<=suYOI z1Ugn8bPM>ma-O9912hOlh}!EqU76#39QlZysV>h;TUn|}O|MO}+{{Y~8tzhx^w<@) zlp!I1;+2PSg9#GDZVsP#_C{sCzn(Q+d+$2Mt%afh0?kBjfYX|x*sJn}qw-{q&5fNj?N9{*puF8%f(eiyN&3714W;+{3Jz zX^-W$s&YK#&ryD!$7K+moMf5%5XG4N8`bX6>{&cVr;%fEgR$4L?C3QCwjo7VJ`yA4 z+eBDLxr2tB3uOx608r{(`LpoF*J6XY7>BI#5y_^|pP?;lTr`nuwt8y#k=eTM-Svti z)}EAWxk*fzZ0CDE$N87ITNOreaK2&`n;Q)>&yOVNv1$^k8x6Q%BxbZNz61DRLGWHF z=Qu5(R#}R{fK&x)eioB>%j78NHe7Aab;zY3w9zOtzipZ%X zzY`Lz+fjOAW&ZKIrOQq5h^IM!oB;nX;LB&bu|*J2)nO>k2eDKG#j?N3I(d0}($?eO ziV7K5*6Ux*#I0%q>l-9)0J85m#Y;zp5M-t6aL(HaDCNcc8yhLWd0W1`3V8iEolrBZ z->JJ9^zWDRPo<8&&UlF;;b0#xe>T6fTU}9PaS@Fs{NfOn9QRrE`~xit51XDEAUYE< z+B28&3(ouGJu^Y)FUHEQLHiQmVwv~D)_{3vQwcp-6(Al#ZX-L&Bj}bV-!RI z-aBgYa?+E@X##+}o4525A)Y^CiHE1|{ZOCcA@VRLw0sgON?uVU8|uDYuH(i`V8b6J z0l%r=+|>X>=XdWLbSX$~D3rfA?7Te7V}*91PDFhwQSdRHyRtLrO`u#C&GPbvm%1qU zPsr(ZPe^Y2(@l>_NBs}hO`eRoz@H!FIfnUHO=VqsNFmaR&zAxB&I?T8g}1ZOE}Xh)V~6?2WI zETgKCv$9;3#6;c*|B(M6B4$CYB;B%{o}Za?CDEOi?7dsvx5Yj=QJGD-Xq|nIC^`3ivY%rca48 zaS&IJ&k@@%5v+sjE(antmo<;kb;c3RY`L4R%U$Ekf&L^Gn^|m>h>OL)t*A9MkpxL; zfb_@DW$!*bEwUegzkRd+DPfK1aCiAF2ZP2Ujo^JHblKOl+||AZlDrL4>bp8)vl>zy zrxu(&l(IOE4AGZtyf+7(^~w%BCr-ds+IC9iE^@@$ge@ zNH5;V?GP(^7_SFEf}YqxL2K|U1%XIxr52rfIp=f2`)dReZU%u(=qG)(2dd}sV#|RN zC7axIPY)YL*Mi1zqh+W$ltaW0q3B!SeLTfYLHFj22k9 z(~$g*LJeB>(1b;BZS|r6PWafu*2zTo4g)K^vAeVSuZXs1cPQ-K!wQ$(pPzo2HYsSA z#V5YzacA6rCM>vnVh>>0xSf|v6x}MH(e4#mH{cy6rlrtfQ#MQ8-u9l;I=r~%1K$W6 zO&T4I8;wNHla~T8wdr7PT~KKd!_f0BP9-Jdl@v!C95%^W6@o&K2Q5UQtXykVy0=AH z_o{}E55pkMyv(%pktxB}>&&{T zyB|`_yLw`Jaw!rTSmorbpBx$_n5GOB%mu(?x^eNAE<+^ z$Xjc5gt#S{YbA}eFIGho4W%Fbp)*-ydv~91kys1;^&%X}KJP6&8~zWH%uNMQ|6)87 z9jX~({9ckEwYKmn>8q%x3x{I+vEOK!RLy#9f7!E0P9@%>F9>C@68NS+w|+T@ zX^;mWoz-C@@osez;*VQV8zsfzKS~iF?a%RX4}t%L+GYJPA(-) z=i3^43-vpc?DbOLXef>@3diNJVJ#@G>YTU1L~BqIYqa}lqZjSCL7wp)L;tUPjQtaeYlJ`*F5{)7c+K()RL#hh<&lz*_?c3V}hpCbHNt3H-5NS?@JGQ3-dO-C~ z8)51K@Mw7JNh(qxh<@YY>aecCUYgyU{Ycy_b1cwVPo!%`YUBROaU&GU-Edoko(Q5W zPgJX|)J4YfV~1*~=gCY%ngGpdg^Rq@ipOucGF%Zv^z6;CgvislWN$VA?Cht`;UZVG<>( zP6gR&h7lcT@-8wSa}15Q z@qLs{#gy|GB~DcjT2zb(R54*BKDG0QSwdxGm!RLnj8Bh_-Z0=%9?D<(8>24dcc4L+ z>#RJK37$I>0v)m@jsyJl<@y9ohA6EuMKyECJ&qbN+#gpevW6R2y;KndKR5NwSFy5_ z)n_!9;ElU)9y{aoJKk8icFwTV*7yYPi+>|&4n>ENF9+)DNqz|p33s9_je@*tnc|8_ zvh)uI4U%K1Qp0C_EnZ#S@>#EP178}=mCHj$f-t@J#aeJ{91=>s0j7}`;8=nDH4Sis z{@uinrj6~=rttNNjChlwYVm*}mAd(g(pH>u)JS6SV{her+%H?jPN$B4U`!#DXMqK| z$uXVqX>KWpvbohq|_Xh|xhQM>SKb*SCW|PbT>BGvH)Q zCXcdf-%?D-%h_-JU4MB{ONmHx&E1S@;{l)hmv?1L)s@3coDTDC_JT>EpFC;oY#!ri z8EZBsV&7EuKuF=7XUk&~_%^$qbqU0tAhS0Y2!y8m`LQnQBu(ZNb=mBCh&q)=Riluo zle|;ZIqLGMcYrq=D=gUNkKuy;AD~-o00ZvhIvgMrG+r7z;&$6>ereQ8UyQch`Uc6j zz64&BS)>^4x6$uo_!~;F_S#*=m0hDZ znDhnACtb{RzX8W;aj>v*uv1HrzkeDDkXE!%TXB~fJXpL^q3w|EMMHEjDUWD_U3PK^ zpU{EolO>?W7v)z0VJ^wc$8Fe$iuCw)(~4@^p~r~UR`;?_!XGaYwY z4G&pjFdcV>USq+IgPQcipU{CGbgobNiMX`C?SF-z)|Mzj;?coiOS`k*0blNv6sq-% zriZLIUE-Od6J?Z}`|IioY;;kYFe5d-;yng$Gm+u@XA~9vs#*z3;P&16wu`4YXaPbm zp1kI%jOinx|B%dyJ|bS&Lp`{0)-J+jV6#hz^iels-bw2PLPfvn+7q=OR3Y(IAg9!n#~ z(*h23rAu3XF)pojJiwFpd?TJpE4dU1s~!*Xv9fx>9y--rC%m*G$9`U5l&Q3EXjK+*)XJvFvF>)L7EY z$au}f^n(lAC@rRfjt*ycSfL`z@>6+T_{;J?@_K>-k!q+RyyyM27h|rh(4c@bP>|w= zt9LH!sK^+63NwQUyebMvjnE-JN)340Z4xmVHu6d#gX612!qJbN3WI0h7w+#8GU15< zLxSwZ{rpy=GVtVs;=bgYM-+e7@=CNIpX{Ez`h5ucUA*$B4XH_#TzVE#VgoR`_+t0= z>~gquqiS4-HdbZ6H^-0B-y^=VS{rL!Yai zh)06{sGB8Q6;|BJ@rHtr?`&PDtz~6U?w|(gpK}f1eapE|#avO-UqKbR@GY62`&XhxG{Jy(NZnmyrD?jk*Yt!-gyHnjF7gpcLT$rQ zS97v!9Dh+{Yqs?Pr6wz}wOIKBeSD*vD=jxXiMF*^*KFaS-1(E6Ta9Bz>O`vCdwp-9 z8X^yuf_%S(;7yW5l?gIwC+Bu8Kqnefw`HX&yHr<}?|eC10a4~=PYrbU+5i1~!68P; zCm$?VjxeKyRYkrrMQygE{79cm`v&~?@82t2>z-bmIOo~Dc@_J=VG>(MMRzaXqtpSG zw}M=qv+Z+^q7V^bMQVV?gTg9B&mQZzT19zZ^M8BDS;qvux*n?gpk{oZeZ%8h)G6n5 z>ZZhalhri3rYcvHQ5Eg^&2@RA!e#H>SnRS_M0}B8U{v%lLj16v53vZCr5+|(u^r17 zOmcVvDFp3XTMYu}rUn#r=1oA1+3bkn7X^Cg?0Mh8DvDixsFKUC0{AG)-Alx5)G1nJ z;W{g5rA;@!p@S%kxzy1=4mw49uD$RG8SgxJ32H-@hdL%g?XJ2@Bs%{11{ma94AHUN z-$s>K0CHb848aO(AC{*S_f7;w(B5j8BJy}G>F{SKZpy||5?`h-e;f61($Vc%1RV`i zm-!FB*6Il|8m=!1A!AyE^jxG_&P8VFy6^|TSCMMbAr)Fzw`-5S-Rc59><14ZWbWx@ z%sh2-GfhN%WJ5`EiPY(i+VLvNJ&pU;46)ZM2-nLxc4D5V@yqzppMLMZ7pr6m;X8ioc8W7 zd<8-WypM;HSwksax5!?AEWK|hi&i7|nLjdnim3qDA3x|B^ZaMV<=R+VepC||0Z24* z!WdW{N~&qB7eWH=R+v5Btf1$MCB2t}YwS6zIj5afJPPhh^waA<{z6ZqIWZ8_rrr~> zlPUn;M&%LyGa=D8kpJRvyWsMt{pWhik}m2TuBW{F8FUkyaa6R68K>oNjC3x0n^@ z6R@V3G;h1Z!P%#eo3B`p{ej~Fo%*igL3ym~&M&`4EIl+qm-Wk7*HrzU)mf`SVDokm zrUtlCOJqX47E9PJ$;BalZ_1R^>)i2TcNc_$XAzyEP@7Gce=T?5QGOza>GYu}-FJud zUwH>-i-!lfJPU6rOcEVdG({?XCus6&Y}i-NT)#z}Ytc6BnC<=CU|h4YaZJJ?2<6`9bFuOPOE>JZ^-K*6LO6j>>2Wws)`Bx#Y*f6Yhx9hTld~NHROokM1M167S2L?MGJt? z<&&@-WTO{CYByv}kK%Vt;cGAKkabN(T9#PqFQ6ks=-80AFPi72u=j)CSLm3(I@lgw z{enoujV9{2x%v&0o5t zeWtkjDdsO*7v|@yT~W?A^3atT&;KLw5X@CZsgIto{s%gTNzci425>qG+Gc%uaMj(h zGDcFCYfUp1SP5gLyn2L5tndq2Yl>>|%OCB!l073BBm08Yr_KMnr-XTo)I^s%?9Tpf zhe)8V{$sg?u5fTXP~!-=qJvQaISY)&K>T(x*RK&>!q%Fj`0i+at)&sRCHQaV;`Nq8 zn4Yw^9doX=)W){-UCx}o-f{xdGM>U|@mfn2Y)hZn%+c#DvFyJKJCR}-ULA6B*p}X7 znFH5bhG7H>l&Y`z?--$1AJ1V%iR~xA@hI?r0`(EWMz{WaEiJw#(0zCBZ~2i{8^9{rYF~lVia$w;ZauWM#%F%Mm8KAf<|>E z1%v<6PQZ?$=&usH)$63UF{F2dok_2agFl9JR8T@@#5K}^gqW+fD^sPP{zv5z#yI&y zz02p3*Qglpp|76vyEtoH19ifJ1_Wh#UI)E{1znpfZMp`E)$BppeQrdZqDFSo*(cUHVi0hyjp`62nUGT5$5Y`s?U;IAi0}vFQ^^^UYVflbjq`p?R(adYs3dN2AkRP0IPXF)V zqt{bnb(UlCo!z%hXpV>-ho3UZT$Z#s#mV>R@KbIc@e*B*uaH z&jiChmM-VL^WR-Jn0bFNT+6<6+`M`!V7ZQA)#A;?vI^>&Y1hFRi1TEYa=6IMBDpKNL^5uKDm=h=g0m$Ap0|#?ri-*TvDET-TbE1KZ zs|)*i7lb)U48~Dgc}K&JJ_#`b;WS>(kf0HRL7eP3cwWg@@C;NqheTVn*ft;#&d9{g ztM(Qk9E7|ZF1D|9GU8y-mWNmly9yv2|Krfx7;eGcCLkQmo_EyPU$f!itw7k!6tFZ( z@$jq$onx`|{HdvMEaiSvV1Iv)jtWQI=@GW`m4slN;{&oZ3=~dCMmH`F4hpq6ptOyH fgL4C8zwmK%tZbLerCJQpS8&vowUufVtwR3~TzSSw literal 0 HcmV?d00001 diff --git a/PROJECTS/advanced/rveng/frontend/public/assets/apple-touch-icon.png b/PROJECTS/advanced/rveng/frontend/public/assets/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..54915be73d89ef74f929587324bb90d98dd0714d GIT binary patch literal 19669 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 literal 0 HcmV?d00001 diff --git a/PROJECTS/advanced/rveng/frontend/public/assets/favicon-32x32.png b/PROJECTS/advanced/rveng/frontend/public/assets/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..cd1b74063dc10ff8d79b455d0c0ed2dfdcf644fd GIT binary patch literal 1136 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 VeL(L6f93 +

+ + + ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/api/client.ts b/PROJECTS/advanced/rveng/frontend/src/api/client.ts new file mode 100644 index 00000000..9657d80d --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/api/client.ts @@ -0,0 +1,107 @@ +// =================== +// © AngelaMos | 2026 +// client.ts +// =================== + +import { API_ENDPOINTS } from '@/config' +import { apiClient } from '@/core/api' +import type { + CfgView, + ChallengeDetail, + ChallengeSummary, + DisasmView, + ElfView, + HexView, + ProgressView, + StringsView, + SubmitRequest, + SubmitResult, + Target, + XrefsView, +} from './types' + +export async function fetchChallenges(): Promise { + const { data } = await apiClient.get( + API_ENDPOINTS.CHALLENGES + ) + return data +} + +export async function fetchChallenge(cid: string): Promise { + const { data } = await apiClient.get( + API_ENDPOINTS.CHALLENGE(cid) + ) + return data +} + +export async function fetchHex( + cid: string, + offset: number, + length: number +): Promise { + const { data } = await apiClient.get(API_ENDPOINTS.HEX(cid), { + params: { offset, length }, + }) + return data +} + +export async function fetchElf(cid: string): Promise { + const { data } = await apiClient.get(API_ENDPOINTS.ELF(cid)) + return data +} + +export async function fetchDisasm( + cid: string, + target: Target, + session: string +): Promise { + const { data } = await apiClient.get(API_ENDPOINTS.DISASM(cid), { + params: { symbol: target.symbol, address: target.address, session }, + }) + return data +} + +export async function fetchCfg(cid: string, target: Target): Promise { + const { data } = await apiClient.get(API_ENDPOINTS.CFG(cid), { + params: { symbol: target.symbol, address: target.address }, + }) + return data +} + +export async function fetchXrefs( + cid: string, + target: number +): Promise { + const { data } = await apiClient.get(API_ENDPOINTS.XREFS(cid), { + params: { target }, + }) + return data +} + +export async function fetchStrings( + cid: string, + minLength: number +): Promise { + const { data } = await apiClient.get(API_ENDPOINTS.STRINGS(cid), { + params: { min_length: minLength }, + }) + return data +} + +export async function fetchProgress(session: string): Promise { + const { data } = await apiClient.get(API_ENDPOINTS.PROGRESS, { + params: { session }, + }) + return data +} + +export async function submitAnswer( + cid: string, + body: SubmitRequest +): Promise { + const { data } = await apiClient.post( + API_ENDPOINTS.SUBMIT(cid), + body + ) + return data +} diff --git a/PROJECTS/advanced/rveng/frontend/src/api/hooks/index.ts b/PROJECTS/advanced/rveng/frontend/src/api/hooks/index.ts new file mode 100644 index 00000000..990f7fb1 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/api/hooks/index.ts @@ -0,0 +1,119 @@ +// =================== +// © AngelaMos | 2026 +// index.ts +// =================== + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { QUERY_KEYS } from '@/config' +import { QUERY_STRATEGIES } from '@/core/api' +import { getSessionId } from '@/lib/session' +import { + fetchCfg, + fetchChallenge, + fetchChallenges, + fetchDisasm, + fetchElf, + fetchHex, + fetchProgress, + fetchStrings, + fetchXrefs, + submitAnswer, +} from '../client' +import type { SubmitResult, Target } from '../types' + +export function useChallenges() { + return useQuery({ + queryKey: QUERY_KEYS.CHALLENGES, + queryFn: fetchChallenges, + ...QUERY_STRATEGIES.static, + }) +} + +export function useChallenge(cid: string) { + return useQuery({ + queryKey: QUERY_KEYS.CHALLENGE(cid), + queryFn: () => fetchChallenge(cid), + enabled: cid.length > 0, + ...QUERY_STRATEGIES.static, + }) +} + +export function useHex(cid: string, offset: number, length: number) { + return useQuery({ + queryKey: QUERY_KEYS.HEX(cid, offset, length), + queryFn: () => fetchHex(cid, offset, length), + enabled: cid.length > 0, + ...QUERY_STRATEGIES.static, + }) +} + +export function useElf(cid: string) { + return useQuery({ + queryKey: QUERY_KEYS.ELF(cid), + queryFn: () => fetchElf(cid), + enabled: cid.length > 0, + ...QUERY_STRATEGIES.static, + }) +} + +export function useDisasm(cid: string, target: Target | null) { + const session = getSessionId() + return useQuery({ + queryKey: QUERY_KEYS.DISASM(cid, target?.label ?? '', session), + queryFn: () => fetchDisasm(cid, target as Target, session), + enabled: cid.length > 0 && target !== null, + ...QUERY_STRATEGIES.standard, + }) +} + +export function useCfg(cid: string, target: Target | null) { + return useQuery({ + queryKey: QUERY_KEYS.CFG(cid, target?.label ?? ''), + queryFn: () => fetchCfg(cid, target as Target), + enabled: cid.length > 0 && target !== null, + ...QUERY_STRATEGIES.static, + }) +} + +export function useXrefs(cid: string, target: number | null) { + return useQuery({ + queryKey: QUERY_KEYS.XREFS(cid, target ?? -1), + queryFn: () => fetchXrefs(cid, target as number), + enabled: cid.length > 0 && target !== null, + ...QUERY_STRATEGIES.static, + }) +} + +export function useStrings(cid: string, minLength: number) { + return useQuery({ + queryKey: QUERY_KEYS.STRINGS(cid, minLength), + queryFn: () => fetchStrings(cid, minLength), + enabled: cid.length > 0, + ...QUERY_STRATEGIES.static, + }) +} + +export function useProgress() { + const session = getSessionId() + return useQuery({ + queryKey: QUERY_KEYS.PROGRESS(session), + queryFn: () => fetchProgress(session), + ...QUERY_STRATEGIES.standard, + }) +} + +export function useSubmit(cid: string) { + const queryClient = useQueryClient() + const session = getSessionId() + return useMutation({ + mutationFn: (answer: string) => submitAnswer(cid, { answer, session }), + onSuccess: (result) => { + if (result.correct) { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.PROGRESS(session) }) + queryClient.invalidateQueries({ + queryKey: ['challenges', cid, 'disasm'], + }) + } + }, + }) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/api/index.ts b/PROJECTS/advanced/rveng/frontend/src/api/index.ts new file mode 100644 index 00000000..0840a7c1 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/api/index.ts @@ -0,0 +1,7 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './hooks' +export * from './types' diff --git a/PROJECTS/advanced/rveng/frontend/src/api/types/index.ts b/PROJECTS/advanced/rveng/frontend/src/api/types/index.ts new file mode 100644 index 00000000..2d8dede3 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/api/types/index.ts @@ -0,0 +1,136 @@ +// =================== +// © AngelaMos | 2026 +// index.ts +// =================== + +import type { Category } from '@/config' + +export interface ChallengeSummary { + id: string + module: string + title: string +} + +export interface ChallengeDetail { + id: string + module: string + title: string + mission: string + category: Category + size: number +} + +export interface HexView { + base: number + length: number + lines: string[] +} + +export interface FunctionView { + name: string + value: number + size: number +} + +export interface DiscoveredFunction { + address: number + label: string +} + +export interface SectionView { + index: number + name: string + type: string + addr: number + offset: number + size: number + flags: string +} + +export interface ElfView { + type: number + machine: number + entry: number + sections: SectionView[] + functions: FunctionView[] + discovered: DiscoveredFunction[] +} + +export interface InstructionView { + address: number + mnemonic: string + op_str: string + bytes: string + immediate: number | null + branch_target: number | null + rip_target: number | null + call_name: string | null + is_gate: boolean +} + +export interface DisasmView { + symbol: string + instructions: InstructionView[] + gate_address: number | null +} + +export interface CfgBlock { + start: number + end: number + instructions: number[] +} + +export interface CfgEdge { + src: number + dst: number + kind: string +} + +export interface CfgView { + symbol: string + blocks: CfgBlock[] + edges: CfgEdge[] +} + +export interface XrefView { + from_addr: number + to_addr: number + kind: string +} + +export interface XrefsView { + target: number + references: XrefView[] +} + +export interface Target { + label: string + address: number + symbol?: string +} + +export interface StringView { + offset: number + text: string +} + +export interface StringsView { + strings: StringView[] +} + +export interface SubmitRequest { + answer: string + session: string +} + +export interface SubmitResult { + correct: boolean + message: string + revealed_source: string | null +} + +export interface ProgressView { + session: string + solved: string[] + total: number +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/AnswerRunner.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/AnswerRunner.module.scss new file mode 100644 index 00000000..67f76d08 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/AnswerRunner.module.scss @@ -0,0 +1,77 @@ +// =================== +// © AngelaMos | 2026 +// AnswerRunner.module.scss +// =================== + +@use '../styles/tokens' as *; +@use '../styles/fonts' as *; + +.runner { + display: flex; + flex-direction: column; + gap: $space-3; +} + +.field { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.hint { + font-size: $font-size-sm; + color: $color-text-muted; +} + +.inputRow { + display: flex; + gap: $space-2; +} + +.input { + flex: 1; + padding: $space-2 $space-3; + font-family: $font-mono; + font-size: $font-size-sm; + border: 1px solid $color-border; + border-radius: $radius-sm; +} + +.inputRow button { + padding: $space-2 $space-4; + font-weight: $font-weight-medium; + color: $color-white; + background: $color-gray-900; + border: none; + border-radius: $radius-sm; + cursor: pointer; + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.correct, +.incorrect { + padding: $space-2 $space-3; + font-size: $font-size-sm; + border-radius: $radius-sm; +} + +.correct { + color: $color-gray-900; + background: $color-gray-200; + border: 1px solid $color-gray-400; +} + +.incorrect { + color: $color-gray-700; + background: $color-gray-100; + border: 1px solid $color-border; +} + +.solvedNote { + font-size: $font-size-sm; + color: $color-text-muted; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/AnswerRunner.tsx b/PROJECTS/advanced/rveng/frontend/src/components/AnswerRunner.tsx new file mode 100644 index 00000000..12faff6d --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/AnswerRunner.tsx @@ -0,0 +1,75 @@ +// =================== +// © AngelaMos | 2026 +// AnswerRunner.tsx +// =================== + +import { type FormEvent, useState } from 'react' +import { useSubmit } from '@/api/hooks' +import type { SubmitResult } from '@/api/types' +import { CATEGORY_HINT, type Category } from '@/config' +import styles from './AnswerRunner.module.scss' +import { SourceReveal } from './SourceReveal' + +interface AnswerRunnerProps { + cid: string + category: Category + solved: boolean +} + +export function AnswerRunner({ + cid, + category, + solved, +}: AnswerRunnerProps): React.ReactElement { + const [answer, setAnswer] = useState('') + const [result, setResult] = useState(null) + const submit = useSubmit(cid) + + const onSubmit = (event: FormEvent): void => { + event.preventDefault() + if (answer.trim().length === 0) { + return + } + submit.mutate(answer, { onSuccess: (data) => setResult(data) }) + } + + const pending = submit.isPending + const empty = answer.trim().length === 0 + + return ( +
+ + + {result && ( +
+ {result.message} +
+ )} + + {result?.correct && result.revealed_source && ( + + )} + + {solved && result === null && ( +

+ Already solved. Submit again to reveal the source. +

+ )} + + ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/CfgPane.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/CfgPane.module.scss new file mode 100644 index 00000000..0a368472 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/CfgPane.module.scss @@ -0,0 +1,95 @@ +// =================== +// © AngelaMos | 2026 +// CfgPane.module.scss +// =================== + +@use '../styles/tokens' as *; +@use '../styles/fonts' as *; + +.cfg { + display: flex; + flex-direction: column; + gap: $space-3; +} + +.summary { + font-size: $font-size-sm; + color: $color-text-muted; +} + +.blocks { + display: flex; + flex-direction: column; + gap: $space-3; +} + +.block { + display: flex; + flex-direction: column; + gap: $space-2; + padding: $space-3 $space-4; + background: $color-surface; + border: 1px solid $color-border; + border-left: 3px solid $color-gray-700; + border-radius: $radius-sm; +} + +.blockHead { + display: flex; + align-items: baseline; + gap: $space-3; +} + +.blockName { + font-weight: $font-weight-semibold; +} + +.blockRange { + font-family: $font-mono; + font-size: $font-size-sm; + color: $color-text-muted; +} + +.blockBody { + font-size: $font-size-sm; + color: $color-text-muted; +} + +.edgeRow { + display: flex; + flex-wrap: wrap; + gap: $space-2; +} + +.edge { + padding: $space-1 $space-2; + font-family: $font-mono; + font-size: $font-size-xs; + border: 1px solid $color-border; + border-radius: $radius-full; + color: $color-text; +} + +.taken { + border-color: $color-gray-600; + background: $color-gray-100; +} + +.fallthrough { + color: $color-text-muted; +} + +.jump { + border-color: $color-gray-500; +} + +.terminal { + font-size: $font-size-xs; + color: $color-text-muted; +} + +.state { + padding: $space-4; + color: $color-text-muted; + font-size: $font-size-sm; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/CfgPane.tsx b/PROJECTS/advanced/rveng/frontend/src/components/CfgPane.tsx new file mode 100644 index 00000000..cfd48960 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/CfgPane.tsx @@ -0,0 +1,79 @@ +// =================== +// © AngelaMos | 2026 +// CfgPane.tsx +// =================== + +import { useMemo } from 'react' +import { useCfg } from '@/api/hooks' +import type { Target } from '@/api/types' +import { formatAddr } from '@/lib/format' +import styles from './CfgPane.module.scss' + +interface CfgPaneProps { + cid: string + target: Target | null +} + +export function CfgPane({ cid, target }: CfgPaneProps): React.ReactElement { + const { data, isLoading, isError } = useCfg(cid, target) + + const outEdges = useMemo(() => { + const map = new Map() + for (const edge of data?.edges ?? []) { + const list = map.get(edge.src) ?? [] + list.push({ dst: edge.dst, kind: edge.kind }) + map.set(edge.src, list) + } + return map + }, [data]) + + if (target === null) { + return
No function selected
+ } + if (isLoading) { + return
Building graph...
+ } + if (isError || !data) { + return
Failed to build graph
+ } + + return ( +
+

+ {data.blocks.length} basic blocks, {data.edges.length} edges +

+
+ {data.blocks.map((block, index) => { + const edges = outEdges.get(block.start) ?? [] + return ( +
+
+ block {index} + + {formatAddr(block.start)} .. {formatAddr(block.end)} + +
+
+ {block.instructions.length} instructions +
+
+ {edges.length === 0 ? ( + terminal + ) : ( + edges.map((edge) => ( + + {edge.kind} to {formatAddr(edge.dst)} + + )) + )} +
+
+ ) + })} +
+
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/ChallengeCard.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/ChallengeCard.module.scss new file mode 100644 index 00000000..50fef78a --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/ChallengeCard.module.scss @@ -0,0 +1,60 @@ +// =================== +// © AngelaMos | 2026 +// ChallengeCard.module.scss +// =================== + +@use '../styles/tokens' as *; +@use '../styles/fonts' as *; + +.card { + display: flex; + flex-direction: column; + gap: $space-2; + padding: $space-4; + background: $color-bg; + border: 1px solid $color-border; + border-radius: $radius-md; + text-decoration: none; + color: inherit; + transition: + border-color $duration-fast $ease, + transform $duration-fast $ease; + + &:hover { + border-color: $color-gray-400; + transform: translateY(-2px); + } +} + +.top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.module { + font-size: $font-size-xs; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $color-text-muted; +} + +.solved { + padding: $space-1 $space-2; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + color: $color-gray-900; + background: $color-gray-200; + border-radius: $radius-full; +} + +.title { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; +} + +.id { + font-family: $font-mono; + font-size: $font-size-xs; + color: $color-text-muted; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/ChallengeCard.tsx b/PROJECTS/advanced/rveng/frontend/src/components/ChallengeCard.tsx new file mode 100644 index 00000000..10f751b1 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/ChallengeCard.tsx @@ -0,0 +1,30 @@ +// =================== +// © AngelaMos | 2026 +// ChallengeCard.tsx +// =================== + +import { Link } from 'react-router-dom' +import type { ChallengeSummary } from '@/api/types' +import { ROUTES } from '@/config' +import styles from './ChallengeCard.module.scss' + +interface ChallengeCardProps { + challenge: ChallengeSummary + solved: boolean +} + +export function ChallengeCard({ + challenge, + solved, +}: ChallengeCardProps): React.ReactElement { + return ( + +
+ {challenge.module} + {solved && Solved} +
+

{challenge.title}

+ {challenge.id} + + ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/DisasmPane.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/DisasmPane.module.scss new file mode 100644 index 00000000..d8e8bbea --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/DisasmPane.module.scss @@ -0,0 +1,133 @@ +// =================== +// © AngelaMos | 2026 +// DisasmPane.module.scss +// =================== + +@use '../styles/tokens' as *; +@use '../styles/fonts' as *; + +.disasm { + display: flex; + flex-direction: column; + gap: $space-3; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $space-4; +} + +.picker { + display: flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $color-text-muted; + + select { + padding: $space-1 $space-2; + font-family: $font-mono; + font-size: $font-size-sm; + border: 1px solid $color-border; + border-radius: $radius-sm; + background: $color-bg; + } +} + +.gateBanner { + padding: $space-1 $space-3; + font-family: $font-mono; + font-size: $font-size-sm; + color: $color-gray-900; + background: $color-gray-200; + border: 1px solid $color-gray-400; + border-radius: $radius-sm; + cursor: pointer; +} + +.listing { + overflow-x: auto; + padding: $space-2; + background: $color-gray-900; + border-radius: $radius-md; + font-family: $font-mono; + font-size: $font-size-sm; +} + +.row { + display: grid; + grid-template-columns: 6rem 9rem 4rem 1fr auto; + gap: $space-3; + align-items: center; + padding: $space-1 $space-2; + white-space: pre; + border-radius: $radius-sm; +} + +.gate { + background: $color-gray-700; + outline: 1px solid $color-gray-400; +} + +.addr { + color: $color-gray-500; +} + +.raw { + color: $color-gray-600; + overflow: hidden; + text-overflow: ellipsis; +} + +.mnemonic { + color: $color-white; + font-weight: $font-weight-semibold; +} + +.ops { + color: $color-gray-300; +} + +.callName { + color: $color-gray-100; + font-weight: $font-weight-semibold; +} + +.dataRef { + color: $color-gray-500; +} + +.callers { + font-family: $font-mono; + font-size: $font-size-sm; + color: $color-text-muted; +} + +.target { + justify-self: end; + padding: 0 $space-2; + font-family: $font-mono; + font-size: $font-size-xs; + color: $color-gray-400; + background: transparent; + border: 1px solid transparent; + border-radius: $radius-sm; +} + +.linkable { + color: $color-gray-100; + border-color: $color-gray-600; + cursor: pointer; + + &:hover { + background: $color-gray-800; + } +} + +.state { + padding: $space-4; + color: $color-text-muted; + font-size: $font-size-sm; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/DisasmPane.tsx b/PROJECTS/advanced/rveng/frontend/src/components/DisasmPane.tsx new file mode 100644 index 00000000..a0dac146 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/DisasmPane.tsx @@ -0,0 +1,112 @@ +// =================== +// © AngelaMos | 2026 +// DisasmPane.tsx +// =================== + +import { useMemo, useRef } from 'react' +import { useDisasm, useXrefs } from '@/api/hooks' +import type { Target } from '@/api/types' +import { formatAddr } from '@/lib/format' +import styles from './DisasmPane.module.scss' + +interface DisasmPaneProps { + cid: string + target: Target | null +} + +export function DisasmPane({ cid, target }: DisasmPaneProps): React.ReactElement { + const { data, isLoading, isError } = useDisasm(cid, target) + const xrefs = useXrefs(cid, target?.address ?? null) + const rowRefs = useRef>(new Map()) + + const addresses = useMemo( + () => new Set((data?.instructions ?? []).map((i) => i.address)), + [data] + ) + + if (target === null) { + return
No function selected
+ } + + const scrollTo = (address: number): void => { + rowRefs.current.get(address)?.scrollIntoView({ + block: 'center', + behavior: 'smooth', + }) + } + + const callers = (xrefs.data?.references ?? []).filter((r) => r.kind === 'call') + + return ( +
+
+ {data?.gate_address != null && ( + + )} + {callers.length > 0 && ( + + called by {callers.map((c) => formatAddr(c.from_addr)).join(', ')} + + )} +
+ + {isLoading &&
Disassembling...
} + {isError &&
Failed to disassemble
} + + {data && ( +
+ {data.instructions.map((ins) => { + const linkable = + ins.branch_target != null && addresses.has(ins.branch_target) + return ( +
{ + rowRefs.current.set(ins.address, el) + }} + className={`${styles.row} ${ins.is_gate ? styles.gate : ''}`} + > + {formatAddr(ins.address)} + {ins.bytes} + {ins.mnemonic} + + {ins.op_str} + {ins.call_name && ( + + {' '} + <{ins.call_name}> + + )} + {ins.rip_target != null && ( + + {' '} + # {formatAddr(ins.rip_target)} + + )} + + {ins.branch_target != null && ( + + )} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/HexViewer.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/HexViewer.module.scss new file mode 100644 index 00000000..e009ee96 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/HexViewer.module.scss @@ -0,0 +1,108 @@ +// =================== +// © AngelaMos | 2026 +// HexViewer.module.scss +// =================== + +@use '../styles/tokens' as *; +@use '../styles/fonts' as *; + +.hex { + display: flex; + flex-direction: column; + gap: $space-3; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $space-4; +} + +.pager { + display: flex; + gap: $space-2; + + button { + padding: $space-1 $space-3; + font-size: $font-size-sm; + border: 1px solid $color-border; + border-radius: $radius-sm; + background: $color-surface; + color: $color-text; + cursor: pointer; + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } +} + +.range { + font-family: $font-mono; + font-size: $font-size-sm; + color: $color-text; +} + +.total { + color: $color-text-muted; +} + +.jump { + display: flex; + gap: $space-2; + margin-left: auto; + + input { + width: 12rem; + padding: $space-1 $space-2; + font-family: $font-mono; + font-size: $font-size-sm; + border: 1px solid $color-border; + border-radius: $radius-sm; + } + + button { + padding: $space-1 $space-3; + font-size: $font-size-sm; + border: 1px solid $color-border; + border-radius: $radius-sm; + background: $color-surface; + cursor: pointer; + } +} + +.dump { + overflow-x: auto; + padding: $space-3; + background: $color-gray-900; + border-radius: $radius-md; + font-family: $font-mono; + font-size: $font-size-sm; + line-height: $line-height-normal; +} + +.row { + display: flex; + gap: $space-4; + white-space: pre; +} + +.gutter { + color: $color-gray-500; +} + +.bytes { + color: $color-gray-100; +} + +.ascii { + color: $color-gray-400; +} + +.state { + padding: $space-4; + color: $color-text-muted; + font-size: $font-size-sm; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/HexViewer.tsx b/PROJECTS/advanced/rveng/frontend/src/components/HexViewer.tsx new file mode 100644 index 00000000..6b3ef5ae --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/HexViewer.tsx @@ -0,0 +1,122 @@ +// =================== +// © AngelaMos | 2026 +// HexViewer.tsx +// =================== + +import { type FormEvent, useState } from 'react' +import { useHex } from '@/api/hooks' +import { HEX } from '@/config' +import { formatOffset } from '@/lib/format' +import styles from './HexViewer.module.scss' + +interface HexRow { + offset: string + bytes: string + ascii: string + raw: string +} + +const LINE_RE = /^([0-9a-f]{8}) {2}(.+?) {2}\|(.*)\|$/ + +function parseLine(line: string): HexRow { + const match = LINE_RE.exec(line) + if (match === null) { + return { offset: '', bytes: '', ascii: '', raw: line } + } + return { offset: match[1], bytes: match[2], ascii: match[3], raw: line } +} + +function parseTarget(text: string): number | null { + const token = text.trim().toLowerCase() + if (token.length === 0) { + return null + } + const value = token.startsWith('0x') + ? Number.parseInt(token.slice(2), 16) + : Number.parseInt(token, 10) + return Number.isNaN(value) || value < 0 ? null : value +} + +interface HexViewerProps { + cid: string + size: number +} + +export function HexViewer({ cid, size }: HexViewerProps): React.ReactElement { + const [offset, setOffset] = useState(0) + const [jump, setJump] = useState('') + const length = HEX.PAGE_LENGTH + const { data, isLoading, isError } = useHex(cid, offset, length) + + const canPrev = offset > 0 + const canNext = offset + length < size + + const gotoPrev = (): void => setOffset(Math.max(0, offset - length)) + const gotoNext = (): void => { + if (canNext) { + setOffset(offset + length) + } + } + + const onJump = (event: FormEvent): void => { + event.preventDefault() + const target = parseTarget(jump) + if (target === null) { + return + } + const aligned = Math.min(target, Math.max(0, size - 1)) + setOffset(aligned - (aligned % HEX.BYTES_PER_LINE)) + } + + return ( +
+
+
+ + +
+ + {data + ? `${formatOffset(data.base)} .. ${formatOffset(data.base + data.length)}` + : formatOffset(offset)} + of {size} bytes + +
+ setJump(e.target.value)} + placeholder="offset e.g. 0x2000" + aria-label="Jump to offset" + /> + +
+
+ + {isLoading &&
Loading hex...
} + {isError &&
Failed to load hex
} + + {data && ( +
+ {data.lines.map(parseLine).map((row) => ( +
+ {row.offset ? ( + <> + {row.offset} + {row.bytes} + {row.ascii} + + ) : ( + {row.raw} + )} +
+ ))} +
+ )} +
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/MissionPanel.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/MissionPanel.module.scss new file mode 100644 index 00000000..42a666be --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/MissionPanel.module.scss @@ -0,0 +1,65 @@ +// =================== +// © AngelaMos | 2026 +// MissionPanel.module.scss +// =================== + +@use '../styles/tokens' as *; + +.panel { + display: flex; + flex-direction: column; + gap: $space-3; + padding: $space-5; + background: $color-surface; + border: 1px solid $color-border; + border-radius: $radius-md; +} + +.head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.module { + font-size: $font-size-xs; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $color-text-muted; +} + +.solved { + padding: $space-1 $space-2; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + color: $color-gray-900; + background: $color-gray-200; + border-radius: $radius-full; +} + +.title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; +} + +.mission { + color: $color-text; + line-height: $line-height-relaxed; +} + +.meta { + display: flex; + gap: $space-3; + font-size: $font-size-sm; +} + +.category { + padding: $space-1 $space-2; + color: $color-text; + background: $color-gray-100; + border-radius: $radius-sm; +} + +.size { + color: $color-text-muted; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/MissionPanel.tsx b/PROJECTS/advanced/rveng/frontend/src/components/MissionPanel.tsx new file mode 100644 index 00000000..27b39680 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/MissionPanel.tsx @@ -0,0 +1,36 @@ +// =================== +// © AngelaMos | 2026 +// MissionPanel.tsx +// =================== + +import type { ChallengeDetail } from '@/api/types' +import { CATEGORY_LABEL } from '@/config' +import { formatBytes } from '@/lib/format' +import styles from './MissionPanel.module.scss' + +interface MissionPanelProps { + challenge: ChallengeDetail + solved: boolean +} + +export function MissionPanel({ + challenge, + solved, +}: MissionPanelProps): React.ReactElement { + return ( +
+
+ {challenge.module} + {solved && Solved} +
+

{challenge.title}

+

{challenge.mission}

+
+ + {CATEGORY_LABEL[challenge.category]} + + {formatBytes(challenge.size)} +
+
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/ProgressBar.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/ProgressBar.module.scss new file mode 100644 index 00000000..51a2fe34 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/ProgressBar.module.scss @@ -0,0 +1,33 @@ +// =================== +// © AngelaMos | 2026 +// ProgressBar.module.scss +// =================== + +@use '../styles/tokens' as *; + +.wrap { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.label { + display: flex; + justify-content: space-between; + font-size: $font-size-sm; + color: $color-text-muted; +} + +.track { + height: 0.5rem; + background: $color-gray-200; + border-radius: $radius-full; + overflow: hidden; +} + +.fill { + height: 100%; + background: $color-gray-700; + border-radius: $radius-full; + transition: width $duration-normal $ease; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/ProgressBar.tsx b/PROJECTS/advanced/rveng/frontend/src/components/ProgressBar.tsx new file mode 100644 index 00000000..eb7d2fc1 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/ProgressBar.tsx @@ -0,0 +1,31 @@ +// =================== +// © AngelaMos | 2026 +// ProgressBar.tsx +// =================== + +import styles from './ProgressBar.module.scss' + +interface ProgressBarProps { + solved: number + total: number +} + +export function ProgressBar({ + solved, + total, +}: ProgressBarProps): React.ReactElement { + const pct = total > 0 ? Math.round((solved / total) * 100) : 0 + return ( +
+
+ Progress + + {solved} / {total} + +
+
+
+
+
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/SectionMap.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/SectionMap.module.scss new file mode 100644 index 00000000..05043f74 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/SectionMap.module.scss @@ -0,0 +1,67 @@ +// =================== +// © AngelaMos | 2026 +// SectionMap.module.scss +// =================== + +@use '../styles/tokens' as *; +@use '../styles/fonts' as *; + +.wrap { + overflow-x: auto; + border: 1px solid $color-border; + border-radius: $radius-md; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: $font-size-sm; + + th, + td { + padding: $space-2 $space-3; + text-align: left; + border-bottom: 1px solid $color-border; + } + + th { + font-weight: $font-weight-semibold; + color: $color-text-muted; + background: $color-surface; + } + + tbody tr:last-child td { + border-bottom: none; + } +} + +.exec { + background: $color-gray-50; + + .name { + font-weight: $font-weight-semibold; + } +} + +.name { + font-family: $font-mono; +} + +.mono { + font-family: $font-mono; + color: $color-text-muted; +} + +.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.dim { + color: $color-text-muted; +} + +.flags { + font-family: $font-mono; + letter-spacing: 0.1em; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/SectionMap.tsx b/PROJECTS/advanced/rveng/frontend/src/components/SectionMap.tsx new file mode 100644 index 00000000..64fb86c3 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/SectionMap.tsx @@ -0,0 +1,48 @@ +// =================== +// © AngelaMos | 2026 +// SectionMap.tsx +// =================== + +import type { SectionView } from '@/api/types' +import { formatAddr, formatOffset } from '@/lib/format' +import styles from './SectionMap.module.scss' + +export function SectionMap({ + sections, +}: { + sections: SectionView[] +}): React.ReactElement { + return ( +
+ + + + + + + + + + + + + + {sections.map((section) => ( + + + + + + + + + + ))} + +
#NameTypeAddrOffsetSizeFlags
{section.index}{section.name || '(null)'}{section.type}{formatAddr(section.addr)}{formatOffset(section.offset)}{section.size}{section.flags || '-'}
+
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/SourceReveal.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/SourceReveal.module.scss new file mode 100644 index 00000000..7f3ea36f --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/SourceReveal.module.scss @@ -0,0 +1,39 @@ +// =================== +// © AngelaMos | 2026 +// SourceReveal.module.scss +// =================== + +@use '../styles/tokens' as *; +@use '../styles/fonts' as *; + +.reveal { + display: flex; + flex-direction: column; + gap: $space-2; + padding: $space-4; + background: $color-gray-900; + border-radius: $radius-md; +} + +.heading { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $color-gray-50; +} + +.note { + font-size: $font-size-sm; + color: $color-gray-400; +} + +.code { + overflow-x: auto; + padding: $space-3; + background: $color-black; + border-radius: $radius-sm; + font-family: $font-mono; + font-size: $font-size-sm; + line-height: $line-height-normal; + color: $color-gray-100; + white-space: pre; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/SourceReveal.tsx b/PROJECTS/advanced/rveng/frontend/src/components/SourceReveal.tsx new file mode 100644 index 00000000..fb555399 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/SourceReveal.tsx @@ -0,0 +1,18 @@ +// =================== +// © AngelaMos | 2026 +// SourceReveal.tsx +// =================== + +import styles from './SourceReveal.module.scss' + +export function SourceReveal({ source }: { source: string }): React.ReactElement { + return ( +
+

Source revealed

+

+ You reached the answer from the binary. Here is the C that produced it. +

+
{source}
+
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/StringsPane.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/StringsPane.module.scss new file mode 100644 index 00000000..d46e7175 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/StringsPane.module.scss @@ -0,0 +1,74 @@ +// =================== +// © AngelaMos | 2026 +// StringsPane.module.scss +// =================== + +@use '../styles/tokens' as *; +@use '../styles/fonts' as *; + +.strings { + display: flex; + flex-direction: column; + gap: $space-3; +} + +.toolbar { + display: flex; + align-items: center; + gap: $space-4; +} + +.control { + display: flex; + align-items: center; + gap: $space-3; + font-size: $font-size-sm; + color: $color-text-muted; + + input { + width: 12rem; + } +} + +.count { + margin-left: auto; + font-size: $font-size-sm; + color: $color-text-muted; +} + +.list { + max-height: 28rem; + overflow-y: auto; + border: 1px solid $color-border; + border-radius: $radius-md; + font-family: $font-mono; + font-size: $font-size-sm; +} + +.row { + display: grid; + grid-template-columns: 8rem 1fr; + gap: $space-4; + padding: $space-1 $space-3; + border-bottom: 1px solid $color-border; + + &:last-child { + border-bottom: none; + } +} + +.offset { + color: $color-text-muted; +} + +.text { + color: $color-text; + white-space: pre-wrap; + word-break: break-all; +} + +.state { + padding: $space-4; + color: $color-text-muted; + font-size: $font-size-sm; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/StringsPane.tsx b/PROJECTS/advanced/rveng/frontend/src/components/StringsPane.tsx new file mode 100644 index 00000000..407fae2b --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/StringsPane.tsx @@ -0,0 +1,49 @@ +// =================== +// © AngelaMos | 2026 +// StringsPane.tsx +// =================== + +import { useState } from 'react' +import { useStrings } from '@/api/hooks' +import { STRINGS } from '@/config' +import { formatOffset } from '@/lib/format' +import styles from './StringsPane.module.scss' + +export function StringsPane({ cid }: { cid: string }): React.ReactElement { + const [minLength, setMinLength] = useState(STRINGS.DEFAULT_MIN_LENGTH) + const { data, isLoading, isError } = useStrings(cid, minLength) + + return ( +
+
+ + {data && ( + {data.strings.length} strings + )} +
+ + {isLoading &&
Loading strings...
} + {isError &&
Failed to load strings
} + + {data && ( +
+ {data.strings.map((entry) => ( +
+ {formatOffset(entry.offset)} + {entry.text} +
+ ))} +
+ )} +
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/Workspace.module.scss b/PROJECTS/advanced/rveng/frontend/src/components/Workspace.module.scss new file mode 100644 index 00000000..ed1e7ab3 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/Workspace.module.scss @@ -0,0 +1,104 @@ +// =================== +// © AngelaMos | 2026 +// Workspace.module.scss +// =================== + +@use '../styles/tokens' as *; +@use '../styles/mixins' as *; + +.workspace { + display: grid; + grid-template-columns: minmax(20rem, 24rem) 1fr; + gap: $space-6; + align-items: start; + + @include breakpoint-down('lg') { + grid-template-columns: 1fr; + } +} + +.side { + display: flex; + flex-direction: column; + gap: $space-4; + position: sticky; + top: $space-4; + + @include breakpoint-down('lg') { + position: static; + } +} + +.runnerCard { + display: flex; + flex-direction: column; + gap: $space-3; + padding: $space-5; + background: $color-bg; + border: 1px solid $color-border; + border-radius: $radius-md; +} + +.runnerTitle { + font-size: $font-size-base; + font-weight: $font-weight-semibold; +} + +.analysis { + display: flex; + flex-direction: column; + gap: $space-4; + min-width: 0; +} + +.tabs { + display: flex; + gap: $space-1; + border-bottom: 1px solid $color-border; +} + +.tab { + padding: $space-2 $space-4; + font-size: $font-size-sm; + color: $color-text-muted; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + + &:hover { + color: $color-text; + } +} + +.activeTab { + color: $color-text; + font-weight: $font-weight-medium; + border-bottom-color: $color-gray-900; +} + +.picker { + display: flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $color-text-muted; + + select { + padding: $space-1 $space-2; + font-size: $font-size-sm; + border: 1px solid $color-border; + border-radius: $radius-sm; + background: $color-bg; + } +} + +.pane { + min-width: 0; +} + +.state { + padding: $space-6; + color: $color-text-muted; + font-size: $font-size-sm; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/Workspace.tsx b/PROJECTS/advanced/rveng/frontend/src/components/Workspace.tsx new file mode 100644 index 00000000..48aa37b7 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/Workspace.tsx @@ -0,0 +1,139 @@ +// =================== +// © AngelaMos | 2026 +// Workspace.tsx +// =================== + +import { useMemo, useState } from 'react' +import { useChallenge, useElf, useProgress } from '@/api/hooks' +import type { Target } from '@/api/types' +import { AnswerRunner } from './AnswerRunner' +import { CfgPane } from './CfgPane' +import { DisasmPane } from './DisasmPane' +import { HexViewer } from './HexViewer' +import { MissionPanel } from './MissionPanel' +import { SectionMap } from './SectionMap' +import { StringsPane } from './StringsPane' +import styles from './Workspace.module.scss' + +const TABS = ['hex', 'disasm', 'graph', 'sections', 'strings'] as const +type Tab = (typeof TABS)[number] + +const TAB_LABEL: Record = { + hex: 'Hex', + disasm: 'Disassembly', + graph: 'Graph', + sections: 'Sections', + strings: 'Strings', +} + +const PREFERRED = ['check', 'main', '_start'] + +function pickDefault(targets: Target[]): Target | null { + for (const name of PREFERRED) { + const hit = targets.find((t) => t.label === name) + if (hit) { + return hit + } + } + return targets[0] ?? null +} + +export function Workspace({ cid }: { cid: string }): React.ReactElement { + const [tab, setTab] = useState('hex') + const [targetKey, setTargetKey] = useState('') + const challenge = useChallenge(cid) + const elf = useElf(cid) + const progress = useProgress() + + const targets = useMemo(() => { + const named = (elf.data?.functions ?? []) + .filter((f) => f.size > 0 && f.value > 0) + .map((f) => ({ label: f.name, address: f.value, symbol: f.name })) + if (named.length > 0) { + return named + } + return (elf.data?.discovered ?? []).map((d) => ({ + label: d.label, + address: d.address, + })) + }, [elf.data]) + + if (challenge.isLoading) { + return
Loading challenge...
+ } + if (challenge.isError || !challenge.data) { + return
Challenge not found
+ } + + const detail = challenge.data + const solved = progress.data?.solved.includes(cid) ?? false + const selected = + targets.find((t) => t.label === targetKey) ?? pickDefault(targets) + const needsTarget = tab === 'disasm' || tab === 'graph' + + return ( +
+ + +
+
+ {TABS.map((item) => ( + + ))} +
+ + {needsTarget && targets.length > 0 && ( + + )} + +
+ {tab === 'hex' && } + {tab === 'disasm' && + (elf.isLoading ? ( +
Loading ELF...
+ ) : ( + + ))} + {tab === 'graph' && + (elf.isLoading ? ( +
Loading ELF...
+ ) : ( + + ))} + {tab === 'sections' && + (elf.isLoading ? ( +
Loading ELF...
+ ) : ( + + ))} + {tab === 'strings' && } +
+
+
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/components/index.tsx b/PROJECTS/advanced/rveng/frontend/src/components/index.tsx new file mode 100644 index 00000000..6b55e178 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/components/index.tsx @@ -0,0 +1,16 @@ +// =================== +// © AngelaMos | 2026 +// index.tsx +// =================== + +export * from './AnswerRunner' +export * from './CfgPane' +export * from './ChallengeCard' +export * from './DisasmPane' +export * from './HexViewer' +export * from './MissionPanel' +export * from './ProgressBar' +export * from './SectionMap' +export * from './SourceReveal' +export * from './StringsPane' +export * from './Workspace' diff --git a/PROJECTS/advanced/rveng/frontend/src/config.ts b/PROJECTS/advanced/rveng/frontend/src/config.ts new file mode 100644 index 00000000..abc77f62 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/config.ts @@ -0,0 +1,102 @@ +// =================== +// © AngelaMos | 2026 +// config.ts +// =================== + +export const API_ENDPOINTS = { + CHALLENGES: '/challenges', + CHALLENGE: (cid: string) => `/challenges/${cid}`, + HEX: (cid: string) => `/challenges/${cid}/hex`, + ELF: (cid: string) => `/challenges/${cid}/elf`, + DISASM: (cid: string) => `/challenges/${cid}/disasm`, + CFG: (cid: string) => `/challenges/${cid}/cfg`, + XREFS: (cid: string) => `/challenges/${cid}/xrefs`, + STRINGS: (cid: string) => `/challenges/${cid}/strings`, + SUBMIT: (cid: string) => `/challenges/${cid}/submit`, + PROGRESS: '/progress', +} as const + +export const QUERY_KEYS = { + CHALLENGES: ['challenges'] as const, + CHALLENGE: (cid: string) => ['challenges', cid] as const, + HEX: (cid: string, offset: number, length: number) => + ['challenges', cid, 'hex', offset, length] as const, + ELF: (cid: string) => ['challenges', cid, 'elf'] as const, + DISASM: (cid: string, label: string, session: string) => + ['challenges', cid, 'disasm', label, session] as const, + CFG: (cid: string, label: string) => ['challenges', cid, 'cfg', label] as const, + XREFS: (cid: string, target: number) => + ['challenges', cid, 'xrefs', target] as const, + STRINGS: (cid: string, minLength: number) => + ['challenges', cid, 'strings', minLength] as const, + PROGRESS: (session: string) => ['progress', session] as const, +} as const + +export const ROUTES = { + HOME: '/', + CHALLENGE: '/c/:cid', + challenge: (cid: string) => `/c/${cid}`, +} as const + +export const STORAGE_KEYS = { + UI: 'rveng-ui', + SESSION: 'rveng-session', +} as const + +export const CATEGORY = { + FOUND_VALUE: 'found_value', + IDENTIFIED_SYMBOL: 'identified_symbol', + PATCHED_BYTES: 'patched_bytes', +} as const + +export type Category = (typeof CATEGORY)[keyof typeof CATEGORY] + +export const CATEGORY_LABEL: Record = { + [CATEGORY.FOUND_VALUE]: 'Find a value', + [CATEGORY.IDENTIFIED_SYMBOL]: 'Name a symbol', + [CATEGORY.PATCHED_BYTES]: 'Patch bytes', +} + +export const CATEGORY_HINT: Record = { + [CATEGORY.FOUND_VALUE]: 'Submit the value in decimal or hex (0x...)', + [CATEGORY.IDENTIFIED_SYMBOL]: 'Submit the exact symbol name', + [CATEGORY.PATCHED_BYTES]: 'Submit the replacement bytes as hex (e.g. 9090)', +} + +export const HEX = { + DEFAULT_LENGTH: 256, + PAGE_LENGTH: 256, + MAX_LENGTH: 4096, + BYTES_PER_LINE: 16, +} as const + +export const STRINGS = { + DEFAULT_MIN_LENGTH: 4, + MIN_MIN_LENGTH: 1, + MAX_MIN_LENGTH: 64, +} as const + +export const QUERY_CONFIG = { + STALE_TIME: { + STATIC: Number.POSITIVE_INFINITY, + DEFAULT: 1000 * 60 * 5, + FREQUENT: 1000 * 30, + }, + GC_TIME: { + DEFAULT: 1000 * 60 * 30, + LONG: 1000 * 60 * 60, + }, + RETRY: { + DEFAULT: 2, + NONE: 0, + }, +} as const + +export const HTTP_STATUS = { + OK: 200, + BAD_REQUEST: 400, + NOT_FOUND: 404, + PAYLOAD_TOO_LARGE: 413, + UNPROCESSABLE: 422, + INTERNAL_SERVER: 500, +} as const diff --git a/PROJECTS/advanced/rveng/frontend/src/core/api/api.config.ts b/PROJECTS/advanced/rveng/frontend/src/core/api/api.config.ts new file mode 100644 index 00000000..9f8bc734 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/api/api.config.ts @@ -0,0 +1,22 @@ +// =================== +// © AngelaMos | 2026 +// api.config.ts +// =================== + +import axios, { type AxiosInstance } from 'axios' +import { transformAxiosError } from './errors' + +const getBaseURL = (): string => { + return import.meta.env.VITE_API_URL ?? '/api' +} + +export const apiClient: AxiosInstance = axios.create({ + baseURL: getBaseURL(), + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, +}) + +apiClient.interceptors.response.use( + (response) => response, + (error) => Promise.reject(transformAxiosError(error)) +) diff --git a/PROJECTS/advanced/rveng/frontend/src/core/api/errors.ts b/PROJECTS/advanced/rveng/frontend/src/core/api/errors.ts new file mode 100644 index 00000000..26982793 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/api/errors.ts @@ -0,0 +1,116 @@ +/** + * ©AngelaMos | 2025 + * errors.ts + */ + +import type { AxiosError } from 'axios' + +export const ApiErrorCode = { + NETWORK_ERROR: 'NETWORK_ERROR', + VALIDATION_ERROR: 'VALIDATION_ERROR', + AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR', + AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + RATE_LIMITED: 'RATE_LIMITED', + SERVER_ERROR: 'SERVER_ERROR', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', +} as const + +export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode] + +export class ApiError extends Error { + readonly code: ApiErrorCode + readonly statusCode: number + readonly details?: Record + + constructor( + message: string, + code: ApiErrorCode, + statusCode: number, + details?: Record + ) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = statusCode + this.details = details + } + + getUserMessage(): string { + const messages: Record = { + [ApiErrorCode.NETWORK_ERROR]: + 'Unable to connect. Please check your internet connection.', + [ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.', + [ApiErrorCode.AUTHENTICATION_ERROR]: + 'Your session has expired. Please log in again.', + [ApiErrorCode.AUTHORIZATION_ERROR]: + 'You do not have permission to perform this action.', + [ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.', + [ApiErrorCode.CONFLICT]: + 'This operation conflicts with an existing resource.', + [ApiErrorCode.RATE_LIMITED]: + 'Too many requests. Please wait a moment and try again.', + [ApiErrorCode.SERVER_ERROR]: + 'Something went wrong on our end. Please try again later.', + [ApiErrorCode.UNKNOWN_ERROR]: + 'An unexpected error occurred. Please try again.', + } + return messages[this.code] + } +} + +interface ApiErrorResponse { + detail?: string | { msg: string; type: string }[] + message?: string +} + +export function transformAxiosError(error: AxiosError): ApiError { + if (!error.response) { + return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0) + } + + const { status } = error.response + const data = error.response.data as ApiErrorResponse | undefined + let message = 'An error occurred' + let details: Record | undefined + + if (data?.detail) { + if (typeof data.detail === 'string') { + message = data.detail + } else if (Array.isArray(data.detail)) { + details = { validation: [] } + data.detail.forEach((err) => { + details?.validation.push(err.msg) + }) + message = 'Validation error' + } + } else if (data?.message) { + message = data.message + } + + const codeMap: Record = { + 400: ApiErrorCode.VALIDATION_ERROR, + 401: ApiErrorCode.AUTHENTICATION_ERROR, + 403: ApiErrorCode.AUTHORIZATION_ERROR, + 404: ApiErrorCode.NOT_FOUND, + 409: ApiErrorCode.CONFLICT, + 413: ApiErrorCode.VALIDATION_ERROR, + 422: ApiErrorCode.VALIDATION_ERROR, + 429: ApiErrorCode.RATE_LIMITED, + 500: ApiErrorCode.SERVER_ERROR, + 502: ApiErrorCode.SERVER_ERROR, + 503: ApiErrorCode.SERVER_ERROR, + 504: ApiErrorCode.SERVER_ERROR, + } + + const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR + + return new ApiError(message, code, status, details) +} + +declare module '@tanstack/react-query' { + interface Register { + defaultError: ApiError + } +} diff --git a/PROJECTS/advanced/rveng/frontend/src/core/api/index.ts b/PROJECTS/advanced/rveng/frontend/src/core/api/index.ts new file mode 100644 index 00000000..1818cd7e --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/api/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './api.config' +export * from './errors' +export * from './query.config' diff --git a/PROJECTS/advanced/rveng/frontend/src/core/api/query.config.ts b/PROJECTS/advanced/rveng/frontend/src/core/api/query.config.ts new file mode 100644 index 00000000..d6590232 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/api/query.config.ts @@ -0,0 +1,93 @@ +// =================== +// © AngelaMos | 2026 +// query.config.ts +// =================== + +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { QUERY_CONFIG } from '@/config' +import { ApiError, ApiErrorCode } from './errors' + +const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [ + ApiErrorCode.NOT_FOUND, + ApiErrorCode.VALIDATION_ERROR, +] as const + +const shouldRetryQuery = (failureCount: number, error: Error): boolean => { + if (error instanceof ApiError) { + if (NO_RETRY_ERROR_CODES.includes(error.code)) { + return false + } + } + return failureCount < QUERY_CONFIG.RETRY.DEFAULT +} + +const calculateRetryDelay = (attemptIndex: number): number => { + const baseDelay = 500 + const maxDelay = 5000 + return Math.min(baseDelay * 2 ** attemptIndex, maxDelay) +} + +const handleQueryCacheError = ( + error: Error, + query: { state: { data: unknown } } +): void => { + if (query.state.data !== undefined) { + const message = + error instanceof ApiError + ? error.getUserMessage() + : 'Background update failed' + toast.error(message) + } +} + +const handleMutationCacheError = ( + error: Error, + _variables: unknown, + _context: unknown, + mutation: { options: { onError?: unknown } } +): void => { + if (mutation.options.onError === undefined) { + const message = + error instanceof ApiError ? error.getUserMessage() : 'Operation failed' + toast.error(message) + } +} + +export const QUERY_STRATEGIES = { + standard: { + staleTime: QUERY_CONFIG.STALE_TIME.DEFAULT, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + }, + static: { + staleTime: QUERY_CONFIG.STALE_TIME.STATIC, + gcTime: QUERY_CONFIG.GC_TIME.LONG, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, +} as const + +export type QueryStrategy = keyof typeof QUERY_STRATEGIES + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: QUERY_CONFIG.STALE_TIME.DEFAULT, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: shouldRetryQuery, + retryDelay: calculateRetryDelay, + refetchOnWindowFocus: false, + refetchOnMount: true, + refetchOnReconnect: true, + }, + mutations: { + retry: QUERY_CONFIG.RETRY.NONE, + }, + }, + queryCache: new QueryCache({ + onError: handleQueryCacheError, + }), + mutationCache: new MutationCache({ + onError: handleMutationCacheError, + }), +}) diff --git a/PROJECTS/advanced/rveng/frontend/src/core/app/routers.tsx b/PROJECTS/advanced/rveng/frontend/src/core/app/routers.tsx new file mode 100644 index 00000000..e8b12abb --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/app/routers.tsx @@ -0,0 +1,28 @@ +// =================== +// © AngelaMos | 2026 +// routers.tsx +// =================== + +import { createBrowserRouter } from 'react-router-dom' +import { ROUTES } from '@/config' +import { Shell } from './shell' + +export const router = createBrowserRouter([ + { + element: , + children: [ + { + path: ROUTES.HOME, + lazy: () => import('@/pages/browser'), + }, + { + path: ROUTES.CHALLENGE, + lazy: () => import('@/pages/workspace'), + }, + { + path: '*', + lazy: () => import('@/pages/browser'), + }, + ], + }, +]) diff --git a/PROJECTS/advanced/rveng/frontend/src/core/app/shell.module.scss b/PROJECTS/advanced/rveng/frontend/src/core/app/shell.module.scss new file mode 100644 index 00000000..a58244fe --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/app/shell.module.scss @@ -0,0 +1,62 @@ +// =================== +// © AngelaMos | 2026 +// shell.module.scss +// =================== + +@use '../../styles/tokens' as *; +@use '../../styles/fonts' as *; + +.shell { + display: flex; + flex-direction: column; + min-height: 100vh; + min-height: 100dvh; +} + +.header { + display: flex; + align-items: baseline; + gap: $space-3; + padding: $space-4 $space-6; + border-bottom: 1px solid $color-border; + background: $color-bg; +} + +.brand { + font-family: $font-mono; + font-size: $font-size-xl; + font-weight: $font-weight-bold; + color: $color-text; + text-decoration: none; +} + +.tag { + font-size: $font-size-sm; + color: $color-text-muted; +} + +.content { + flex: 1; + padding: $space-6; +} + +.loading { + padding: $space-8; + color: $color-text-muted; +} + +.error { + display: flex; + flex-direction: column; + gap: $space-3; + padding: $space-6; + + pre { + padding: $space-3; + background: $color-gray-100; + border-radius: $radius-sm; + font-family: $font-mono; + font-size: $font-size-sm; + overflow-x: auto; + } +} diff --git a/PROJECTS/advanced/rveng/frontend/src/core/app/shell.tsx b/PROJECTS/advanced/rveng/frontend/src/core/app/shell.tsx new file mode 100644 index 00000000..8d2ac468 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/app/shell.tsx @@ -0,0 +1,44 @@ +/** + * ©AngelaMos | 2026 + * shell.tsx + */ + +import { Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { Link, Outlet } from 'react-router-dom' +import { ROUTES } from '@/config' +import styles from './shell.module.scss' + +function ShellErrorFallback({ error }: { error: Error }): React.ReactElement { + return ( +
+

Something went wrong

+
{error.message}
+
+ ) +} + +function ShellLoading(): React.ReactElement { + return
Loading...
+} + +export function Shell(): React.ReactElement { + return ( +
+
+ + rveng + + reverse-engineering lab +
+ +
+ + }> + + + +
+
+ ) +} diff --git a/PROJECTS/advanced/rveng/frontend/src/core/app/toast.module.scss b/PROJECTS/advanced/rveng/frontend/src/core/app/toast.module.scss new file mode 100644 index 00000000..62a8ee32 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/app/toast.module.scss @@ -0,0 +1,4 @@ +// =================== +// © AngelaMos | 2026 +// toast.module.scss +// =================== diff --git a/PROJECTS/advanced/rveng/frontend/src/core/lib/index.ts b/PROJECTS/advanced/rveng/frontend/src/core/lib/index.ts new file mode 100644 index 00000000..cdee46e2 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/lib/index.ts @@ -0,0 +1,6 @@ +// =================== +// © AngelaMos | 2026 +// index.ts +// =================== + +export * from './shell.ui.store' diff --git a/PROJECTS/advanced/rveng/frontend/src/core/lib/shell.ui.store.ts b/PROJECTS/advanced/rveng/frontend/src/core/lib/shell.ui.store.ts new file mode 100644 index 00000000..d601a53b --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/core/lib/shell.ui.store.ts @@ -0,0 +1,63 @@ +/** + * ©AngelaMos | 2025 + * ui.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +type Theme = 'light' | 'dark' | 'system' + +interface UIState { + theme: Theme + sidebarOpen: boolean + sidebarCollapsed: boolean + setTheme: (theme: Theme) => void + toggleSidebar: () => void + setSidebarOpen: (open: boolean) => void + toggleSidebarCollapsed: () => void +} + +export const useUIStore = create()( + devtools( + persist( + (set) => ({ + theme: 'dark', + sidebarOpen: false, + sidebarCollapsed: false, + + setTheme: (theme) => set({ theme }, false, 'ui/setTheme'), + + toggleSidebar: () => + set( + (state) => ({ sidebarOpen: !state.sidebarOpen }), + false, + 'ui/toggleSidebar' + ), + + setSidebarOpen: (open) => + set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'), + + toggleSidebarCollapsed: () => + set( + (state) => ({ sidebarCollapsed: !state.sidebarCollapsed }), + false, + 'ui/toggleSidebarCollapsed' + ), + }), + { + name: 'ui-storage', + partialize: (state) => ({ + theme: state.theme, + sidebarCollapsed: state.sidebarCollapsed, + }), + } + ), + { name: 'UIStore' } + ) +) + +export const useTheme = (): Theme => useUIStore((s) => s.theme) +export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen) +export const useSidebarCollapsed = (): boolean => + useUIStore((s) => s.sidebarCollapsed) diff --git a/PROJECTS/advanced/rveng/frontend/src/lib/format.ts b/PROJECTS/advanced/rveng/frontend/src/lib/format.ts new file mode 100644 index 00000000..3f60dde0 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/lib/format.ts @@ -0,0 +1,48 @@ +// =================== +// © AngelaMos | 2026 +// format.ts +// =================== + +export function toHex(value: number, pad = 0): string { + const body = value.toString(16).padStart(pad, '0') + return `0x${body}` +} + +export function formatAddr(value: number): string { + return toHex(value, 6) +} + +export function formatOffset(value: number): string { + return toHex(value, 8) +} + +const ELF_TYPES: Record = { + 0: 'NONE', + 1: 'REL', + 2: 'EXEC', + 3: 'DYN', + 4: 'CORE', +} + +const ELF_MACHINES: Record = { + 3: 'x86', + 40: 'ARM', + 62: 'x86-64', + 183: 'AArch64', + 243: 'RISC-V', +} + +export function elfTypeName(value: number): string { + return ELF_TYPES[value] ?? toHex(value) +} + +export function elfMachineName(value: number): string { + return ELF_MACHINES[value] ?? toHex(value) +} + +export function formatBytes(count: number): string { + if (count < 1024) { + return `${count} B` + } + return `${(count / 1024).toFixed(1)} KiB` +} diff --git a/PROJECTS/advanced/rveng/frontend/src/lib/session.ts b/PROJECTS/advanced/rveng/frontend/src/lib/session.ts new file mode 100644 index 00000000..2ff1da9c --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/lib/session.ts @@ -0,0 +1,35 @@ +// =================== +// © AngelaMos | 2026 +// session.ts +// =================== + +import { STORAGE_KEYS } from '@/config' + +function createId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + return `s-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}` +} + +let cached: string | null = null + +export function getSessionId(): string { + if (cached !== null) { + return cached + } + try { + const existing = localStorage.getItem(STORAGE_KEYS.SESSION) + if (existing) { + cached = existing + return existing + } + const fresh = createId() + localStorage.setItem(STORAGE_KEYS.SESSION, fresh) + cached = fresh + return fresh + } catch { + cached = createId() + return cached + } +} diff --git a/PROJECTS/advanced/rveng/frontend/src/main.tsx b/PROJECTS/advanced/rveng/frontend/src/main.tsx new file mode 100644 index 00000000..1b1f2c8a --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/main.tsx @@ -0,0 +1,15 @@ +// =========================== +// ©AngelaMos | 2025 +// main.tsx +// =========================== + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './styles.scss' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/PROJECTS/advanced/rveng/frontend/src/pages/browser/browser.module.scss b/PROJECTS/advanced/rveng/frontend/src/pages/browser/browser.module.scss new file mode 100644 index 00000000..175a40d2 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/pages/browser/browser.module.scss @@ -0,0 +1,44 @@ +// =================== +// © AngelaMos | 2026 +// browser.module.scss +// =================== + +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.page { + display: flex; + flex-direction: column; + gap: $space-6; + max-width: $breakpoint-xl; + margin: 0 auto; +} + +.header { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.title { + font-size: $font-size-3xl; + font-weight: $font-weight-bold; +} + +.subtitle { + color: $color-text-muted; +} + +.progress { + max-width: 24rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: $space-4; +} + +.state { + color: $color-text-muted; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/pages/browser/index.tsx b/PROJECTS/advanced/rveng/frontend/src/pages/browser/index.tsx new file mode 100644 index 00000000..19d4b023 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/pages/browser/index.tsx @@ -0,0 +1,53 @@ +// =================== +// © AngelaMos | 2026 +// index.tsx +// =================== + +import { useChallenges, useProgress } from '@/api/hooks' +import { ChallengeCard, ProgressBar } from '@/components' +import styles from './browser.module.scss' + +export function Component(): React.ReactElement { + const challenges = useChallenges() + const progress = useProgress() + const solvedSet = new Set(progress.data?.solved ?? []) + + return ( +
+
+

Challenges

+

+ Read the binary. Reach the answer. Reveal the source. +

+
+ + {progress.data && ( +
+ +
+ )} + + {challenges.isLoading &&

Loading...

} + {challenges.isError && ( +

Failed to load challenges

+ )} + + {challenges.data && ( +
+ {challenges.data.map((challenge) => ( + + ))} +
+ )} +
+ ) +} + +Component.displayName = 'Browser' diff --git a/PROJECTS/advanced/rveng/frontend/src/pages/workspace/index.tsx b/PROJECTS/advanced/rveng/frontend/src/pages/workspace/index.tsx new file mode 100644 index 00000000..f17a1e31 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/pages/workspace/index.tsx @@ -0,0 +1,28 @@ +// =================== +// © AngelaMos | 2026 +// index.tsx +// =================== + +import { Link, useParams } from 'react-router-dom' +import { Workspace } from '@/components' +import { ROUTES } from '@/config' +import styles from './workspace.module.scss' + +export function Component(): React.ReactElement { + const { cid } = useParams<{ cid: string }>() + + if (!cid) { + return
No challenge selected
+ } + + return ( +
+ + Back to challenges + + +
+ ) +} + +Component.displayName = 'ChallengeWorkspace' diff --git a/PROJECTS/advanced/rveng/frontend/src/pages/workspace/workspace.module.scss b/PROJECTS/advanced/rveng/frontend/src/pages/workspace/workspace.module.scss new file mode 100644 index 00000000..cf482729 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/pages/workspace/workspace.module.scss @@ -0,0 +1,24 @@ +// =================== +// © AngelaMos | 2026 +// workspace.module.scss +// =================== + +@use '../../styles/tokens' as *; + +.page { + display: flex; + flex-direction: column; + gap: $space-4; +} + +.back { + align-self: flex-start; + font-size: $font-size-sm; + color: $color-text-muted; + text-decoration: none; + + &:hover { + color: $color-text; + text-decoration: underline; + } +} diff --git a/PROJECTS/advanced/rveng/frontend/src/styles.scss b/PROJECTS/advanced/rveng/frontend/src/styles.scss new file mode 100644 index 00000000..d3afe02d --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/styles.scss @@ -0,0 +1,31 @@ +// =================== +// © AngelaMos | 2026 +// styles.scss +// =================== + +@forward 'styles/tokens'; +@forward 'styles/fonts'; +@forward 'styles/mixins'; + +@use 'styles/reset'; +@use 'styles/tokens' as *; +@use 'styles/fonts' as *; + +body { + font-family: $font-sans; + color: $color-text; + background-color: $color-bg; +} + +#root { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; +} + +.app { + flex: 1; + display: flex; + flex-direction: column; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/styles/_fonts.scss b/PROJECTS/advanced/rveng/frontend/src/styles/_fonts.scss new file mode 100644 index 00000000..049e125a --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/styles/_fonts.scss @@ -0,0 +1,9 @@ +// =================== +// © AngelaMos | 2026 +// _fonts.scss +// =================== + +$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, + Arial, sans-serif; +$font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', + monospace; diff --git a/PROJECTS/advanced/rveng/frontend/src/styles/_index.scss b/PROJECTS/advanced/rveng/frontend/src/styles/_index.scss new file mode 100644 index 00000000..9d5d028c --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/styles/_index.scss @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2026 +// _index.scss +// =================== + +@forward 'tokens'; +@forward 'fonts'; +@forward 'mixins'; diff --git a/PROJECTS/advanced/rveng/frontend/src/styles/_mixins.scss b/PROJECTS/advanced/rveng/frontend/src/styles/_mixins.scss new file mode 100644 index 00000000..aef18b11 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/styles/_mixins.scss @@ -0,0 +1,42 @@ +// =================== +// © AngelaMos | 2026 +// _mixins.scss +// =================== + +@use 'sass:map'; +@use 'tokens' as *; + +$breakpoints: ( + 'xs': $breakpoint-xs, + 'sm': $breakpoint-sm, + 'md': $breakpoint-md, + 'lg': $breakpoint-lg, + 'xl': $breakpoint-xl, + '2xl': $breakpoint-2xl, +); + +@mixin breakpoint-up($size) { + @media (min-width: map.get($breakpoints, $size)) { @content; } +} + +@mixin breakpoint-down($size) { + @media (max-width: map.get($breakpoints, $size)) { @content; } +} + +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/styles/_reset.scss b/PROJECTS/advanced/rveng/frontend/src/styles/_reset.scss new file mode 100644 index 00000000..71e9d060 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/styles/_reset.scss @@ -0,0 +1,75 @@ +// =================== +// © AngelaMos | 2026 +// _reset.scss +// =================== + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; +} + +body { + min-height: 100vh; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; + background: none; + border: none; +} + +a { + color: inherit; + text-decoration: none; +} + +ul, +ol { + list-style: none; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +#root { + isolation: isolate; +} diff --git a/PROJECTS/advanced/rveng/frontend/src/styles/_tokens.scss b/PROJECTS/advanced/rveng/frontend/src/styles/_tokens.scss new file mode 100644 index 00000000..7880360e --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/src/styles/_tokens.scss @@ -0,0 +1,85 @@ +// =================== +// © AngelaMos | 2026 +// _tokens.scss +// =================== +// +// Neutral baseline. No brand identity -- grayscale palette, system type, plain +// spacing scale. Design on top of these; nothing here implies a look. + +// Spacing (8px base) +$space-0: 0; +$space-1: 0.25rem; +$space-2: 0.5rem; +$space-3: 0.75rem; +$space-4: 1rem; +$space-5: 1.25rem; +$space-6: 1.5rem; +$space-8: 2rem; +$space-10: 2.5rem; +$space-12: 3rem; +$space-16: 4rem; +$space-20: 5rem; +$space-24: 6rem; + +// Font sizes +$font-size-xs: 0.75rem; +$font-size-sm: 0.875rem; +$font-size-base: 1rem; +$font-size-lg: 1.125rem; +$font-size-xl: 1.25rem; +$font-size-2xl: 1.5rem; +$font-size-3xl: 1.875rem; +$font-size-4xl: 2.25rem; + +// Weights / line-height / radius +$font-weight-normal: 400; +$font-weight-medium: 500; +$font-weight-semibold: 600; +$font-weight-bold: 700; +$line-height-tight: 1.25; +$line-height-normal: 1.5; +$line-height-relaxed: 1.75; +$radius-sm: 0.25rem; +$radius-md: 0.5rem; +$radius-lg: 0.75rem; +$radius-full: 9999px; + +// Neutral grayscale +$color-white: #ffffff; +$color-black: #000000; +$color-gray-50: #fafafa; +$color-gray-100: #f4f4f5; +$color-gray-200: #e4e4e7; +$color-gray-300: #d4d4d8; +$color-gray-400: #a1a1aa; +$color-gray-500: #71717a; +$color-gray-600: #52525b; +$color-gray-700: #3f3f46; +$color-gray-800: #27272a; +$color-gray-900: #18181b; + +// Neutral semantic aliases +$color-bg: $color-white; +$color-surface: $color-gray-50; +$color-border: $color-gray-200; +$color-text: $color-gray-900; +$color-text-muted: $color-gray-500; + +// Breakpoints +$breakpoint-xs: 480px; +$breakpoint-sm: 640px; +$breakpoint-md: 768px; +$breakpoint-lg: 1024px; +$breakpoint-xl: 1280px; +$breakpoint-2xl: 1536px; + +// Z-index +$z-base: 1; +$z-dropdown: 1000; +$z-modal: 1300; +$z-toast: 1400; + +// Motion +$duration-fast: 150ms; +$duration-normal: 250ms; +$ease: cubic-bezier(0.4, 0, 0.2, 1); diff --git a/PROJECTS/advanced/rveng/frontend/stylelint.config.js b/PROJECTS/advanced/rveng/frontend/stylelint.config.js new file mode 100644 index 00000000..56ebd7bc --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/stylelint.config.js @@ -0,0 +1,107 @@ +// ©AngelaMos | 2025 +// stylelint.config.js + +/** @type {import('stylelint').Config} */ +export default { + extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'], + rules: { + 'block-no-empty': true, + 'declaration-no-important': true, + 'color-no-invalid-hex': true, + 'property-no-unknown': true, + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['global'], + }, + ], + + 'selector-class-pattern': [ + '^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$', + { + message: + 'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)', + }, + ], + + 'value-keyword-case': [ + 'lower', + { + camelCaseSvgKeywords: true, + ignoreKeywords: [ + 'BlinkMacSystemFont', + 'SFMono-Regular', + 'Menlo', + 'Monaco', + 'Consolas', + 'Roboto', + 'Arial', + 'Helvetica', + 'Times', + 'Georgia', + 'Verdana', + 'Tahoma', + 'Trebuchet', + 'Impact', + 'Comic', + ], + }, + ], + + 'property-no-vendor-prefix': [ + true, + { + ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'], + }, + ], + 'value-no-vendor-prefix': true, + 'selector-no-vendor-prefix': true, + + 'property-no-deprecated': [ + true, + { + ignoreProperties: ['clip'], + }, + ], + + 'container-name-pattern': null, + 'layer-name-pattern': null, + + 'scss/at-rule-no-unknown': true, + 'scss/declaration-nested-properties-no-divided-groups': true, + 'scss/dollar-variable-no-missing-interpolation': true, + 'scss/dollar-variable-empty-line-before': null, + + 'declaration-empty-line-before': null, + 'custom-property-empty-line-before': null, + + 'no-descending-specificity': null, + + 'media-feature-name-no-unknown': [ + true, + { + ignoreMediaFeatureNames: ['map'], + }, + ], + + 'color-function-notation': null, + 'hue-degree-notation': null, + }, + ignoreFiles: [ + 'node_modules/**', + 'dist/**', + 'build/**', + '**/*.js', + '**/*.ts', + '**/*.tsx', + ], + overrides: [ + { + files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'], + rules: { + 'declaration-no-important': null, + 'scss/comment-no-empty': null, + }, + }, + ], +} diff --git a/PROJECTS/advanced/rveng/frontend/tsconfig.app.json b/PROJECTS/advanced/rveng/frontend/tsconfig.app.json new file mode 100644 index 00000000..afe17f60 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/PROJECTS/advanced/rveng/frontend/tsconfig.json b/PROJECTS/advanced/rveng/frontend/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/PROJECTS/advanced/rveng/frontend/tsconfig.node.json b/PROJECTS/advanced/rveng/frontend/tsconfig.node.json new file mode 100644 index 00000000..a96b3e59 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/PROJECTS/advanced/rveng/frontend/vite.config.ts b/PROJECTS/advanced/rveng/frontend/vite.config.ts new file mode 100644 index 00000000..601fec69 --- /dev/null +++ b/PROJECTS/advanced/rveng/frontend/vite.config.ts @@ -0,0 +1,70 @@ +/** + * ©AngelaMos | 2026 + * vite.config.ts + */ + +import path from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig, loadEnv } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, '') + const isDev = mode === 'development' + + return { + plugins: [react(), tsconfigPaths()], + + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + + css: { + preprocessorOptions: { + scss: {}, + }, + }, + + server: { + port: 5173, + host: '0.0.0.0', + proxy: { + '/api': { + target: env.VITE_API_TARGET || 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, + + build: { + target: 'esnext', + cssTarget: 'chrome100', + sourcemap: isDev ? true : 'hidden', + minify: 'oxc', + rollupOptions: { + output: { + manualChunks(id: string): string | undefined { + if (id.includes('node_modules')) { + if (id.includes('react-dom') || id.includes('react-router')) { + return 'vendor-react' + } + if (id.includes('@tanstack/react-query')) { + return 'vendor-query' + } + if (id.includes('zustand')) { + return 'vendor-state' + } + } + return undefined + }, + }, + }, + }, + + preview: { + port: 4173, + }, + } +}) diff --git a/PROJECTS/advanced/rveng/src/rveng/api/app.py b/PROJECTS/advanced/rveng/src/rveng/api/app.py index 041f3911..d312006e 100644 --- a/PROJECTS/advanced/rveng/src/rveng/api/app.py +++ b/PROJECTS/advanced/rveng/src/rveng/api/app.py @@ -6,22 +6,30 @@ app.py from pathlib import Path from fastapi import FastAPI, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware from rveng.api import schemas from rveng.api.limits import ( DEFAULT_SESSION, + DEV_ORIGIN_REGEX, + MAX_BODY_BYTES, MAX_DISASM_BYTES, MAX_HEX_BYTES, + MAX_SESSION_LEN, ) +from rveng.api.middleware import BodySizeLimitMiddleware from rveng.api.store import ( ChallengeStore, InMemoryProgress, ProgressStore, load_store, ) -from rveng.engine import disasm, hex as hexmod, strings +from rveng.engine import cfg as cfgmod +from rveng.engine import disasm, hex as hexmod, strings, xref from rveng.engine.challenge import Challenge, grade +from rveng.engine.discover import discover_functions from rveng.engine.elf import ElfImage, NotAnElf +from rveng.engine.plt import plt_map CHALLENGES_ROOT = Path(__file__).resolve().parents[3] / "challenges" @@ -35,6 +43,13 @@ def create_app( store = store or load_store(CHALLENGES_ROOT) progress = progress or InMemoryProgress() app = FastAPI(title="rveng") + app.add_middleware(BodySizeLimitMiddleware, max_bytes=MAX_BODY_BYTES) + app.add_middleware( + CORSMiddleware, + allow_origin_regex=DEV_ORIGIN_REGEX, + allow_methods=["GET", "POST"], + allow_headers=["*"], + ) def require(cid: str) -> Challenge: challenge = store.get(cid) @@ -48,6 +63,27 @@ def create_app( except NotAnElf as exc: raise HTTPException(422, f"not an ELF: {exc}") + def instructions_for( + image: ElfImage, + symbol: str | None, + address: int | None) -> tuple[list[disasm.Instruction], str]: + if symbol: + sym = image.symbol(symbol) + if sym is None: + raise HTTPException(404, "symbol not found") + if sym.size > MAX_DISASM_BYTES: + raise HTTPException(413, "symbol too large to disassemble") + try: + return disasm.disassemble_symbol(image, sym), symbol + except ValueError as exc: + raise HTTPException(422, str(exc)) + if address is not None: + try: + return disasm.disassemble_at(image, address), f"sub_{address:x}" + except ValueError as exc: + raise HTTPException(422, str(exc)) + raise HTTPException(422, "provide a symbol or an address") + @app.get("/api/challenges") def list_challenges() -> list[schemas.ChallengeSummary]: return [ @@ -96,26 +132,30 @@ def create_app( schemas.FunctionView( name=f.name, value=f.value, size=f.size) for f in image.functions() if f.name + ], + discovered=[ + schemas.DiscoveredFunctionView( + address=d.address, label=d.label) + for d in discover_functions(image) ]) @app.get("/api/challenges/{cid}/disasm") def disasm_view( - cid: str, symbol: str = Query(...)) -> schemas.DisasmView: + cid: str, + symbol: str | None = Query(None), + address: int | None = Query(None, ge=0), + session: str = Query( + DEFAULT_SESSION, max_length=MAX_SESSION_LEN) + ) -> schemas.DisasmView: c = require(cid) image = elf_of(c) - sym = image.symbol(symbol) - if sym is None: - raise HTTPException(404, "symbol not found") - try: - instructions = disasm.disassemble_symbol(image, sym) - except ValueError as exc: - raise HTTPException(422, str(exc)) - if sum(i.size for i in instructions) > MAX_DISASM_BYTES: - raise HTTPException(413, "symbol too large to disassemble") - gate = disasm.find_gate(instructions) + instructions, label = instructions_for(image, symbol, address) + imports = plt_map(image) + solved = c.id in progress.solved(session) + gate = disasm.find_gate(instructions) if solved else None gate_addr = gate.address if gate else None return schemas.DisasmView( - symbol=symbol, + symbol=label, gate_address=gate_addr, instructions=[ schemas.InstructionView( @@ -123,10 +163,49 @@ def create_app( op_str=i.op_str, bytes=i.raw.hex(), immediate=i.immediate, branch_target=i.branch_target, + rip_target=i.rip_target, + call_name=(imports.get(i.branch_target) + if i.mnemonic == "call" else None), is_gate=(i.address == gate_addr)) for i in instructions ]) + @app.get("/api/challenges/{cid}/cfg") + def cfg_view( + cid: str, + symbol: str | None = Query(None), + address: int | None = Query(None, ge=0)) -> schemas.CfgView: + c = require(cid) + image = elf_of(c) + instructions, label = instructions_for(image, symbol, address) + graph = cfgmod.build_cfg(instructions) + return schemas.CfgView( + symbol=label, + blocks=[ + schemas.CfgBlockView( + start=b.start, end=b.end, + instructions=list(b.instructions)) + for b in graph.blocks + ], + edges=[ + schemas.CfgEdgeView(src=e.src, dst=e.dst, kind=e.kind) + for e in graph.edges + ]) + + @app.get("/api/challenges/{cid}/xrefs") + def xrefs_view( + cid: str, target: int = Query(..., ge=0)) -> schemas.XrefsView: + c = require(cid) + image = elf_of(c) + refs = xref.xrefs_to(disasm.disassemble_text(image), target) + return schemas.XrefsView( + target=target, + references=[ + schemas.XrefView( + from_addr=r.from_addr, to_addr=r.to_addr, kind=r.kind) + for r in refs + ]) + @app.get("/api/challenges/{cid}/strings") def strings_view( cid: str, @@ -154,7 +233,9 @@ def create_app( @app.get("/api/progress") def get_progress( - session: str = Query(DEFAULT_SESSION)) -> schemas.ProgressView: + session: str = Query( + DEFAULT_SESSION, + max_length=MAX_SESSION_LEN)) -> schemas.ProgressView: solved = progress.solved(session) return schemas.ProgressView( session=session, diff --git a/PROJECTS/advanced/rveng/src/rveng/api/limits.py b/PROJECTS/advanced/rveng/src/rveng/api/limits.py index 1bf9f70a..7f927a80 100644 --- a/PROJECTS/advanced/rveng/src/rveng/api/limits.py +++ b/PROJECTS/advanced/rveng/src/rveng/api/limits.py @@ -6,4 +6,8 @@ limits.py MAX_HEX_BYTES = 4096 MAX_DISASM_BYTES = 65536 MAX_ANSWER_LEN = 8192 +MAX_SESSION_LEN = 128 +MAX_BODY_BYTES = 65536 DEFAULT_SESSION = "local" + +DEV_ORIGIN_REGEX = r"^http://(localhost|127\.0\.0\.1)(:\d+)?$" diff --git a/PROJECTS/advanced/rveng/src/rveng/api/middleware.py b/PROJECTS/advanced/rveng/src/rveng/api/middleware.py new file mode 100644 index 00000000..6be5daba --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/api/middleware.py @@ -0,0 +1,63 @@ +""" +©AngelaMos | 2026 +middleware.py +""" + +import json + +CONTENT_LENGTH = b"content-length" +PAYLOAD_TOO_LARGE = 413 + + +class BodySizeLimitMiddleware: + """ + Reject request bodies larger than a byte cap before they are parsed + """ + + def __init__(self, app, max_bytes: int): + self.app = app + self.max_bytes = max_bytes + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + if self._declared_length(scope) > self.max_bytes: + await self._reject(send) + return + await self.app(scope, self._bounded(receive), send) + + def _declared_length(self, scope) -> int: + for key, value in scope.get("headers", []): + if key.lower() == CONTENT_LENGTH: + try: + return int(value) + except ValueError: + return 0 + return 0 + + def _bounded(self, receive): + seen = 0 + + async def bounded_receive(): + nonlocal seen + message = await receive() + if message["type"] == "http.request": + seen += len(message.get("body", b"")) + if seen > self.max_bytes: + return {"type": "http.disconnect"} + return message + + return bounded_receive + + async def _reject(self, send) -> None: + body = json.dumps({"detail": "request body too large"}).encode() + await send({ + "type": "http.response.start", + "status": PAYLOAD_TOO_LARGE, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + }) + await send({"type": "http.response.body", "body": body}) diff --git a/PROJECTS/advanced/rveng/src/rveng/api/schemas.py b/PROJECTS/advanced/rveng/src/rveng/api/schemas.py index 8ed12bff..037e2564 100644 --- a/PROJECTS/advanced/rveng/src/rveng/api/schemas.py +++ b/PROJECTS/advanced/rveng/src/rveng/api/schemas.py @@ -3,9 +3,9 @@ schemas.py """ -from pydantic import BaseModel +from pydantic import BaseModel, Field -from rveng.api.limits import DEFAULT_SESSION, MAX_ANSWER_LEN +from rveng.api.limits import DEFAULT_SESSION, MAX_ANSWER_LEN, MAX_SESSION_LEN class ChallengeSummary(BaseModel): @@ -35,6 +35,11 @@ class FunctionView(BaseModel): size: int +class DiscoveredFunctionView(BaseModel): + address: int + label: str + + class SectionView(BaseModel): index: int name: str @@ -51,6 +56,7 @@ class ElfView(BaseModel): entry: int sections: list[SectionView] functions: list[FunctionView] + discovered: list[DiscoveredFunctionView] class InstructionView(BaseModel): @@ -60,6 +66,8 @@ class InstructionView(BaseModel): bytes: str immediate: int | None branch_target: int | None + rip_target: int | None + call_name: str | None is_gate: bool @@ -69,6 +77,35 @@ class DisasmView(BaseModel): gate_address: int | None +class CfgBlockView(BaseModel): + start: int + end: int + instructions: list[int] + + +class CfgEdgeView(BaseModel): + src: int + dst: int + kind: str + + +class CfgView(BaseModel): + symbol: str + blocks: list[CfgBlockView] + edges: list[CfgEdgeView] + + +class XrefView(BaseModel): + from_addr: int + to_addr: int + kind: str + + +class XrefsView(BaseModel): + target: int + references: list[XrefView] + + class StringView(BaseModel): offset: int text: str @@ -80,7 +117,7 @@ class StringsView(BaseModel): class SubmitRequest(BaseModel): answer: str - session: str = DEFAULT_SESSION + session: str = Field(DEFAULT_SESSION, max_length=MAX_SESSION_LEN) def within_limits(self) -> bool: return len(self.answer) <= MAX_ANSWER_LEN diff --git a/PROJECTS/advanced/rveng/src/rveng/api/store.py b/PROJECTS/advanced/rveng/src/rveng/api/store.py index fb30e5f3..8c316d61 100644 --- a/PROJECTS/advanced/rveng/src/rveng/api/store.py +++ b/PROJECTS/advanced/rveng/src/rveng/api/store.py @@ -4,6 +4,7 @@ store.py """ import json +import logging from pathlib import Path from typing import Protocol @@ -15,6 +16,8 @@ from rveng.engine.challenge import ( PatchedBytes, ) +log = logging.getLogger(__name__) + MANIFEST = "challenge.json" TARGET = "target" SOURCE = "source.c" @@ -32,15 +35,18 @@ class ChallengeError(ValueError): def _build_answer(spec: dict, binary: bytes): category = spec.get("category") - if category == CAT_FOUND_VALUE: - return FoundValue(spec["expected"]) - if category == CAT_IDENTIFIED_SYMBOL: - return IdentifiedSymbol(spec["name"]) - if category == CAT_PATCHED_BYTES: - offset = spec["offset"] - replacement = bytes.fromhex(spec["patch"]) - known_good = patch.apply(binary, offset, replacement) - return PatchedBytes(offset=offset, known_good=known_good) + try: + if category == CAT_FOUND_VALUE: + return FoundValue(spec["expected"]) + if category == CAT_IDENTIFIED_SYMBOL: + return IdentifiedSymbol(spec["name"]) + if category == CAT_PATCHED_BYTES: + offset = spec["offset"] + replacement = bytes.fromhex(spec["patch"]) + known_good = patch.apply(binary, offset, replacement) + return PatchedBytes(offset=offset, known_good=known_good) + except (KeyError, ValueError) as exc: + raise ChallengeError(f"bad {category} answer spec: {exc}") from exc raise ChallengeError(f"unknown answer category: {category}") @@ -51,18 +57,21 @@ def load_challenge(directory: Path) -> Challenge: manifest_path = directory / MANIFEST if not manifest_path.is_file(): raise ChallengeError(f"no manifest in {directory}") - manifest = json.loads(manifest_path.read_text()) - binary = (directory / TARGET).read_bytes() - source = (directory / SOURCE).read_text() - return Challenge( - id=manifest["id"], - module=manifest["module"], - title=manifest["title"], - mission=manifest["mission"], - binary=binary, - source=source, - answer=_build_answer(manifest["answer"], binary), - ) + try: + manifest = json.loads(manifest_path.read_text()) + binary = (directory / TARGET).read_bytes() + source = (directory / SOURCE).read_text() + return Challenge( + id=manifest["id"], + module=manifest["module"], + title=manifest["title"], + mission=manifest["mission"], + binary=binary, + source=source, + answer=_build_answer(manifest["answer"], binary), + ) + except (json.JSONDecodeError, KeyError, OSError) as exc: + raise ChallengeError(f"malformed challenge in {directory}: {exc}") from exc class ChallengeStore: @@ -86,8 +95,12 @@ def load_store(root: Path) -> ChallengeStore: """ challenges = [] for directory in sorted(root.iterdir()): - if (directory / MANIFEST).is_file(): + if not (directory / MANIFEST).is_file(): + continue + try: challenges.append(load_challenge(directory)) + except ChallengeError as exc: + log.warning("skipping challenge: %s", exc) return ChallengeStore(challenges) diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/cfg.py b/PROJECTS/advanced/rveng/src/rveng/engine/cfg.py new file mode 100644 index 00000000..37dd9027 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/cfg.py @@ -0,0 +1,106 @@ +""" +©AngelaMos | 2026 +cfg.py +""" + +from dataclasses import dataclass + +from rveng.engine.disasm import Instruction + +RET = "ret" +JMP = "jmp" + +EDGE_TAKEN = "taken" +EDGE_FALLTHROUGH = "fallthrough" +EDGE_JUMP = "jump" + + +@dataclass(frozen=True) +class BasicBlock: + """ + A straight run of instructions with a single entry and single exit + """ + + start: int + end: int + instructions: tuple[int, ...] + + +@dataclass(frozen=True) +class Edge: + """ + A control-flow edge between two basic blocks, keyed by their starts + """ + + src: int + dst: int + kind: str + + +@dataclass(frozen=True) +class ControlFlowGraph: + """ + The basic blocks and edges of a single function + """ + + blocks: list[BasicBlock] + edges: list[Edge] + + +def _is_terminator(ins: Instruction) -> bool: + return (ins.is_conditional_branch + or ins.mnemonic == JMP + or ins.mnemonic == RET) + + +def _leaders(instructions: list[Instruction], within: set[int]) -> list[int]: + addrs = [i.address for i in instructions] + leaders = {addrs[0]} + for idx, ins in enumerate(instructions): + if not _is_terminator(ins): + continue + if idx + 1 < len(instructions): + leaders.add(addrs[idx + 1]) + if ins.branch_target is not None and ins.branch_target in within: + leaders.add(ins.branch_target) + return sorted(leaders) + + +def build_cfg(instructions: list[Instruction]) -> ControlFlowGraph: + """ + Split a function's instructions into basic blocks and connect them + """ + if not instructions: + return ControlFlowGraph([], []) + + addrs = [i.address for i in instructions] + by_addr = {i.address: i for i in instructions} + within = set(addrs) + starts = _leaders(instructions, within) + + blocks = [] + for pos, start in enumerate(starts): + stop = starts[pos + 1] if pos + 1 < len(starts) else None + body = tuple( + a for a in addrs if a >= start and (stop is None or a < stop)) + blocks.append(BasicBlock(start=start, end=body[-1], instructions=body)) + + block_starts = [b.start for b in blocks] + edges = [] + for pos, block in enumerate(blocks): + last = by_addr[block.end] + following = block_starts[pos + 1] if pos + 1 < len(blocks) else None + if last.mnemonic == RET: + continue + if last.is_conditional_branch: + if last.branch_target in within: + edges.append(Edge(block.start, last.branch_target, EDGE_TAKEN)) + if following is not None: + edges.append(Edge(block.start, following, EDGE_FALLTHROUGH)) + elif last.mnemonic == JMP: + if last.branch_target in within: + edges.append(Edge(block.start, last.branch_target, EDGE_JUMP)) + elif following is not None: + edges.append(Edge(block.start, following, EDGE_FALLTHROUGH)) + + return ControlFlowGraph(blocks, edges) diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/disasm.py b/PROJECTS/advanced/rveng/src/rveng/engine/disasm.py index 850259c4..6c060dab 100644 --- a/PROJECTS/advanced/rveng/src/rveng/engine/disasm.py +++ b/PROJECTS/advanced/rveng/src/rveng/engine/disasm.py @@ -6,7 +6,7 @@ disasm.py from dataclasses import dataclass from capstone import CS_ARCH_X86, CS_MODE_64, CS_OPT_SYNTAX_INTEL, Cs -from capstone.x86 import X86_OP_IMM +from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_REG_RIP from rveng.engine.elf import ElfImage, Symbol @@ -31,6 +31,7 @@ class Instruction: raw: bytes immediate: int | None branch_target: int | None = None + rip_target: int | None = None @property def size(self) -> int: @@ -68,6 +69,13 @@ def _immediate(ins) -> int | None: return value +def _rip_target(ins) -> int | None: + for operand in ins.operands: + if operand.type == X86_OP_MEM and operand.mem.base == X86_REG_RIP: + return ins.address + ins.size + operand.mem.disp + return None + + def disassemble(code: bytes, base: int = 0) -> list[Instruction]: """ Decode a byte range into annotated instructions at virtual base @@ -85,10 +93,54 @@ def disassemble(code: bytes, base: int = 0) -> list[Instruction]: raw=bytes(ins.bytes), immediate=None if flow else imm, branch_target=imm if flow else None, + rip_target=_rip_target(ins), )) return out +def disassemble_text(image: ElfImage) -> list[Instruction]: + """ + Disassemble the whole .text section for whole-binary analysis + """ + section = image.section(".text") + if section is None: + return [] + code = image.data[section.offset:section.offset + section.size] + return disassemble(code, section.addr) + + +RET = "ret" + + +def _section_at(image: ElfImage, vaddr: int): + for section in image.sections: + if section.addr == 0 or section.is_nobits: + continue + if section.addr <= vaddr < section.addr + section.size: + return section + return None + + +def disassemble_at( + image: ElfImage, + vaddr: int, + max_bytes: int = 4096) -> list[Instruction]: + """ + Disassemble from a raw virtual address until the first ret + """ + section = _section_at(image, vaddr) + if section is None: + raise ValueError("address is not in a mapped section") + start = vaddr - section.addr + section.offset + end = min(start + max_bytes, section.offset + section.size) + out = [] + for ins in disassemble(image.data[start:end], vaddr): + out.append(ins) + if ins.mnemonic == RET: + break + return out + + def disassemble_symbol( image: ElfImage, symbol: Symbol) -> list[Instruction]: """ diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/discover.py b/PROJECTS/advanced/rveng/src/rveng/engine/discover.py new file mode 100644 index 00000000..e6cde1df --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/discover.py @@ -0,0 +1,41 @@ +""" +©AngelaMos | 2026 +discover.py +""" + +from dataclasses import dataclass + +from rveng.engine.elf import SHF_EXECINSTR, ElfImage + +PROLOGUE = b"\x55\x48\x89\xe5" + + +@dataclass(frozen=True) +class DiscoveredFunction: + """ + A candidate function entry found without a symbol table + """ + + address: int + section: str + + @property + def label(self) -> str: + return f"sub_{self.address:x}" + + +def discover_functions(image: ElfImage) -> list[DiscoveredFunction]: + """ + Find function entries by scanning executable sections for the prologue + """ + found = [] + for section in image.sections: + if section.is_nobits or not section.flags & SHF_EXECINSTR: + continue + blob = image.data[section.offset:section.offset + section.size] + pos = blob.find(PROLOGUE) + while pos != -1: + found.append(DiscoveredFunction( + address=section.addr + pos, section=section.name)) + pos = blob.find(PROLOGUE, pos + 1) + return sorted(found, key=lambda f: f.address) diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/plt.py b/PROJECTS/advanced/rveng/src/rveng/engine/plt.py new file mode 100644 index 00000000..d8e0e5d3 --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/plt.py @@ -0,0 +1,101 @@ +""" +©AngelaMos | 2026 +plt.py +""" + +import struct + +from rveng.engine.elf import ElfImage + +DYNSYM = ".dynsym" +DYNSTR = ".dynstr" +RELA_PLT = ".rela.plt" +PLT = ".plt" + +SYM_ENTRY = 24 +SYM_NAME = 0x00 + +RELA_ENTRY = 24 +RELA_OFFSET = 0x00 +RELA_INFO = 0x08 + +PLT_ENTRY = 16 +JMP_RIP = b"\xff\x25" +JMP_RIP_LEN = 6 + + +def _cstring(blob: bytes, offset: int) -> str: + end = blob.find(b"\x00", offset) + if end == -1: + end = len(blob) + return blob[offset:end].decode("utf-8", "replace") + + +def _dynamic_names(image: ElfImage) -> list[str]: + dynsym = image.section(DYNSYM) + dynstr = image.section(DYNSTR) + if dynsym is None or dynstr is None or dynsym.entsize == 0: + return [] + names = image.data[dynstr.offset:dynstr.offset + dynstr.size] + body = image.data[dynsym.offset:dynsym.offset + dynsym.size] + en = image.header.endian + out = [] + for base in range(0, len(body) - SYM_ENTRY + 1, SYM_ENTRY): + name_off = struct.unpack_from(en + "I", body, base + SYM_NAME)[0] + out.append(_cstring(names, name_off)) + return out + + +def _got_to_name(image: ElfImage, names: list[str]) -> dict[int, str]: + rela = image.section(RELA_PLT) + if rela is None or rela.entsize == 0: + return {} + body = image.data[rela.offset:rela.offset + rela.size] + en = image.header.endian + mapping = {} + for base in range(0, len(body) - RELA_ENTRY + 1, RELA_ENTRY): + r_offset = struct.unpack_from(en + "Q", body, base + RELA_OFFSET)[0] + r_info = struct.unpack_from(en + "Q", body, base + RELA_INFO)[0] + sym_index = r_info >> 32 + if 0 <= sym_index < len(names): + mapping[r_offset] = names[sym_index] + return mapping + + +def _stub_got_slot(entry: bytes, entry_addr: int, endian: str) -> int | None: + jmp = entry.find(JMP_RIP) + if jmp == -1 or jmp + JMP_RIP_LEN > len(entry): + return None + disp = struct.unpack_from(endian + "i", entry, jmp + 2)[0] + return entry_addr + jmp + JMP_RIP_LEN + disp + + +def plt_map(image: ElfImage) -> dict[int, str]: + """ + Map each PLT stub address to the imported symbol it jumps to + """ + plt = image.section(PLT) + if plt is None: + return {} + got_names = _got_to_name(image, _dynamic_names(image)) + if not got_names: + return {} + body = image.data[plt.offset:plt.offset + plt.size] + en = image.header.endian + result = {} + for base in range(0, len(body) - PLT_ENTRY + 1, PLT_ENTRY): + entry = body[base:base + PLT_ENTRY] + got_slot = _stub_got_slot(entry, plt.addr + base, en) + if got_slot is None: + continue + name = got_names.get(got_slot) + if name is not None: + result[plt.addr + base] = name + return result + + +def resolve_call(image: ElfImage, target: int) -> str | None: + """ + Return the import a call target resolves to, or None when it is local + """ + return plt_map(image).get(target) diff --git a/PROJECTS/advanced/rveng/src/rveng/engine/xref.py b/PROJECTS/advanced/rveng/src/rveng/engine/xref.py new file mode 100644 index 00000000..bb74443d --- /dev/null +++ b/PROJECTS/advanced/rveng/src/rveng/engine/xref.py @@ -0,0 +1,55 @@ +""" +©AngelaMos | 2026 +xref.py +""" + +from dataclasses import dataclass + +from rveng.engine.disasm import Instruction + +CALL = "call" +KIND_CALL = "call" +KIND_BRANCH = "branch" +KIND_DATA = "data" + + +@dataclass(frozen=True) +class Reference: + """ + One cross reference from an instruction to a target address + """ + + from_addr: int + to_addr: int + kind: str + + +def references(instructions: list[Instruction]) -> list[Reference]: + """ + Every control-flow and data reference the instructions emit + """ + out = [] + for ins in instructions: + if ins.branch_target is not None: + kind = KIND_CALL if ins.mnemonic == CALL else KIND_BRANCH + out.append(Reference(ins.address, ins.branch_target, kind)) + if ins.rip_target is not None: + out.append(Reference(ins.address, ins.rip_target, KIND_DATA)) + return out + + +def build_xrefs(instructions: list[Instruction]) -> dict[int, list[Reference]]: + """ + Group every reference by the address it points at + """ + table: dict[int, list[Reference]] = {} + for ref in references(instructions): + table.setdefault(ref.to_addr, []).append(ref) + return table + + +def xrefs_to(instructions: list[Instruction], target: int) -> list[Reference]: + """ + Every reference that points at target + """ + return [ref for ref in references(instructions) if ref.to_addr == target] diff --git a/PROJECTS/advanced/rveng/tests/conftest.py b/PROJECTS/advanced/rveng/tests/conftest.py index ea9b2193..5981788e 100644 --- a/PROJECTS/advanced/rveng/tests/conftest.py +++ b/PROJECTS/advanced/rveng/tests/conftest.py @@ -18,3 +18,13 @@ def gate_path() -> Path: @pytest.fixture(scope="session") def gate_bytes(gate_path: Path) -> bytes: return gate_path.read_bytes() + + +@pytest.fixture(scope="session") +def gate_stripped_path() -> Path: + return FIXTURES / "gate_stripped" + + +@pytest.fixture(scope="session") +def gate_stripped_bytes(gate_stripped_path: Path) -> bytes: + return gate_stripped_path.read_bytes() diff --git a/PROJECTS/advanced/rveng/tests/fixtures/gate_stripped b/PROJECTS/advanced/rveng/tests/fixtures/gate_stripped new file mode 100755 index 0000000000000000000000000000000000000000..602810968153700212a4848bef59d31c006723ce GIT binary patch literal 14416 zcmeHOU2IfE6rSBuy8N_VM6gnRE(Q`bblXB{pai&3OD{^a@+UsvcH7-{H@3UY?pDS|a*jNSu@0 z`^`CXzBzNxy)*kWb6=_3zSZmT2o|r{C{Szko6@AtLNruLm27K8kub$VQ7Wc`Wf~l& ztU**IMt`2FVDyn6nvr*c>QCgEvW9V?)C9y(!aYPp-W@Tis4$wZR7T-fI`U){>4L70 z@dgE8bn;|8L$xCeQxyoe6OYl!!`w`iDffvDipN;tRra-n%tIAL!P01LgsZ4uY-7u%gB!r{ZW{=a;ef+E;VI=(NN}GJm&4Z zYKO7>Py5+Gyb5Z#g7VK;Me!Ip-gCgieBQ6{5`NC(v@sz}mU;ePXE!I?BRo)gZYm3Fxon@VC ztIMjZ0;xn`m23yw;`X3;Uos249-6`v+nLI~=pObuup4l=1#>)AJE9|Pr$oq3#6&O% z*VPRKV^+{(yXFz8_XFz8_XFz8_XFzA*LCApBbKPeh%m1=m2`5daU%fEng@e&hCs7JAWY&Z+D{5Yt#FUzNxDK(D;9Epz2X zEAy?@Gcw+=tFE%I@{%<$5S{}~&3hhPe{KmBFL@2ylkN9A)hC5Gy8^yYTO06f&pnG? z*v%QAp&>im(%awSB$(n;>3`&=gv_{g?&c=z+(e$`xoBOvZO_TIP(UrX`9BpezIAv* zA#_Zn=kB&LS4R)RRb0&f9ez-F_VxcI9{OK9ijJA?$s^s9>)=a&y5KD4<66Fqwyz&+ zAknN9GY((s~fg@`rvjz-N5Kax3k$y-Q=;l zNl@N}PQZ090-td!a}k=@E1MXQAx8Iu@3msEHYn@pZZOEIOK>aYJ-+L%Vo)tQ13Cjb z13Cjb13Cjb13Cjb13Cjb13Cl$rwriwnH`M;TiQac!B{F7jU*!?9dAoCzZ41A7|T;L z#erlZ-fG0t?M;!S@Ra7Q$2C@5+Zz8qo9zXx7|&)K02={c2Auzc`tex>I0QNhu6d$( z4(<@1PQRygc7dz*2au_K)p{QKeht37c86#hYn+Yh2{9B7k zo+_Soz;{?|TCo0!)yrhF$lD9Y9_Zs@^xItIf5Tf_IQ<3a2&h=!3CAv|KLnVo_jV}< zFyXo&_7`L0+ARvM{dr|cpL7Ov26P5=26P5=26P5=26P5=26P4<%nX>Y0E^-$bpD<~ z`)oX+&_3xxrHK*pf0T6I&s<76?<-zFy4YyS68^qI;rd^0XA`KO$}aN+)z3r)WS0Vg`&m&+6o`3*47?|El=gM_%|l*>qL1(fiUt-Sz`M*^@sb7 zzco={lc^Mr59_V+diuy8)@!BCd4->qQa?@gME}o#_m8^+R@436O1Ov6?N?u0TVt$v zGSU84al1ez1=?UA_c&e^}w@ws;HHEy8ipZCifu15K}WHbJ^;pFo^e%{}Y|Ccxg z|I=^^zUG0?ERPp{AA{f1;Bm|xe!f{>2ZdkZS!X_v?^84&na_Kp-TkixKIVY=JpKoX z-%Ej}17d9E^EiAOZ0O2-o=+O-LV5lG7b))k?*uE3UFP$6JxzR`7rFoJ$JhvboC{dz z`9Qt|0ChZnxcd)PQgD*MP|WA~XPn|QpX0l)zl-==WNRWfpBY53$c`$`d2W6W*f1y9 zKPfyP_?61@B-P5kav2f-J9F|4;v2*_(a9lX&h6jNK%JA{Nc=`}eARWsn9ohV<>K?_ z)yN#>!SgozQ(9RSZxf&Mf9HK4In*r|^3#Re!_(z{QUmres773F3l+ z+?nqm8_qz%IsW;59DYy7U`IlC|NFrXdx+#1LKpsg=l5XT@mOcP44h&IpFjWQ3mIT% zKPXCJo$)KM; TestClient: return TestClient(create_app()) -def test_list_has_three_seed_challenges(client: TestClient): +def test_list_has_the_seed_challenges(client: TestClient): body = client.get("/api/challenges").json() ids = {c["id"] for c in body} assert ids == { - "03-flip-the-gate", "04-name-the-function", "05-find-the-gate"} + "03-flip-the-gate", "04-name-the-function", "05-find-the-gate", + "06-stripped-gate"} def test_detail_does_not_leak_source_or_answer(client: TestClient): @@ -58,9 +65,25 @@ def test_elf_view_reports_entry_and_functions(client: TestClient): assert "check" in names and "main" in names -def test_disasm_marks_the_gate(client: TestClient): +def test_disasm_hides_gate_before_solve(client: TestClient): body = client.get( - "/api/challenges/05-find-the-gate/disasm?symbol=check").json() + "/api/challenges/05-find-the-gate/disasm" + "?symbol=check&session=fresh").json() + assert body["gate_address"] is None + assert all(i["is_gate"] is False for i in body["instructions"]) + cmp_ins = next( + i for i in body["instructions"] if i["mnemonic"] == "cmp") + assert "0x539" in cmp_ins["op_str"] + + +def test_disasm_reveals_gate_after_solve(client: TestClient): + session = "solver" + client.post( + "/api/challenges/05-find-the-gate/submit", + json={"answer": "1337", "session": session}) + body = client.get( + "/api/challenges/05-find-the-gate/disasm" + f"?symbol=check&session={session}").json() assert body["gate_address"] == 0x40114D gate = next(i for i in body["instructions"] if i["is_gate"]) assert gate["mnemonic"] == "cmp" @@ -129,4 +152,95 @@ def test_progress_tracks_solves(client: TestClient): json={"answer": "1337", "session": "s1"}) body = client.get("/api/progress?session=s1").json() assert body["solved"] == ["05-find-the-gate"] - assert body["total"] == 3 + assert body["total"] == 4 + + +def test_disasm_resolves_import_call_names(client: TestClient): + body = client.get( + "/api/challenges/05-find-the-gate/disasm?symbol=main").json() + names = {i["call_name"] for i in body["instructions"] if i["call_name"]} + assert "atoi" in names and "puts" in names + + +def test_disasm_exposes_rip_target(client: TestClient): + body = client.get( + "/api/challenges/05-find-the-gate/disasm?symbol=main").json() + targets = {i["rip_target"] for i in body["instructions"]} + assert 0x402004 in targets + + +def test_elf_lists_discovered_functions(client: TestClient): + body = client.get("/api/challenges/05-find-the-gate/elf").json() + labels = {d["label"] for d in body["discovered"]} + assert "sub_401146" in labels + + +def test_cfg_of_check_is_a_diamond(client: TestClient): + body = client.get( + "/api/challenges/05-find-the-gate/cfg?symbol=check").json() + assert len(body["blocks"]) == 4 + assert len(body["edges"]) == 4 + + +def test_xrefs_find_caller_of_check(client: TestClient): + body = client.get( + "/api/challenges/05-find-the-gate/xrefs?target=4198726").json() + assert any( + r["from_addr"] == 0x4011A6 and r["kind"] == "call" + for r in body["references"]) + + +def test_stripped_challenge_disassembles_by_address(client: TestClient): + elf = client.get("/api/challenges/06-stripped-gate/elf").json() + assert elf["functions"] == [] + assert any(d["address"] == 0x401146 for d in elf["discovered"]) + dis = client.get( + "/api/challenges/06-stripped-gate/disasm?address=4198726").json() + assert dis["instructions"][-1]["mnemonic"] == "ret" + + +def test_stripped_challenge_grades_found_value(client: TestClient): + r = client.post( + "/api/challenges/06-stripped-gate/submit", + json={"answer": "0x539"}).json() + assert r["correct"] is True + + +def test_load_store_skips_malformed_dir(tmp_path: Path): + root = tmp_path / "challenges" + root.mkdir() + shutil.copytree( + CHALLENGES / "04-name-the-function", + root / "04-name-the-function") + broken = root / "99-broken" + broken.mkdir() + (broken / "challenge.json").write_text("{ not valid json") + store = load_store(root) + assert {c.id for c in store.list()} == {"04-name-the-function"} + + +def test_answer_over_answer_len_is_413(client: TestClient): + r = client.post( + "/api/challenges/05-find-the-gate/submit", + json={"answer": "9" * 10000}) + assert r.status_code == 413 + + +def test_body_over_size_cap_is_413(client: TestClient): + r = client.post( + "/api/challenges/05-find-the-gate/submit", + json={"answer": "9" * 100000}) + assert r.status_code == 413 + + +def test_session_over_max_len_is_422(client: TestClient): + long_session = "s" * (MAX_SESSION_LEN + 1) + r = client.get(f"/api/progress?session={long_session}") + assert r.status_code == 422 + + +def test_cors_allows_localhost_dev_origin(client: TestClient): + r = client.get( + "/api/challenges", headers={"origin": "http://localhost:5173"}) + assert r.headers.get("access-control-allow-origin") == ( + "http://localhost:5173") diff --git a/PROJECTS/advanced/rveng/tests/test_cfg.py b/PROJECTS/advanced/rveng/tests/test_cfg.py new file mode 100644 index 00000000..9dbcb3f0 --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_cfg.py @@ -0,0 +1,45 @@ +""" +©AngelaMos | 2026 +test_cfg.py +""" + +from rveng.engine.cfg import EDGE_FALLTHROUGH, EDGE_JUMP, EDGE_TAKEN, build_cfg +from rveng.engine.disasm import disassemble_symbol +from rveng.engine.elf import ElfImage + +B0 = 0x401146 +B1 = 0x401156 +B2 = 0x40115D +B3 = 0x401162 + + +def _check_cfg(gate_bytes: bytes): + image = ElfImage(gate_bytes) + symbol = image.symbol("check") + return build_cfg(disassemble_symbol(image, symbol)) + + +def test_check_has_four_basic_blocks(gate_bytes: bytes): + cfg = _check_cfg(gate_bytes) + assert [b.start for b in cfg.blocks] == [B0, B1, B2, B3] + + +def test_check_block_zero_ends_at_the_gate_jne(gate_bytes: bytes): + cfg = _check_cfg(gate_bytes) + assert cfg.blocks[0].end == 0x401154 + + +def test_check_cfg_is_a_diamond(gate_bytes: bytes): + cfg = _check_cfg(gate_bytes) + edges = {(e.src, e.dst, e.kind) for e in cfg.edges} + assert edges == { + (B0, B2, EDGE_TAKEN), + (B0, B1, EDGE_FALLTHROUGH), + (B1, B3, EDGE_JUMP), + (B2, B3, EDGE_FALLTHROUGH), + } + + +def test_terminal_block_has_no_out_edges(gate_bytes: bytes): + cfg = _check_cfg(gate_bytes) + assert all(e.src != B3 for e in cfg.edges) diff --git a/PROJECTS/advanced/rveng/tests/test_discover.py b/PROJECTS/advanced/rveng/tests/test_discover.py new file mode 100644 index 00000000..3eb5e24d --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_discover.py @@ -0,0 +1,42 @@ +""" +©AngelaMos | 2026 +test_discover.py +""" + +from rveng.engine.disasm import disassemble_at, find_gate +from rveng.engine.discover import discover_functions +from rveng.engine.elf import ElfImage + +CHECK = 0x401146 +MAIN = 0x401164 +START = 0x401060 + + +def test_prologue_scan_finds_check_and_main(gate_stripped_bytes: bytes): + image = ElfImage(gate_stripped_bytes) + addrs = {f.address for f in discover_functions(image)} + assert CHECK in addrs + assert MAIN in addrs + + +def test_prologue_scan_skips_the_runtime_start(gate_stripped_bytes: bytes): + image = ElfImage(gate_stripped_bytes) + addrs = {f.address for f in discover_functions(image)} + assert START not in addrs + + +def test_discovered_function_has_an_address_label(gate_stripped_bytes: bytes): + image = ElfImage(gate_stripped_bytes) + functions = discover_functions(image) + check = next(f for f in functions if f.address == CHECK) + assert check.label == "sub_401146" + + +def test_disassemble_at_recovers_the_gate_without_symbols( + gate_stripped_bytes: bytes): + image = ElfImage(gate_stripped_bytes) + instructions = disassemble_at(image, CHECK) + assert instructions[-1].mnemonic == "ret" + gate = find_gate(instructions) + assert gate is not None + assert gate.immediate == 1337 diff --git a/PROJECTS/advanced/rveng/tests/test_plt.py b/PROJECTS/advanced/rveng/tests/test_plt.py new file mode 100644 index 00000000..7f897cf0 --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_plt.py @@ -0,0 +1,51 @@ +""" +©AngelaMos | 2026 +test_plt.py +""" + +from rveng.engine.elf import ElfImage +from rveng.engine.plt import _stub_got_slot, plt_map, resolve_call + +PUTS = 0x401030 +PRINTF = 0x401040 +ATOI = 0x401050 + + +def test_plt_map_names_the_three_imports(gate_bytes: bytes): + image = ElfImage(gate_bytes) + assert plt_map(image) == {PUTS: "puts", PRINTF: "printf", ATOI: "atoi"} + + +def test_plt0_stub_is_not_treated_as_an_import(gate_bytes: bytes): + image = ElfImage(gate_bytes) + assert 0x401020 not in plt_map(image) + + +def test_resolve_call_maps_target_to_name(gate_bytes: bytes): + image = ElfImage(gate_bytes) + assert resolve_call(image, ATOI) == "atoi" + assert resolve_call(image, PUTS) == "puts" + + +def test_resolve_call_returns_none_for_local_target(gate_bytes: bytes): + image = ElfImage(gate_bytes) + assert resolve_call(image, 0x401146) is None + + +def test_plt_resolution_survives_stripping(gate_stripped_bytes: bytes): + image = ElfImage(gate_stripped_bytes) + assert plt_map(image) == {PUTS: "puts", PRINTF: "printf", ATOI: "atoi"} + + +def test_stub_decode_of_classic_entry(): + entry = bytes.fromhex("ff25ca2f0000") + b"\x00" * 10 + assert _stub_got_slot(entry, 0x401030, "<") == 0x404000 + + +def test_stub_decode_tolerates_truncated_jmp_pattern(): + entry = b"\x90" * 11 + b"\xff\x25\x00\x00\x00" + assert _stub_got_slot(entry, 0x401030, "<") is None + + +def test_stub_decode_returns_none_without_jmp(): + assert _stub_got_slot(b"\x90" * 16, 0x401030, "<") is None diff --git a/PROJECTS/advanced/rveng/tests/test_xref.py b/PROJECTS/advanced/rveng/tests/test_xref.py new file mode 100644 index 00000000..f575b068 --- /dev/null +++ b/PROJECTS/advanced/rveng/tests/test_xref.py @@ -0,0 +1,40 @@ +""" +©AngelaMos | 2026 +test_xref.py +""" + +from rveng.engine.disasm import disassemble_text +from rveng.engine.elf import ElfImage +from rveng.engine.xref import build_xrefs, xrefs_to + +CHECK = 0x401146 +FLAG = 0x402004 +MAIN_CALLS_CHECK = 0x4011A6 +MAIN_LEA_FLAG = 0x401173 + + +def test_rip_relative_resolves_to_flag_string(gate_bytes: bytes): + image = ElfImage(gate_bytes) + by_addr = {i.address: i for i in disassemble_text(image)} + assert by_addr[MAIN_LEA_FLAG].rip_target == FLAG + + +def test_xref_finds_caller_of_check(gate_bytes: bytes): + image = ElfImage(gate_bytes) + refs = xrefs_to(disassemble_text(image), CHECK) + assert any( + r.from_addr == MAIN_CALLS_CHECK and r.kind == "call" for r in refs) + + +def test_xref_finds_data_reference_to_flag(gate_bytes: bytes): + image = ElfImage(gate_bytes) + refs = xrefs_to(disassemble_text(image), FLAG) + assert any( + r.from_addr == MAIN_LEA_FLAG and r.kind == "data" for r in refs) + + +def test_build_xrefs_groups_by_target(gate_bytes: bytes): + image = ElfImage(gate_bytes) + table = build_xrefs(disassemble_text(image)) + assert CHECK in table + assert FLAG in table From 1d9668ee3450d1f37edeabed5c6b1fa53c191862 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 5 Jul 2026 22:14:39 -0400 Subject: [PATCH 3/3] feat: rveng M4 challenges + SQLite progress, M5 self-host + docs M4: - Add 01-read-the-hex (hex-reading) and 02-find-the-entry (elf-anatomy) gradeable challenges over the gate binary, completing the five-module pedagogy - Swap in-memory progress for a SQLite ProgressStore behind the existing Protocol; routes untouched, migration-free, thread-safe M5: - Self-host Docker: prod (nginx + uvicorn) and dev (nginx + Vite HMR) compose, justfile, Dockerfiles, and nginx configs; zero-config clone-and-run - learn/ teaching docs (00-04) and public README - One-shot install.sh and uninstall.sh --- PROJECTS/advanced/rveng/.dockerignore | 18 ++ PROJECTS/advanced/rveng/.gitignore | 2 + PROJECTS/advanced/rveng/README.md | 160 ++++++++++++ .../challenges/01-read-the-hex/challenge.json | 7 + .../rveng/challenges/01-read-the-hex/source.c | 23 ++ .../rveng/challenges/01-read-the-hex/target | Bin 0 -> 15904 bytes .../02-find-the-entry/challenge.json | 7 + .../challenges/02-find-the-entry/source.c | 23 ++ .../rveng/challenges/02-find-the-entry/target | Bin 0 -> 15904 bytes PROJECTS/advanced/rveng/compose.yml | 39 +++ PROJECTS/advanced/rveng/dev.compose.yml | 61 +++++ PROJECTS/advanced/rveng/frontend/.gitignore | 1 + .../rveng/infra/docker/api.dockerfile | 28 +++ PROJECTS/advanced/rveng/infra/docker/vite.dev | 12 + .../advanced/rveng/infra/docker/vite.prod | 21 ++ PROJECTS/advanced/rveng/infra/nginx/dev.nginx | 31 +++ .../advanced/rveng/infra/nginx/prod.nginx | 28 +++ PROJECTS/advanced/rveng/install.sh | 236 ++++++++++++++++++ PROJECTS/advanced/rveng/justfile | 111 ++++++++ PROJECTS/advanced/rveng/learn/00-OVERVIEW.md | 108 ++++++++ PROJECTS/advanced/rveng/learn/01-CONCEPTS.md | 175 +++++++++++++ .../advanced/rveng/learn/02-ARCHITECTURE.md | 166 ++++++++++++ .../advanced/rveng/learn/03-IMPLEMENTATION.md | 195 +++++++++++++++ .../advanced/rveng/learn/04-CHALLENGES.md | 141 +++++++++++ PROJECTS/advanced/rveng/src/rveng/api/app.py | 4 +- .../advanced/rveng/src/rveng/api/store.py | 45 +++- PROJECTS/advanced/rveng/tests/test_api.py | 53 +++- .../advanced/rveng/tests/test_progress.py | 54 ++++ PROJECTS/advanced/rveng/uninstall.sh | 58 +++++ 29 files changed, 1799 insertions(+), 8 deletions(-) create mode 100644 PROJECTS/advanced/rveng/.dockerignore create mode 100644 PROJECTS/advanced/rveng/challenges/01-read-the-hex/challenge.json create mode 100644 PROJECTS/advanced/rveng/challenges/01-read-the-hex/source.c create mode 100755 PROJECTS/advanced/rveng/challenges/01-read-the-hex/target create mode 100644 PROJECTS/advanced/rveng/challenges/02-find-the-entry/challenge.json create mode 100644 PROJECTS/advanced/rveng/challenges/02-find-the-entry/source.c create mode 100755 PROJECTS/advanced/rveng/challenges/02-find-the-entry/target create mode 100644 PROJECTS/advanced/rveng/compose.yml create mode 100644 PROJECTS/advanced/rveng/dev.compose.yml create mode 100644 PROJECTS/advanced/rveng/infra/docker/api.dockerfile create mode 100644 PROJECTS/advanced/rveng/infra/docker/vite.dev create mode 100644 PROJECTS/advanced/rveng/infra/docker/vite.prod create mode 100644 PROJECTS/advanced/rveng/infra/nginx/dev.nginx create mode 100644 PROJECTS/advanced/rveng/infra/nginx/prod.nginx create mode 100755 PROJECTS/advanced/rveng/install.sh create mode 100644 PROJECTS/advanced/rveng/justfile create mode 100644 PROJECTS/advanced/rveng/learn/00-OVERVIEW.md create mode 100644 PROJECTS/advanced/rveng/learn/01-CONCEPTS.md create mode 100644 PROJECTS/advanced/rveng/learn/02-ARCHITECTURE.md create mode 100644 PROJECTS/advanced/rveng/learn/03-IMPLEMENTATION.md create mode 100644 PROJECTS/advanced/rveng/learn/04-CHALLENGES.md create mode 100644 PROJECTS/advanced/rveng/tests/test_progress.py create mode 100755 PROJECTS/advanced/rveng/uninstall.sh diff --git a/PROJECTS/advanced/rveng/.dockerignore b/PROJECTS/advanced/rveng/.dockerignore new file mode 100644 index 00000000..069e90a9 --- /dev/null +++ b/PROJECTS/advanced/rveng/.dockerignore @@ -0,0 +1,18 @@ +# ©AngelaMos | 2026 +# .dockerignore + +.git +.venv +data +**/__pycache__ +**/*.pyc +.pytest_cache +**/.pytest_cache + +frontend/node_modules +frontend/dist +frontend/.vite +frontend/.biome_cache + +docs/plans +docs/context diff --git a/PROJECTS/advanced/rveng/.gitignore b/PROJECTS/advanced/rveng/.gitignore index 173d0337..b76929ba 100644 --- a/PROJECTS/advanced/rveng/.gitignore +++ b/PROJECTS/advanced/rveng/.gitignore @@ -8,3 +8,5 @@ __pycache__/ .pytest_cache/ .venv/ *.pyc + +data/ diff --git a/PROJECTS/advanced/rveng/README.md b/PROJECTS/advanced/rveng/README.md index e69de29b..0610a517 100644 --- a/PROJECTS/advanced/rveng/README.md +++ b/PROJECTS/advanced/rveng/README.md @@ -0,0 +1,160 @@ + + + +``` + ______ _____ ____ ____ _ + / ___/ | / / _ \/ __ \/ __ `/ + / / | |/ / __/ / / / /_/ / +/_/ |___/\___/_/ /_/\__, / + /____/ +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2337-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/rveng) +[![Python](https://img.shields.io/badge/Python-3.13-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org) +[![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=flat&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com) +[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev) +[![capstone](https://img.shields.io/badge/disassembler-capstone-6d4aff?style=flat)](https://www.capstone-engine.org) +[![No execution](https://img.shields.io/badge/binaries-never%20executed-2ea043?style=flat)](#it-never-runs-a-binary) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) + +> An interactive reverse-engineering learning platform. It hands you a real compiled binary, asks a concrete question about it, gives you an in-browser hex viewer, disassembler, section map, and string scanner to answer it, and grades your answer. Only when you are right does it reveal the original C source, so you connect the machine code you just read back to the code that produced it. One framework-free Python analysis engine wears three faces: a web app, a read-only HTTP API, and an embeddable library. + +## Why a reverse-engineering platform + +A pile of worksheets and pre-compiled binaries can walk you through reverse engineering, but it cannot check your work and it cannot become anything more. rveng keeps the solve-then-reveal loop and makes it a real system. The engine that parses ELF, drives the disassembler, resolves imports, and grades answers is the core. The web app turns the worksheet into an interactive lab: an in-browser hex viewer, live disassembly, a section map, and a gradeable challenge runner. The engine and its lesson content are decoupled from the web framework, so they embed into a larger application as a standalone reverse-engineering feature. One core, three consumers, curated content. + +## It never runs a binary + +The single load-bearing decision, and the reason a web app that eats binaries is safe: the backend never executes any binary. Every operation is reading and parsing bytes. + +- Hex dump, ELF header parse, section walk, symbol read, and string scan are all pure byte reads. +- Disassembly is decoding, not running. capstone reads instruction bytes and returns their text form; it never transfers control to the decoded code. +- Patch challenges are graded by a static byte diff against a known-good patched target. The patched binary is never executed. + +Challenge binaries are curated and pre-compiled and shipped as static assets; nobody uploads an executable to run. So there is no arbitrary-code-execution surface, no sandbox to escape, and no resource-exhaustion path through a hostile binary, because nothing is ever run. This is a hard constraint, not a preference. Any feature that would require executing a binary is out of scope until it is redesigned against this posture, most likely by moving execution to an isolated, disposable sandbox that is explicitly not the analysis backend. + +## Features + +**The engine** +- A hand-rolled ELF64 parser: header, section table, and symbol table read straight from raw bytes +- x86-64 disassembly via capstone in Intel syntax, annotated with comparisons, conditional branches, call targets, and RIP-relative data references +- Import resolution through the PLT, walking `.plt`, `.rela.plt`, `.dynsym`, and `.dynstr` the way the loader would, so a bare `call 0x401050` becomes `call atoi` +- Cross-references (who calls this, what data it touches) and a basic-block control-flow graph for a single function +- Function discovery in stripped binaries by scanning executable sections for the standard prologue, so a symbol-stripped binary is still navigable + +**The platform** +- Six curated challenges over one sample binary, spanning the five core reverse-engineering skills plus a stripped variant +- Solve-then-reveal grading in three machine-checkable categories: found-value, identified-symbol, and patched-bytes +- Progress persisted in SQLite behind a swappable interface, so it drops into a host application's own store without touching the engine +- A React web app and a read-only FastAPI, served together for one-command self-hosting + +## Quick Start + +rveng self-hosts on localhost with Docker. No account, no secret, no external service, nothing to configure. + +```bash +curl -fsSL https://angelamos.com/rveng/install.sh | bash +# then open http://localhost:8790 +``` + +One command takes a fresh machine to the app built and running: it installs Docker if it is missing, builds the engine image and the React app, brings the stack up, and waits until it answers. The first thing you do is open the browser. + +Already have the repo cloned? Run it straight from the project directory: + +```bash +just up # build and serve on http://localhost:8790 +just dev-up # hot-reload dev stack on http://localhost:8791 +just test # engine tests, no server (uv run pytest -q) +just typecheck # frontend type check, no server +just down # stop +``` + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see every recipe grouped by area: `dev` (dockerized Vite hot-reload), `prod` (the self-host stack), `verify` (tests and type check), and `cleanup`. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + +## Architecture + +One analysis engine with three faces. The engine is framework-free Python that knows nothing about HTTP or React. A thin FastAPI layer adapts it to the web. A React app consumes that API. Progress lives behind a small interface so the whole thing embeds into a larger application without dragging a web framework along. + +``` + +--------------------------+ + | rveng/engine/ (pure) | + | elf disasm plt xref cfg | + | hex strings patch discover| + | challenge (grading) | + +------------+--------------+ + | + +-----------------+-----------------+ + | | + +-----+------+ +------+------+ + | HTTP API | | library | + | FastAPI | | (import it)| + +-----+------+ +-------------+ + | + +-----+------+ + | React app | + +------------+ +``` + +In production, nginx serves the built frontend and proxies `/api` to the engine container, which stays a pure API and never learns to serve a SPA. Development mirrors that with nginx fronting the Vite dev server for hot reload. The two stacks use structural-literal project names (`rveng` and `rveng-dev`), so they are namespace-isolated by construction and never collide. + +``` +PROD (compose.yml, "rveng") DEV (dev.compose.yml, "rveng-dev") + + browser :8790 browser :8791 + | | + [ nginx ] serves dist [ nginx ] --> [ vite HMR ] + | /api | /api + [ api ] uvicorn [ api ] uvicorn --reload + | | + rveng_data (sqlite progress) rveng_data_dev +``` + +## Project Structure + +``` +rveng/ +├── compose.yml # prod self-host: nginx + api (name: rveng) +├── dev.compose.yml # dev: nginx + vite HMR + api --reload +├── justfile # dev / prod / verify / cleanup recipes +├── install.sh # one-shot curl|bash: installs Docker, builds, runs +├── uninstall.sh # tears the stacks down and removes the cache +├── infra/ +│ ├── docker/ # api.dockerfile, vite.dev, vite.prod (multistage -> nginx) +│ └── nginx/ # dev.nginx (fronts Vite HMR), prod.nginx (serves dist) +├── src/rveng/ +│ ├── engine/ # the pure analysis core (no HTTP, no framework) +│ │ ├── elf.py # hand-rolled ELF64 header / sections / symbols +│ │ ├── disasm.py # capstone x86-64 decode + annotation +│ │ ├── plt.py # PLT / GOT import resolution +│ │ ├── xref.py # cross-references from decoded instructions +│ │ ├── cfg.py # basic-block control-flow graph +│ │ ├── discover.py # stripped-binary function discovery +│ │ ├── hex.py strings.py patch.py # dump, string scan, byte diff +│ │ └── challenge.py # the challenge model and solve-then-reveal grader +│ └── api/ # thin FastAPI adapter over the engine +│ ├── app.py # create_app() and every read-only route +│ ├── store.py # challenge loader + ProgressStore (in-memory / sqlite) +│ ├── schemas.py limits.py middleware.py server.py +├── challenges/ # the six curated challenges (target + source + answer) +├── frontend/ # the React face (self-contained, extractable) +└── learn/ # the teaching track +``` + +## Learn + +This project ships a full teaching track. Read it in order, or jump to what you need. + +| Doc | What it covers | +|-----|----------------| +| [`learn/00-OVERVIEW.md`](learn/00-OVERVIEW.md) | What rveng is, the no-execution posture, and a quick tour | +| [`learn/01-CONCEPTS.md`](learn/01-CONCEPTS.md) | Reverse-engineering theory: static analysis, ELF, symbols, the PLT, patching, grounded in real analysis | +| [`learn/02-ARCHITECTURE.md`](learn/02-ARCHITECTURE.md) | The one-engine-three-faces design and how a request flows, with diagrams | +| [`learn/03-IMPLEMENTATION.md`](learn/03-IMPLEMENTATION.md) | A code walkthrough of every engine module against the sample binary | +| [`learn/04-CHALLENGES.md`](learn/04-CHALLENGES.md) | The six challenges, what each teaches, and how to add your own | + +## License + +[AGPL 3.0](LICENSE). diff --git a/PROJECTS/advanced/rveng/challenges/01-read-the-hex/challenge.json b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/challenge.json new file mode 100644 index 00000000..8902224a --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/challenge.json @@ -0,0 +1,7 @@ +{ + "id": "01-read-the-hex", + "module": "hex-reading", + "title": "Read the hex", + "mission": "This binary hides a secret string in its read-only data. Open the hex viewer and read the ASCII column on the right to recover it. Submit the string exactly as it appears.", + "answer": { "category": "found_value", "expected": "the_flag_is_here" } +} diff --git a/PROJECTS/advanced/rveng/challenges/01-read-the-hex/source.c b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/source.c new file mode 100644 index 00000000..1c42d31b --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/source.c @@ -0,0 +1,23 @@ +#include +#include + +int check(int n) { + if (n == 1337) { + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + char *secret = "the_flag_is_here"; + int n = 0; + if (argc > 1) { + n = atoi(argv[1]); + } + if (check(n)) { + printf("unlocked: %s\n", secret); + } else { + printf("wrong number\n"); + } + return 0; +} diff --git a/PROJECTS/advanced/rveng/challenges/01-read-the-hex/target b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/target new file mode 100755 index 0000000000000000000000000000000000000000..bc1436b17427672fa70b3c2a0d73bb41cf236307 GIT binary patch literal 15904 zcmeHOeQXrh5r22a*deifhC&=Dupp4!Dn4v576Ae4k2CAafD0xes?zoPZtYv;zI3;j z*l9^f-47iom7_+Ls(z(^^y8nZsG<^WrPhflR-#IER8{4VO0g;;nF6V!rWHvot~2k= zoYxy$P5Va`sXNl{yqPz@dGqG&?!CQz@3~liZ#WbZOkr_EpfnWGrAnQJc)3H0q&pz$ zg)X*FU1^VTr{8iDSpHAr93WgPIc}x=GjFH%m~(rd1{~(|!xCQ}r>MQv#;=yf zjd_R~z;@9GUTeoroDt#kVhVNM`j3H(>BjoM{OD)z9sKtDZ9o6ct92*e3^iq5{M}FX zq@3Z7_C2XYOUlU<$6Ce?bhLD|M+@2LUg-yYu|Ft&D7qi4)~{fRepb?NT}2-Qy#|M$ zFvlzT5xJsU5SE*DglR&*;%33M@~)Y-oQ%ljos2soP>sFo5Xbuay1LE1(Y?|AmGZuJ zK#r!f8A6)SQW)}A1v$VYEOvp0$E~>8xev38w-4$rzxZ_sEi4W~8R_~_U2=@qo5MMe zp06Egs4M##5NODyy4ItoTSw|69z8vZq&_$W83-~EWFW{ukbxirK?Z^h1R3}!WWboX zU29yd{=*(2j4w^O;qt68akX~35?Xox>rhqR{aZLUY}cWLauVxr&Xs|7zlt(WrEktk z`4=eTRQcu<$ZdD}N^gADDE--(xUn!W7;Bqqd(D`cNi>41jgNupyCczt?O#NH(*Lwq z>=EKhGfb$B!+3T#TtO=g^S)oBqC7V;IX&V9=;Bw>{`t2hD=iqWynWPosxQ<-KT5HMnb;zBY2w`N(6gJ2i?KOl zVhV->>SpGDdaqnA#TGBd7D4zX1Offt3N{Ny=^D5=D_zXUCgz_4+glBw9Z=f&GoX-F zufb5tF!8{7E};P}^NtC!Wq{GoxCjm>#zCBDAII5PoCDZ(9q0Ete;Ow=R^+1HgxX ze;)Xzcht*gJMiluBjGnsq|h^`L})A$+VVh6?PRDXg5T)y8~=6iwG$11R<>2&^U(HB zV9Vd`@Y`Q29A`nN0~hs?-uk9bHQfJn?Rjx@^Px}f+a=v1?kpUSLX6wcuCqS!<#2c1 z+Q%Uf5V3v?jzOq@7I>vTJgy8th2I4+UThn`-6G+)zpyL?ryv7C27(L(83-~EWFW{u zkbxirK?Z^hd^9tl!v|QT2yyTG6zU(q6B4bHu2Yh@LG}-k%!~1hXT-`VglE*A~h){HWFvXYcl6)T_?Yz z8Dzlkn7FPJ=|RPWR;|kt>lY{cq>w__mXN)~DY~dcM9vl!W6S5)TPnAArQ;gV$9E|9PP6 zRaV03b8uk0e7~^%D^LiD4T7(K*8czsEA@A^e*IYbX%x*nbaqkUEBX1QwBIQF<9Su0 zB7)b$vy(qc`v(Qz2duvX1&qm0han*v+*M7KAFf{q`jzarfIb5DoKLpjiT2_8%JYKt z&8zr1M1K738lcx0?BS1rzA@Yc+c9*HR)_@ZMFW789{;mJDu6x$88ziDx0wjV4298 zqp9q$l`<1oyYsSK@I<=nK6TEjh<7&Jl21*tFzxc z+1qq;om`^^M(P~B!C+YDl%>$FztlpT0#a+TMd@in^>=Q6@cqyTMQsc zg{+yhGKrKe%)XOgm2fgzN zW(qr>6Vfng6_O&F7|(!H#ofHBdCJZgoNQ*9U_u@A#zFv zl<_>DTkzqzeSiN;gzqAUJU`S3V2~a~oJ0EYUjrTH1mly$&+mv5dEQC2GFC1l|0*0_ zyheD9@H!fKh}7~s1**RRhZoPkXG3K81>b-%o?U*|hv)hKjYeg`^I*28w6Z9^Pk7Eh z|1JwnnY30cRl$!}^P{El=!B^&|_+SEJJ0&g2tiJ^nc +#include + +int check(int n) { + if (n == 1337) { + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + char *secret = "the_flag_is_here"; + int n = 0; + if (argc > 1) { + n = atoi(argv[1]); + } + if (check(n)) { + printf("unlocked: %s\n", secret); + } else { + printf("wrong number\n"); + } + return 0; +} diff --git a/PROJECTS/advanced/rveng/challenges/02-find-the-entry/target b/PROJECTS/advanced/rveng/challenges/02-find-the-entry/target new file mode 100755 index 0000000000000000000000000000000000000000..bc1436b17427672fa70b3c2a0d73bb41cf236307 GIT binary patch literal 15904 zcmeHOeQXrh5r22a*deifhC&=Dupp4!Dn4v576Ae4k2CAafD0xes?zoPZtYv;zI3;j z*l9^f-47iom7_+Ls(z(^^y8nZsG<^WrPhflR-#IER8{4VO0g;;nF6V!rWHvot~2k= zoYxy$P5Va`sXNl{yqPz@dGqG&?!CQz@3~liZ#WbZOkr_EpfnWGrAnQJc)3H0q&pz$ zg)X*FU1^VTr{8iDSpHAr93WgPIc}x=GjFH%m~(rd1{~(|!xCQ}r>MQv#;=yf zjd_R~z;@9GUTeoroDt#kVhVNM`j3H(>BjoM{OD)z9sKtDZ9o6ct92*e3^iq5{M}FX zq@3Z7_C2XYOUlU<$6Ce?bhLD|M+@2LUg-yYu|Ft&D7qi4)~{fRepb?NT}2-Qy#|M$ zFvlzT5xJsU5SE*DglR&*;%33M@~)Y-oQ%ljos2soP>sFo5Xbuay1LE1(Y?|AmGZuJ zK#r!f8A6)SQW)}A1v$VYEOvp0$E~>8xev38w-4$rzxZ_sEi4W~8R_~_U2=@qo5MMe zp06Egs4M##5NODyy4ItoTSw|69z8vZq&_$W83-~EWFW{ukbxirK?Z^h1R3}!WWboX zU29yd{=*(2j4w^O;qt68akX~35?Xox>rhqR{aZLUY}cWLauVxr&Xs|7zlt(WrEktk z`4=eTRQcu<$ZdD}N^gADDE--(xUn!W7;Bqqd(D`cNi>41jgNupyCczt?O#NH(*Lwq z>=EKhGfb$B!+3T#TtO=g^S)oBqC7V;IX&V9=;Bw>{`t2hD=iqWynWPosxQ<-KT5HMnb;zBY2w`N(6gJ2i?KOl zVhV->>SpGDdaqnA#TGBd7D4zX1Offt3N{Ny=^D5=D_zXUCgz_4+glBw9Z=f&GoX-F zufb5tF!8{7E};P}^NtC!Wq{GoxCjm>#zCBDAII5PoCDZ(9q0Ete;Ow=R^+1HgxX ze;)Xzcht*gJMiluBjGnsq|h^`L})A$+VVh6?PRDXg5T)y8~=6iwG$11R<>2&^U(HB zV9Vd`@Y`Q29A`nN0~hs?-uk9bHQfJn?Rjx@^Px}f+a=v1?kpUSLX6wcuCqS!<#2c1 z+Q%Uf5V3v?jzOq@7I>vTJgy8th2I4+UThn`-6G+)zpyL?ryv7C27(L(83-~EWFW{u zkbxirK?Z^hd^9tl!v|QT2yyTG6zU(q6B4bHu2Yh@LG}-k%!~1hXT-`VglE*A~h){HWFvXYcl6)T_?Yz z8Dzlkn7FPJ=|RPWR;|kt>lY{cq>w__mXN)~DY~dcM9vl!W6S5)TPnAArQ;gV$9E|9PP6 zRaV03b8uk0e7~^%D^LiD4T7(K*8czsEA@A^e*IYbX%x*nbaqkUEBX1QwBIQF<9Su0 zB7)b$vy(qc`v(Qz2duvX1&qm0han*v+*M7KAFf{q`jzarfIb5DoKLpjiT2_8%JYKt z&8zr1M1K738lcx0?BS1rzA@Yc+c9*HR)_@ZMFW789{;mJDu6x$88ziDx0wjV4298 zqp9q$l`<1oyYsSK@I<=nK6TEjh<7&Jl21*tFzxc z+1qq;om`^^M(P~B!C+YDl%>$FztlpT0#a+TMd@in^>=Q6@cqyTMQsc zg{+yhGKrKe%)XOgm2fgzN zW(qr>6Vfng6_O&F7|(!H#ofHBdCJZgoNQ*9U_u@A#zFv zl<_>DTkzqzeSiN;gzqAUJU`S3V2~a~oJ0EYUjrTH1mly$&+mv5dEQC2GFC1l|0*0_ zyheD9@H!fKh}7~s1**RRhZoPkXG3K81>b-%o?U*|hv)hKjYeg`^I*28w6Z9^Pk7Eh z|1JwnnY30cRl$!}^P{El=!B^&|_+SEJJ0&g2tiJ^nc&2; } +ok() { printf '%s\n' " ${GREEN}+${RESET} $*" >&2; } +warn() { printf '%s\n' " ${YELLOW}!${RESET} $*" >&2; } +die() { printf '%s\n' " ${RED}x $*${RESET}" >&2; exit 1; } +header(){ printf '\n%s\n\n' "${BOLD}${CYAN}--- $* ---${RESET}" >&2; } +have() { command -v "$1" >/dev/null 2>&1; } + +trap 'printf "%s\n" "${RED}x install failed${RESET}" >&2' ERR + +banner() { + printf '%s' "${CYAN}${BOLD}" >&2 + cat >&2 <<'ART' + _ __ __ __ ___ _ __ __ _ + | '__/ \ / // -_)| '_ \ / _` | + |_| \_/\_/ \___||_.__/ \__, | + |___/ +ART + printf '%s\n' "${RESET}" >&2 + printf '%s\n' " ${DIM}${TAGLINE}${RESET}" >&2 +} + +# ============================================================================ +# Privilege + package-manager fan +# ============================================================================ +SUDO="" +if [ "$(id -u)" -ne 0 ]; then + if have sudo; then SUDO="sudo"; fi +fi + +pkg_install() { + if have apt-get; then $SUDO apt-get update -y || warn "apt update had errors; continuing" + $SUDO apt-get install -y --no-install-recommends "$@" + elif have dnf; then $SUDO dnf install -y "$@" + elif have pacman; then $SUDO pacman -S --needed --noconfirm "$@" + elif have zypper; then $SUDO zypper install -y "$@" + elif have apk; then $SUDO apk add "$@" + elif have brew; then brew install "$@" + else die "no known package manager; install manually: $*"; fi +} + +# ============================================================================ +# Args +# ============================================================================ +usage() { + cat >&2 </dev/null || warn "pull failed; using existing clone" + else + info "cloning ${REPO_URL}" + git clone --depth 1 --branch "$DEFAULT_BRANCH" --quiet "$REPO_URL" "$cache" \ + || die "clone failed from ${REPO_URL}" + fi + printf '%s\n' "$cache/$PROJECT_SUBDIR" +} + +# ============================================================================ +# Docker — the only real dependency; install it if missing +# ============================================================================ +ensure_docker() { + if ! have docker; then + if [ "$OS" = "darwin" ]; then + die "Docker not found. Install Docker Desktop for Mac, then re-run." + fi + info "installing Docker via get.docker.com" + download_docker + fi + if ! docker info >/dev/null 2>&1; then + if have systemctl; then + info "starting the Docker daemon" + $SUDO systemctl start docker 2>/dev/null || true + fi + fi + docker info >/dev/null 2>&1 || die "Docker is installed but the daemon is not running; start it and re-run" + if ! docker compose version >/dev/null 2>&1; then + info "installing the Docker Compose plugin" + pkg_install docker-compose-plugin || die "install the Docker Compose v2 plugin, then re-run" + fi + ok "docker $(docker --version | awk '{print $3}' | tr -d ,)" +} + +download_docker() { + local tmp + tmp="$(mktemp)" + if have curl; then curl -fsSL https://get.docker.com -o "$tmp" + elif have wget; then wget -qO "$tmp" https://get.docker.com + else die "need curl or wget to install Docker"; fi + $SUDO sh "$tmp" || { rm -f "$tmp"; die "Docker install failed"; } + rm -f "$tmp" + if [ -n "$SUDO" ] && have usermod; then + $SUDO usermod -aG docker "$(id -un)" 2>/dev/null || true + fi +} + +# ============================================================================ +# Compose wrapper — run as the current user against the prod stack +# ============================================================================ +compose() { + NGINX_HOST_PORT="$HOST_PORT" docker compose "$@" +} + +http_probe() { + if have curl; then curl -fsS "$1" >/dev/null 2>&1 + elif have wget; then wget -q -O /dev/null "$1" 2>/dev/null + else return 1; fi +} + +wait_healthy() { + local url="http://localhost:${HOST_PORT}/api/challenges" i + for i in $(seq 1 90); do + if http_probe "$url"; then return 0; fi + sleep 2 + done + return 1 +} + +# ============================================================================ +# Main +# ============================================================================ +main() { + banner + ensure_docker + + header "Fetching rveng" + PROJECT="$(resolve_project)" + [ -d "$PROJECT" ] || die "could not locate the rveng project directory" + cd "$PROJECT" + ok "project at $PROJECT" + + header "Building and starting (this compiles the frontend and engine image)" + compose up -d --build + + header "Waiting for the app" + if wait_healthy; then + ok "rveng is answering on port ${HOST_PORT}" + else + warn "app did not answer within the timeout; check 'docker compose logs'" + fi + + printf '\n%s\n\n' " ${GREEN}${BOLD}rveng is running.${RESET}" >&2 + cat >&2 <