diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c3c7771 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1150 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fuser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e697f6f62c20b6fad1ba0f84ae909f25971cf16e735273524e3977c94604cf8" +dependencies = [ + "libc", + "log", + "memchr", + "page_size", + "pkg-config", + "smallvec", + "zerocopy 0.7.35", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy 0.8.56", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taproot" +version = "0.0.1" +dependencies = [ + "base64", + "chrono", + "clap", + "ed25519-dalek", + "fuser", + "hex", + "libc", + "rand", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive 0.8.56", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..92523cd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "taproot" +version = "0.0.1" +edition = "2021" +description = "State inheritance fabric between VCS and CI — environment-as-object" +license = "MIT" +readme = "README.md" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +hex = "0.4" +base64 = "0.22" +ed25519-dalek = { version = "2.1", features = ["rand_core"] } +rand = "0.8" +thiserror = "2.0" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +fuser = "0.14" +libc = "0.2" +tempfile = "3.0" + +[dev-dependencies] +tempfile = "3.0" diff --git a/README.md b/README.md index d72aac1..001e650 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ If the state has drifted from the signed baseline, Taproot blocks execution and We are building the wedge primitive: - [x] State serialization engine (Rust) -- [ ] FUSE mount CLI +- [x] FUSE mount CLI (read-only, v0.0.1) - [ ] GitHub Action + baseline check - [ ] Signed state registry - [ ] Managed fabric + registry API @@ -58,7 +58,10 @@ Taproot's mount CLI, protocol format, and state schema are MIT-licensed. The man git clone https://github.com/Epoch-AI-Lab/taproot.git cd taproot cargo build --release -./target/release/taproot mount ~/projects/myapp +./target/release/taproot init --repo myapp --branch main --commit 9f3a2c1 +./target/release/taproot mount --no-fuse ~/projects/myapp # requires existing dir; omit --no-fuse for real FUSE +./target/release/taproot status +./target/release/taproot verify ``` ## Contribute diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..7e21204 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,450 @@ +use std::path::{Path, PathBuf}; + +use clap::{Args, Parser, Subcommand}; + +use crate::engine::StateEngine; +use crate::error::TaprootError; +use crate::state::TaprootState; + +const DEFAULT_STATE_PATH: &str = ".taproot/state.json"; + +fn default_state_path() -> PathBuf { + PathBuf::from(DEFAULT_STATE_PATH) +} + +fn resolve_state_path(input: Option) -> PathBuf { + input.unwrap_or_else(default_state_path) +} + +fn display_state_path(path: &Path) -> String { + // Show absolute if relative, to avoid cwd confusion noted in PR review + if path.is_absolute() { + path.display().to_string() + } else if let Ok(cur) = std::env::current_dir() { + cur.join(path).display().to_string() + } else { + path.display().to_string() + } +} + +// --------------------------------------------------------------------------- +// CLI definition +// --------------------------------------------------------------------------- + +#[derive(Debug, Parser)] +#[command( + name = "taproot", + version, + about = "State inheritance fabric between VCS and CI" +)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + /// Initialise a new taproot state snapshot + Init(InitArgs), + /// Mount a taproot state (v0.0.1: read-only FUSE) + Mount(MountArgs), + /// Show current state status + Status(StatusArgs), + /// Verify state signature and hash + Verify(VerifyArgs), +} + +#[derive(Debug, Args)] +pub struct InitArgs { + /// Repository name (e.g. myapp or org/myapp) + #[arg(long)] + pub repo: String, + + /// Branch name (e.g. main or feat/foo) + #[arg(long)] + pub branch: String, + + /// Commit hash (e.g. 9f3a2c1) + #[arg(long)] + pub commit: String, + + /// Path to state file (default: .taproot/state.json, relative to current directory) + #[arg(long, value_name = "PATH")] + pub state_path: Option, + + /// Skip signing (store hash only, no ed25519 signature) + #[arg(long = "no-sign", default_value_t = false)] + pub no_sign: bool, +} + +#[derive(Debug, Args)] +pub struct MountArgs { + /// Path to mount (must be an existing empty directory) + pub path: PathBuf, + + /// Path to state file (default: .taproot/state.json, relative to current directory) + #[arg(long, value_name = "PATH")] + pub state_path: Option, + + /// Disable FUSE mount — just print header and exit (useful in CI without FUSE) + #[arg(long = "no-fuse", default_value_t = false)] + pub no_fuse: bool, +} + +#[derive(Debug, Args)] +pub struct StatusArgs { + /// Path to state file (default: .taproot/state.json, relative to current directory) + #[arg(long, value_name = "PATH")] + pub state_path: Option, +} + +#[derive(Debug, Args)] +pub struct VerifyArgs { + /// Path to state file (default: .taproot/state.json, relative to current directory) + #[arg(long, value_name = "PATH")] + pub state_path: Option, +} + +// --------------------------------------------------------------------------- +// Helpers — printing +// --------------------------------------------------------------------------- + +fn print_mount_header(signed: &crate::state::SignedState) { + let state = &signed.state; + let short_hash = if signed.hash.len() >= 12 { + &signed.hash[..12] + } else { + &signed.hash + }; + let sig_label = if signed.signature.is_some() { + "signed" + } else { + "unsigned" + }; + + println!("TAPROOT MOUNT"); + println!("─────────────────────────────────────────"); + println!("repo: {}", state.base.repo); + println!("base: {}@{}", state.base.branch, state.base.commit); + println!("state: {sig_label} · sha256:{short_hash}"); + println!("runtimes: {}", state.runtimes.len()); + for r in &state.runtimes { + println!(" - {}: {} (pinned={})", r.name, r.version, r.pinned); + } + println!("containers: {}", state.containers.len()); + for c in &state.containers { + println!(" - {}: {} ({})", c.name, c.version, c.image); + } + println!("env-vars: {}", state.env_vars.len()); +} + +fn print_status_line(ok: bool) { + if ok { + println!("status: ▶ INHERITED — ready to work"); + } else { + println!("status: ✗ DRIFTED — state verification failed"); + } +} + +fn print_unsigned_warning() { + println!("warning: ⚠ UNSIGNED — hash ok, not cryptographically signed"); +} + +fn validate_non_empty(field: &str, value: &str) -> Result<(), TaprootError> { + if value.trim().is_empty() { + return Err(TaprootError::InvalidKey(format!( + "{field} must be non-empty" + ))); + } + if value.len() > 256 { + return Err(TaprootError::InvalidKey(format!( + "{field} too long (max 256)" + ))); + } + if value.contains('\0') || value.contains('\\') { + return Err(TaprootError::InvalidKey(format!( + "{field} must not contain null byte or backslash" + ))); + } + if value.contains('\n') || value.contains('\r') { + return Err(TaprootError::InvalidKey(format!( + "{field} must not contain newline" + ))); + } + // Allow '/' for org/repo and branch names like feat/foo, but block traversal and empty segments + if value == "." || value == ".." { + return Err(TaprootError::InvalidKey(format!( + "{field} must not be '.' or '..'" + ))); + } + if value.starts_with('/') || value.ends_with('/') { + return Err(TaprootError::InvalidKey(format!( + "{field} must not start or end with '/'" + ))); + } + if value.contains("//") { + return Err(TaprootError::InvalidKey(format!( + "{field} must not contain '//'" + ))); + } + for seg in value.split('/') { + if seg == "." || seg == ".." { + return Err(TaprootError::InvalidKey(format!( + "{field} segment must not be '.' or '..'" + ))); + } + if seg.is_empty() && value.contains('/') { + return Err(TaprootError::InvalidKey(format!( + "{field} contains empty segment" + ))); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +pub fn handle_init(args: InitArgs) -> Result<(), TaprootError> { + validate_non_empty("repo", &args.repo)?; + validate_non_empty("branch", &args.branch)?; + validate_non_empty("commit", &args.commit)?; + + let state_path = resolve_state_path(args.state_path); + tracing::info!(?state_path, repo = %args.repo, "init state"); + + let state = TaprootState::new(args.repo.clone(), args.branch.clone(), args.commit.clone()); + + let signed = if args.no_sign { + let hash = StateEngine::hash(&state)?; + crate::state::SignedState { + state, + hash, + signature: None, + public_key: None, + } + } else { + let (priv_key, _pub_key) = StateEngine::generate_keypair(); + StateEngine::sign(&state, &priv_key)? + }; + + if let Some(parent) = state_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + + StateEngine::save(&state_path, &signed)?; + + // Header — similar to README / mount but labelled for init + let short_hash = if signed.hash.len() >= 12 { + &signed.hash[..12] + } else { + &signed.hash + }; + let sig_label = if signed.signature.is_some() { + "signed" + } else { + "unsigned" + }; + + println!("TAPROOT INIT"); + println!("─────────────────────────────────────────"); + println!("repo: {}", signed.state.base.repo); + println!( + "base: {}@{}", + signed.state.base.branch, signed.state.base.commit + ); + println!("state: {sig_label} · sha256:{short_hash}"); + println!("hash: {}", signed.hash); + if let Some(pk) = &signed.public_key { + let preview = if pk.len() >= 16 { &pk[..16] } else { pk }; + println!("pubkey: {preview}..."); + } + println!("path: {}", display_state_path(&state_path)); + println!(); + print_status_line(true); + println!(); + println!("[next: taproot mount ]"); + + Ok(()) +} + +pub fn handle_mount(args: MountArgs) -> Result<(), TaprootError> { + let state_path = resolve_state_path(args.state_path); + tracing::info!(?state_path, ?args.path, "mount"); + + let signed = match StateEngine::load(&state_path) { + Ok(s) => s, + Err(e) => { + eprintln!( + "warning: failed to load state from {}: {e}", + display_state_path(&state_path) + ); + if !Path::new(&state_path).exists() { + eprintln!("hint: run `taproot init --repo --branch --commit ` first"); + } + println!(); + println!("status: ✗ ERROR — state not found or invalid"); + println!(); + return Err(e); + } + }; + + print_mount_header(&signed); + println!(); + println!("mount: {}", args.path.display()); + let target_meta = std::fs::symlink_metadata(&args.path); + match &target_meta { + Ok(m) if m.is_dir() => println!("target: exists (directory)"), + Ok(m) if m.file_type().is_symlink() => { + println!("target: exists (symlink — will be rejected)") + } + Ok(_) => println!("target: exists (not a directory — will be rejected)"), + Err(_) => println!("target: not found"), + } + println!("hash: {}", signed.hash); + if signed.signature.is_none() { + print_unsigned_warning(); + } + println!(); + + // Validate mountpoint before honoring --no-fuse — CI must not hide symlink/file attacks + if let Ok(m) = &target_meta { + if m.file_type().is_symlink() { + let e = TaprootError::Mount(format!( + "mountpoint is a symlink (refusing): {}", + args.path.display() + )); + eprintln!("✗ mount failed: {e}"); + println!("status: ✗ MOUNT FAILED — symlink rejected"); + println!(); + return Err(e); + } + if !m.is_dir() { + let e = TaprootError::Mount(format!( + "mountpoint is not a directory: {}", + args.path.display() + )); + eprintln!("✗ mount failed: {e}"); + println!("status: ✗ MOUNT FAILED — not a directory"); + println!(); + return Err(e); + } + } else if !args.no_fuse { + // real mount requires existing dir + let e = TaprootError::Mount(format!( + "mountpoint does not exist: {}", + args.path.display() + )); + eprintln!("✗ mount failed: {e}"); + println!("status: ✗ MOUNT FAILED — mountpoint missing"); + println!(); + return Err(e); + } + + if args.no_fuse { + println!("(no-fuse — skipping FUSE mount, mountpoint validated)"); + print_status_line(true); + println!(); + return Ok(()); + } + + println!( + "attempting FUSE mount at {} (read-only, Ctrl-C to unmount)...", + args.path.display() + ); + match crate::mount::mount_readonly(&args.path, &signed) { + Ok(()) => { + print_status_line(true); + println!(); + Ok(()) + } + Err(e) => { + eprintln!("✗ mount failed: {e}"); + println!("status: ✗ MOUNT FAILED — {}", e); + println!(); + Err(e) + } + } +} + +pub fn handle_status(args: StatusArgs) -> Result<(), TaprootError> { + let state_path = resolve_state_path(args.state_path); + tracing::info!(?state_path, "status"); + + let signed = StateEngine::load(&state_path)?; + + println!("TAPROOT STATUS"); + println!("─────────────────────────────────────────"); + // reuse same header but with correct title + let short_hash = if signed.hash.len() >= 12 { + &signed.hash[..12] + } else { + &signed.hash + }; + let sig_label = if signed.signature.is_some() { + "signed" + } else { + "unsigned" + }; + println!("repo: {}", signed.state.base.repo); + println!( + "base: {}@{}", + signed.state.base.branch, signed.state.base.commit + ); + println!("state: {sig_label} · sha256:{short_hash}"); + println!("runtimes: {}", signed.state.runtimes.len()); + println!("containers: {}", signed.state.containers.len()); + println!("env-vars: {}", signed.state.env_vars.len()); + println!(); + println!("hash: {}", signed.hash); + if let Some(pk) = &signed.public_key { + let preview = if pk.len() >= 16 { &pk[..16] } else { pk }; + println!("pubkey: {preview}..."); + } + if signed.signature.is_none() { + print_unsigned_warning(); + } + println!("path: {}", display_state_path(&state_path)); + println!(); + print_status_line(true); + println!(); + + Ok(()) +} + +pub fn handle_verify(args: VerifyArgs) -> Result<(), TaprootError> { + let state_path = resolve_state_path(args.state_path); + tracing::info!(?state_path, "verify"); + + match StateEngine::load(&state_path) { + Ok(signed) => { + if signed.signature.is_none() { + println!("⚠ verified (unsigned) — sha256:{}", signed.hash); + println!( + " repo: {} base: {}@{}", + signed.state.base.repo, signed.state.base.branch, signed.state.base.commit + ); + println!(" path: {}", display_state_path(&state_path)); + print_unsigned_warning(); + } else { + println!("✓ verified — sha256:{}", signed.hash); + println!( + " repo: {} base: {}@{}", + signed.state.base.repo, signed.state.base.branch, signed.state.base.commit + ); + println!(" path: {}", display_state_path(&state_path)); + } + Ok(()) + } + Err(e) => { + eprintln!( + "✗ verification failed for {}: {e}", + display_state_path(&state_path) + ); + Err(e) + } + } +} diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..10e0330 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,239 @@ +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::rngs::OsRng; +use sha2::{Digest, Sha256}; + +use crate::error::TaprootError; +use crate::state::{SignedState, TaprootState}; + +pub struct StateEngine; + +impl StateEngine { + /// Canonical JSON — sorted keys, no whitespace tricks. + /// Uses serde_json with BTreeMap already sorted, then re-serializes deterministically. + pub fn to_canonical_json(state: &TaprootState) -> Result, TaprootError> { + // serde_json sorts struct keys by definition order; BTreeMap sorts env_vars. + // For true canonical, we serialize via Value then to_string with sorted keys. + let json = serde_json::to_string(state)?; + // Parse and re-stringify to ensure deterministic key ordering at all levels. + // serde_json's Value uses BTreeMap internally when `preserve_order` is off (default). + let value: serde_json::Value = serde_json::from_str(&json)?; + Ok(serde_json::to_vec(&value)?) + } + + pub fn serialize(state: &TaprootState) -> Result, TaprootError> { + Ok(serde_json::to_vec_pretty(state)?) + } + + pub fn deserialize(bytes: &[u8]) -> Result { + Ok(serde_json::from_slice(bytes)?) + } + + /// sha256 hex of canonical JSON + pub fn hash(state: &TaprootState) -> Result { + let canonical = Self::to_canonical_json(state)?; + let mut hasher = Sha256::new(); + hasher.update(&canonical); + Ok(hex::encode(hasher.finalize())) + } + + /// Generate a fresh ed25519 keypair. Returns (private_key_b64, public_key_b64) + pub fn generate_keypair() -> (String, String) { + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + ( + B64.encode(signing_key.to_bytes()), + B64.encode(verifying_key.to_bytes()), + ) + } + + /// Sign state. Returns SignedState with hash + signature. + pub fn sign(state: &TaprootState, private_key_b64: &str) -> Result { + let hash_hex = Self::hash(state)?; + let hash_bytes = hex::decode(&hash_hex) + .map_err(|e| TaprootError::InvalidKey(format!("hash not hex: {e}")))?; + + let key_bytes = B64 + .decode(private_key_b64.trim()) + .map_err(|e| TaprootError::InvalidKey(e.to_string()))?; + let key_arr: [u8; 32] = key_bytes + .try_into() + .map_err(|_| TaprootError::InvalidKey("private key must be 32 bytes".into()))?; + let signing_key = SigningKey::from_bytes(&key_arr); + let signature: Signature = signing_key.sign(&hash_bytes); + + let public_key_b64 = B64.encode(signing_key.verifying_key().to_bytes()); + + Ok(SignedState { + state: state.clone(), + hash: hash_hex, + signature: Some(B64.encode(signature.to_bytes())), + public_key: Some(public_key_b64), + }) + } + + /// Verify SignedState. Checks hash matches state and signature is valid. + pub fn verify(signed: &SignedState) -> Result<(), TaprootError> { + let computed = Self::hash(&signed.state)?; + if computed != signed.hash { + return Err(TaprootError::HashMismatch { + expected: signed.hash.clone(), + got: computed, + }); + } + + match (&signed.signature, &signed.public_key) { + (Some(sig_b64), Some(pub_b64)) => { + let sig_bytes = B64 + .decode(sig_b64) + .map_err(|e| TaprootError::InvalidKey(e.to_string()))?; + let pub_bytes = B64 + .decode(pub_b64) + .map_err(|e| TaprootError::InvalidKey(e.to_string()))?; + + let sig_arr: [u8; 64] = sig_bytes + .try_into() + .map_err(|_| TaprootError::InvalidKey("signature must be 64 bytes".into()))?; + let pub_arr: [u8; 32] = pub_bytes + .try_into() + .map_err(|_| TaprootError::InvalidKey("public key must be 32 bytes".into()))?; + + let verifying_key = VerifyingKey::from_bytes(&pub_arr) + .map_err(|e| TaprootError::InvalidKey(e.to_string()))?; + let signature = Signature::from_bytes(&sig_arr); + + let hash_bytes = hex::decode(&signed.hash) + .map_err(|e| TaprootError::InvalidKey(format!("hash not hex: {e}")))?; + verifying_key + .verify(&hash_bytes, &signature) + .map_err(|_| TaprootError::InvalidSignature) + } + (None, None) => Ok(()), // unsigned is okay, hash already checked + _ => Err(TaprootError::InvalidKey( + "signature and public_key must both be present or both absent".into(), + )), + } + } + + /// Save signed state to file (pretty JSON) — atomic via tempfile in same dir + pub fn save(path: &std::path::Path, signed: &SignedState) -> Result<(), TaprootError> { + use std::io::Write; + let bytes = serde_json::to_vec_pretty(signed)?; + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")); + if !parent.as_os_str().is_empty() && parent != std::path::Path::new(".") { + std::fs::create_dir_all(parent)?; + } + // Use tempfile with random suffix to avoid symlink races and collisions + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(&bytes)?; + tmp.flush()?; + tmp.as_file().sync_all()?; + tmp.persist(path).map_err(|e| TaprootError::Io(e.error))?; + // fsync parent dir for durability + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + Ok(()) + } + + /// Load signed state from file and verify + pub fn load(path: &std::path::Path) -> Result { + let bytes = std::fs::read(path)?; + let signed: SignedState = serde_json::from_slice(&bytes)?; + Self::verify(&signed)?; + Ok(signed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_state() -> TaprootState { + TaprootState::new("myapp", "main", "9f3a2c1") + .with_runtime("python", "3.11.4") + .with_runtime("node", "20.5.0") + .with_container("postgres", "15.3", "postgres:15.3") + .with_env("DATABASE_URL", "postgres://localhost/taproot") + .with_env("NODE_ENV", "development") + } + + #[test] + fn roundtrip_json() { + let state = sample_state(); + let bytes = StateEngine::serialize(&state).unwrap(); + let decoded = StateEngine::deserialize(&bytes).unwrap(); + assert_eq!(state.base, decoded.base); + assert_eq!(state.runtimes, decoded.runtimes); + } + + #[test] + fn hash_is_deterministic() { + let state = sample_state(); + let h1 = StateEngine::hash(&state).unwrap(); + let h2 = StateEngine::hash(&state).unwrap(); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); // hex sha256 + } + + #[test] + fn hash_changes_on_mutation() { + let s1 = sample_state(); + let mut s2 = s1.clone(); + s2.env_vars.insert("NEW".into(), "1".into()); + assert_ne!( + StateEngine::hash(&s1).unwrap(), + StateEngine::hash(&s2).unwrap() + ); + } + + #[test] + fn sign_and_verify() { + let state = sample_state(); + let (priv_b64, _) = StateEngine::generate_keypair(); + let signed = StateEngine::sign(&state, &priv_b64).unwrap(); + assert!(StateEngine::verify(&signed).is_ok()); + } + + #[test] + fn verify_fails_on_tamper() { + let state = sample_state(); + let (priv_b64, _) = StateEngine::generate_keypair(); + let mut signed = StateEngine::sign(&state, &priv_b64).unwrap(); + signed.state.env_vars.insert("EVIL".into(), "1".into()); + assert!(StateEngine::verify(&signed).is_err()); + } + + #[test] + fn verify_fails_on_wrong_key() { + let state = sample_state(); + let (priv_b64, _) = StateEngine::generate_keypair(); + let (other_priv, _) = StateEngine::generate_keypair(); + let mut signed = StateEngine::sign(&state, &priv_b64).unwrap(); + // re-sign hash with wrong key but keep hash + let other_signed = StateEngine::sign(&state, &other_priv).unwrap(); + signed.signature = other_signed.signature; + signed.public_key = other_signed.public_key; + // Now tamper one more way: sign with other key but verify should use that key — it will pass. + // So instead test: keep original signature, swap pubkey + let mut tampered = StateEngine::sign(&state, &priv_b64).unwrap(); + let (_, other_pub) = StateEngine::generate_keypair(); + tampered.public_key = Some(other_pub); + assert!(StateEngine::verify(&tampered).is_err()); + } + + #[test] + fn save_and_load_roundtrip() { + let state = sample_state(); + let (priv_b64, _) = StateEngine::generate_keypair(); + let signed = StateEngine::sign(&state, &priv_b64).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.json"); + StateEngine::save(&path, &signed).unwrap(); + let loaded = StateEngine::load(&path).unwrap(); + assert_eq!(signed.hash, loaded.hash); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..0fc57e2 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TaprootError { + #[error("serialization failed: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("invalid hash: expected {expected}, got {got}")] + HashMismatch { expected: String, got: String }, + + #[error("signature verification failed")] + InvalidSignature, + + #[error("invalid key: {0}")] + InvalidKey(String), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("mount failed: {0}")] + Mount(String), +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..60214ea --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod cli; +pub mod engine; +pub mod error; +pub mod mount; +pub mod state; + +pub use engine::StateEngine; +pub use error::TaprootError; +pub use state::{BaseRef, Container, Runtime, SignedState, TaprootState}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..19cca03 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,25 @@ +use clap::Parser; +use taproot::cli::{handle_init, handle_mount, handle_status, handle_verify, Cli, Commands}; + +fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .init(); + + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Init(args) => handle_init(args), + Commands::Mount(args) => handle_mount(args), + Commands::Status(args) => handle_status(args), + Commands::Verify(args) => handle_verify(args), + }; + + if let Err(e) = result { + eprintln!("error: {e}"); + std::process::exit(1); + } +} diff --git a/src/mount.rs b/src/mount.rs new file mode 100644 index 0000000..0989842 --- /dev/null +++ b/src/mount.rs @@ -0,0 +1,618 @@ +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +use fuser::{ + FileAttr, FileType, Filesystem, KernelConfig, ReplyAttr, ReplyCreate, ReplyData, + ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyWrite, Request, TimeOrNow, +}; +use libc::{c_int, EROFS}; + +use crate::error::TaprootError; +use crate::state::SignedState; + +// --------------------------------------------------------------------------- +// Inode table +// --------------------------------------------------------------------------- + +const TTL: Duration = Duration::from_secs(1); +const ROOT_INO: u64 = 1; + +#[derive(Debug, Clone)] +struct Inode { + ino: u64, + parent: u64, + name: String, + kind: FileType, + data: Vec, + children: Vec, +} + +fn now() -> SystemTime { + SystemTime::now() +} + +fn file_attr(ino: u64, size: u64, kind: FileType) -> FileAttr { + let t = now(); + FileAttr { + ino, + size, + blocks: size + .div_ceil(512) + .max(if kind == FileType::Directory { 1 } else { 0 }), + atime: t, + mtime: t, + ctime: t, + crtime: t, + kind, + perm: if kind == FileType::Directory { + 0o555 + } else { + 0o444 + }, + nlink: if kind == FileType::Directory { 2 } else { 1 }, + uid: unsafe { libc::getuid() } as u32, + gid: unsafe { libc::getgid() } as u32, + rdev: 0, + blksize: 4096, + flags: 0, + } +} + +fn is_safe_filename(name: &str) -> bool { + if name.is_empty() || name.len() > 64 { + return false; + } + if name == "." || name == ".." { + return false; + } + if name.contains('/') || name.contains('\0') || name.contains('\\') { + return false; + } + name.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') +} + +// --------------------------------------------------------------------------- +// TaprootFS +// --------------------------------------------------------------------------- + +pub struct TaprootFS { + inodes: HashMap, + /// (parent_ino, name) -> ino + lookup: HashMap<(u64, String), u64>, + next_ino: u64, +} + +impl TaprootFS { + pub fn new(signed: &SignedState) -> Self { + let mut fs = Self { + inodes: HashMap::new(), + lookup: HashMap::new(), + next_ino: ROOT_INO + 1, + }; + + fs.inodes.insert( + ROOT_INO, + Inode { + ino: ROOT_INO, + parent: ROOT_INO, + name: String::new(), + kind: FileType::Directory, + data: Vec::new(), + children: Vec::new(), + }, + ); + + let readme = Self::readme_content(signed); + let state_json = serde_json::to_string_pretty(signed).unwrap_or_else(|_| "{}".into()); + let env_content = Self::env_content(signed); + + fs.add_file(ROOT_INO, "README.taproot", readme.into_bytes()); + fs.add_file(ROOT_INO, "state.json", state_json.into_bytes()); + fs.add_file(ROOT_INO, "env", env_content.into_bytes()); + fs.add_file(ROOT_INO, "hash", signed.hash.clone().into_bytes()); + fs.add_file( + ROOT_INO, + "version", + signed.state.version.clone().into_bytes(), + ); + + let runtimes_ino = fs.add_dir(ROOT_INO, "runtimes"); + for r in &signed.state.runtimes { + if !is_safe_filename(&r.name) { + continue; + } + let content = format!( + "name: {}\nversion: {}\npinned: {}\n", + r.name, r.version, r.pinned + ); + let fname = format!("{}.txt", r.name); + if fs.lookup.contains_key(&(runtimes_ino, fname.clone())) { + continue; + } + fs.add_file(runtimes_ino, &fname, content.into_bytes()); + } + + let containers_ino = fs.add_dir(ROOT_INO, "containers"); + for c in &signed.state.containers { + if !is_safe_filename(&c.name) { + continue; + } + let content = format!( + "name: {}\nversion: {}\nimage: {}\nsigned: {}\n", + c.name, c.version, c.image, c.signed + ); + let fname = format!("{}.txt", c.name); + if fs.lookup.contains_key(&(containers_ino, fname.clone())) { + continue; + } + fs.add_file(containers_ino, &fname, content.into_bytes()); + } + + fs + } + + fn readme_content(signed: &SignedState) -> String { + let s = &signed.state; + let sig = if signed.signature.is_some() { + "signed" + } else { + "unsigned" + }; + format!( + "taproot read-only mount\n\ + =======================\n\ + repo: {} branch: {} commit: {}\n\ + state: {sig} sha256:{}\n\ + runtimes: {} containers: {} env-vars: {}\n\ + \n\ + This filesystem is read-only. All writes return EROFS.\n\ + Files:\n\ + state.json — pretty-printed SignedState\n\ + env — key=value list\n\ + hash — sha256 hex\n\ + version — schema version\n\ + runtimes/ — per-runtime virtual files\n\ + containers/ — per-container virtual files\n", + s.base.repo, + s.base.branch, + s.base.commit, + signed.hash, + s.runtimes.len(), + s.containers.len(), + s.env_vars.len(), + ) + } + + fn env_content(signed: &SignedState) -> String { + if signed.state.env_vars.is_empty() { + return String::new(); + } + let mut out = String::new(); + for (k, v) in &signed.state.env_vars { + out.push_str(k); + out.push('='); + out.push_str(v); + out.push('\n'); + } + out + } + + fn add_file(&mut self, parent: u64, name: &str, data: Vec) -> u64 { + let ino = self.next_ino; + self.next_ino += 1; + let inode = Inode { + ino, + parent, + name: name.to_string(), + kind: FileType::RegularFile, + data, + children: Vec::new(), + }; + self.inodes.insert(ino, inode); + self.lookup.insert((parent, name.to_string()), ino); + if let Some(p) = self.inodes.get_mut(&parent) { + p.children.push(ino); + } + ino + } + + fn add_dir(&mut self, parent: u64, name: &str) -> u64 { + let ino = self.next_ino; + self.next_ino += 1; + let inode = Inode { + ino, + parent, + name: name.to_string(), + kind: FileType::Directory, + data: Vec::new(), + children: Vec::new(), + }; + self.inodes.insert(ino, inode); + self.lookup.insert((parent, name.to_string()), ino); + if let Some(p) = self.inodes.get_mut(&parent) { + p.children.push(ino); + } + ino + } + + #[cfg(test)] + pub fn inode_count(&self) -> usize { + self.inodes.len() + } + + #[cfg(test)] + #[allow(private_interfaces)] + pub fn get_inode(&self, ino: u64) -> Option<&Inode> { + self.inodes.get(&ino) + } + + #[cfg(test)] + pub fn lookup_ino(&self, parent: u64, name: &str) -> Option { + self.lookup.get(&(parent, name.to_string())).copied() + } + + fn getattr_for(&self, ino: u64) -> Option { + let inode = self.inodes.get(&ino)?; + let size = if inode.kind == FileType::Directory { + 0 + } else { + inode.data.len() as u64 + }; + Some(file_attr(ino, size, inode.kind)) + } +} + +// --------------------------------------------------------------------------- +// Filesystem impl — read-only +// --------------------------------------------------------------------------- + +impl Filesystem for TaprootFS { + fn init(&mut self, _req: &Request<'_>, _config: &mut KernelConfig) -> Result<(), c_int> { + Ok(()) + } + + fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) { + let name_str = name.to_string_lossy().to_string(); + if let Some(&ino) = self.lookup.get(&(parent, name_str)) { + if let Some(attr) = self.getattr_for(ino) { + reply.entry(&TTL, &attr, 0); + return; + } + } + reply.error(libc::ENOENT); + } + + fn getattr(&mut self, _req: &Request<'_>, ino: u64, reply: ReplyAttr) { + if let Some(attr) = self.getattr_for(ino) { + reply.attr(&TTL, &attr); + } else { + reply.error(libc::ENOENT); + } + } + + fn open(&mut self, _req: &Request<'_>, ino: u64, flags: i32, reply: ReplyOpen) { + if (flags & libc::O_TRUNC) != 0 || (flags & libc::O_CREAT) != 0 { + reply.error(EROFS); + return; + } + let accmode = flags & libc::O_ACCMODE; + if accmode == libc::O_WRONLY || accmode == libc::O_RDWR { + reply.error(EROFS); + return; + } + if self.inodes.contains_key(&ino) { + reply.opened(0, 0); + } else { + reply.error(libc::ENOENT); + } + } + + fn read( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyData, + ) { + let Some(inode) = self.inodes.get(&ino) else { + reply.error(libc::ENOENT); + return; + }; + if inode.kind == FileType::Directory { + reply.error(libc::EISDIR); + return; + } + if offset < 0 { + reply.error(libc::EINVAL); + return; + } + let data = &inode.data; + let off = offset as usize; + if off >= data.len() { + reply.data(&[]); + return; + } + let end = (off + size as usize).min(data.len()); + reply.data(&data[off..end]); + } + + fn readdir( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + mut reply: ReplyDirectory, + ) { + if offset < 0 { + reply.error(libc::EINVAL); + return; + } + let Some(inode) = self.inodes.get(&ino).cloned() else { + reply.error(libc::ENOENT); + return; + }; + if inode.kind != FileType::Directory { + reply.error(libc::ENOTDIR); + return; + } + + let mut entries: Vec<(u64, FileType, String)> = Vec::new(); + entries.push((ino, FileType::Directory, ".".to_string())); + entries.push(( + if ino == ROOT_INO { + ROOT_INO + } else { + inode.parent + }, + FileType::Directory, + "..".to_string(), + )); + for &child_ino in &inode.children { + if let Some(child) = self.inodes.get(&child_ino) { + entries.push((child.ino, child.kind, child.name.clone())); + } + } + + for (i, (child_ino, kind, name)) in entries.into_iter().enumerate() { + let idx = (i + 1) as i64; + if idx <= offset { + continue; + } + if reply.add(child_ino, idx, kind, name) { + break; + } + } + reply.ok(); + } + + fn release( + &mut self, + _req: &Request<'_>, + _ino: u64, + _fh: u64, + _flags: i32, + _lock_owner: Option, + _flush: bool, + reply: ReplyEmpty, + ) { + reply.ok(); + } + + // --- read-only denials --- + fn write( + &mut self, + _req: &Request<'_>, + _ino: u64, + _fh: u64, + _offset: i64, + _data: &[u8], + _write_flags: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyWrite, + ) { + reply.error(EROFS); + } + + fn create( + &mut self, + _req: &Request<'_>, + _parent: u64, + _name: &OsStr, + _mode: u32, + _umask: u32, + _flags: i32, + reply: ReplyCreate, + ) { + reply.error(EROFS); + } + + fn mkdir( + &mut self, + _req: &Request<'_>, + _parent: u64, + _name: &OsStr, + _mode: u32, + _umask: u32, + reply: ReplyEntry, + ) { + reply.error(EROFS); + } + + fn mknod( + &mut self, + _req: &Request<'_>, + _parent: u64, + _name: &OsStr, + _mode: u32, + _umask: u32, + _rdev: u32, + reply: ReplyEntry, + ) { + reply.error(EROFS); + } + + fn unlink(&mut self, _req: &Request<'_>, _parent: u64, _name: &OsStr, reply: ReplyEmpty) { + reply.error(EROFS); + } + + fn rmdir(&mut self, _req: &Request<'_>, _parent: u64, _name: &OsStr, reply: ReplyEmpty) { + reply.error(EROFS); + } + + fn rename( + &mut self, + _req: &Request<'_>, + _parent: u64, + _name: &OsStr, + _newparent: u64, + _newname: &OsStr, + _flags: u32, + reply: ReplyEmpty, + ) { + reply.error(EROFS); + } + + fn setattr( + &mut self, + _req: &Request<'_>, + _ino: u64, + _mode: Option, + _uid: Option, + _gid: Option, + _size: Option, + _atime: Option, + _mtime: Option, + _ctime: Option, + _fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + _flags: Option, + reply: ReplyAttr, + ) { + reply.error(EROFS); + } +} + +// --------------------------------------------------------------------------- +// Public mount helper +// --------------------------------------------------------------------------- + +/// Mount a read-only FUSE filesystem at `mountpoint` reflecting `signed`. +/// +/// Blocks until unmounted. Mount options: RO, FSName("taproot"). +pub fn mount_readonly(mountpoint: &Path, signed: &SignedState) -> Result<(), TaprootError> { + let meta = std::fs::symlink_metadata(mountpoint).map_err(|e| { + TaprootError::Mount(format!( + "mountpoint does not exist: {}: {e}", + mountpoint.display() + )) + })?; + if meta.file_type().is_symlink() { + return Err(TaprootError::Mount(format!( + "mountpoint is a symlink (refusing): {}", + mountpoint.display() + ))); + } + if !meta.is_dir() { + return Err(TaprootError::Mount(format!( + "mountpoint is not a directory: {}", + mountpoint.display() + ))); + } + let fs = TaprootFS::new(signed); + let options = [ + fuser::MountOption::RO, + fuser::MountOption::FSName("taproot".to_string()), + fuser::MountOption::Subtype("taproot".to_string()), + ]; + fuser::mount2(fs, mountpoint, &options).map_err(|e| TaprootError::Mount(e.to_string()))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::TaprootState; + + fn sample_signed() -> SignedState { + let state = TaprootState::new("myapp", "main", "abc123") + .with_runtime("python", "3.11.4") + .with_env("FOO", "bar") + .with_container("postgres", "15.3", "postgres:15.3"); + let hash = crate::engine::StateEngine::hash(&state).unwrap(); + SignedState { + state, + hash, + signature: None, + public_key: None, + } + } + + #[test] + fn inode_table_has_expected_files() { + let signed = sample_signed(); + let fs = TaprootFS::new(&signed); + assert!(fs.inode_count() >= 8); + assert!(fs.lookup_ino(ROOT_INO, "state.json").is_some()); + assert!(fs.lookup_ino(ROOT_INO, "README.taproot").is_some()); + assert!(fs.lookup_ino(ROOT_INO, "env").is_some()); + assert!(fs.lookup_ino(ROOT_INO, "hash").is_some()); + assert!(fs.lookup_ino(ROOT_INO, "runtimes").is_some()); + assert!(fs.lookup_ino(ROOT_INO, "containers").is_some()); + } + + #[test] + fn file_content_correct() { + let signed = sample_signed(); + let fs = TaprootFS::new(&signed); + let ino = fs.lookup_ino(ROOT_INO, "env").unwrap(); + let inode = fs.get_inode(ino).unwrap(); + let text = String::from_utf8_lossy(&inode.data); + assert!(text.contains("FOO=bar")); + + let ino2 = fs.lookup_ino(ROOT_INO, "hash").unwrap(); + let inode2 = fs.get_inode(ino2).unwrap(); + assert_eq!(String::from_utf8_lossy(&inode2.data), signed.hash); + } + + #[test] + fn per_runtime_container_files() { + let signed = sample_signed(); + let fs = TaprootFS::new(&signed); + let runtimes = fs.lookup_ino(ROOT_INO, "runtimes").unwrap(); + assert!(fs.lookup_ino(runtimes, "python.txt").is_some()); + let containers = fs.lookup_ino(ROOT_INO, "containers").unwrap(); + assert!(fs.lookup_ino(containers, "postgres.txt").is_some()); + } + + #[test] + fn getattr_root_is_dir() { + let signed = sample_signed(); + let fs = TaprootFS::new(&signed); + let attr = fs.getattr_for(ROOT_INO).unwrap(); + assert_eq!(attr.kind, FileType::Directory); + assert_eq!(attr.perm, 0o555); + } + + #[test] + fn mount_readonly_errors_on_missing_path() { + let signed = sample_signed(); + let res = mount_readonly(Path::new("/tmp/does-not-exist-taproot-test-xyz"), &signed); + assert!(res.is_err()); + assert!(matches!(res.unwrap_err(), TaprootError::Mount(_))); + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..c1b6a4d --- /dev/null +++ b/src/state.rs @@ -0,0 +1,121 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Git baseline this environment inherits from. +/// Think `main@9f3a2c1` — branch + commit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BaseRef { + pub repo: String, + pub branch: String, + pub commit: String, +} + +/// A pinned runtime — python 3.11.4, node 20.5.0, etc. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Runtime { + pub name: String, + pub version: String, + #[serde(default = "default_true")] + pub pinned: bool, +} + +fn default_true() -> bool { + true +} + +/// A containerized service — postgres 15.3 etc. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Container { + pub name: String, + pub version: String, + /// e.g. postgres:15.3 or full digest + pub image: String, + #[serde(default)] + pub signed: bool, +} + +/// The core state object — everything needed to reproduce the env. +/// BTreeMap for env_vars ensures deterministic ordering. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaprootState { + /// Schema version, e.g. "1.0" + pub version: String, + pub base: BaseRef, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtimes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub containers: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env_vars: BTreeMap, + /// When snapshot was taken + pub created_at: DateTime, + /// Optional freeform notes + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +impl TaprootState { + pub fn new( + repo: impl Into, + branch: impl Into, + commit: impl Into, + ) -> Self { + Self { + version: "1.0".to_string(), + base: BaseRef { + repo: repo.into(), + branch: branch.into(), + commit: commit.into(), + }, + runtimes: Vec::new(), + containers: Vec::new(), + env_vars: BTreeMap::new(), + created_at: Utc::now(), + notes: None, + } + } + + pub fn with_runtime(mut self, name: impl Into, version: impl Into) -> Self { + self.runtimes.push(Runtime { + name: name.into(), + version: version.into(), + pinned: true, + }); + self + } + + pub fn with_container( + mut self, + name: impl Into, + version: impl Into, + image: impl Into, + ) -> Self { + self.containers.push(Container { + name: name.into(), + version: version.into(), + image: image.into(), + signed: true, + }); + self + } + + pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { + self.env_vars.insert(key.into(), value.into()); + self + } +} + +/// State + its integrity envelope. Hash is sha256 of canonical JSON. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedState { + pub state: TaprootState, + /// hex sha256, no prefix + pub hash: String, + /// base64 ed25519 signature over hash bytes, None if unsigned + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, + /// base64 public key that signed it, if any + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_key: Option, +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..b3688cc --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,122 @@ +use std::path::PathBuf; +use taproot::cli::{handle_init, handle_mount, handle_status, handle_verify, InitArgs, MountArgs}; + +fn temp_dir() -> tempfile::TempDir { + tempfile::tempdir().unwrap() +} + +#[test] +fn init_allows_slash_in_repo_and_branch() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + let args = InitArgs { + repo: "Epoch-AI-Lab/taproot".into(), + branch: "feat/cli-v0.0.1-readonly-mount".into(), + commit: "abc123".into(), + state_path: Some(state_path.clone()), + no_sign: true, + }; + assert!(handle_init(args).is_ok()); + assert!(state_path.exists()); +} + +#[test] +fn init_rejects_dotdot() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + let args = InitArgs { + repo: "myapp".into(), + branch: "../etc".into(), + commit: "abc".into(), + state_path: Some(state_path), + no_sign: true, + }; + assert!(handle_init(args).is_err()); +} + +#[test] +fn init_rejects_empty_repo() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + let args = InitArgs { + repo: "".into(), + branch: "main".into(), + commit: "abc".into(), + state_path: Some(state_path), + no_sign: true, + }; + assert!(handle_init(args).is_err()); +} + +#[test] +fn mount_rejects_symlink_even_with_no_fuse() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + let init = InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state_path.clone()), + no_sign: true, + }; + handle_init(init).unwrap(); + + let real = dir.path().join("real"); + std::fs::create_dir_all(&real).unwrap(); + let link = dir.path().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let args = MountArgs { + path: link, + state_path: Some(state_path), + no_fuse: true, + }; + assert!(handle_mount(args).is_err()); +} + +#[test] +fn mount_no_fuse_succeeds_on_valid_dir() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + let init = InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state_path.clone()), + no_sign: true, + }; + handle_init(init).unwrap(); + + let mnt = dir.path().join("mnt"); + std::fs::create_dir_all(&mnt).unwrap(); + + let args = MountArgs { + path: mnt, + state_path: Some(state_path), + no_fuse: true, + }; + assert!(handle_mount(args).is_ok()); +} + +#[test] +fn status_and_verify_roundtrip() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + let init = InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state_path.clone()), + no_sign: false, + }; + handle_init(init).unwrap(); + + assert!(handle_status(taproot::cli::StatusArgs { + state_path: Some(state_path.clone()) + }) + .is_ok()); + assert!(handle_verify(taproot::cli::VerifyArgs { + state_path: Some(state_path) + }) + .is_ok()); +}