diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..5f018ff311197f576826bda916e9c7eea2722a3c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +target/ +.git/ +docs/ +huggingface/ +*.md +!README.md diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..40dbacc231b8604aeb49e9289d3f62009515c503 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1816 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +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 0.61.2", +] + +[[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 0.61.2", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +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 = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[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 = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fuser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a5eca878900c2e39e9e52fd797954b7fc39eeefc8558257114bfea6a698fcf" +dependencies = [ + "bitflags", + "libc", + "log", + "memchr", + "nix 0.30.1", + "num_enum", + "page_size", + "parking_lot", + "pkg-config", + "ref-cast", + "smallvec", + "zerocopy", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "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 = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[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.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "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.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "markdownfs" +version = "0.2.0" +dependencies = [ + "axum", + "bincode", + "bytes", + "chrono", + "clap", + "fuser", + "glob", + "memmap2", + "regex", + "rmcp", + "rustyline", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rmcp" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f542f74cf247da16f19bbc87e298cd201e912314f4083e88cdd671f44f5fcb53" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2391e4ae47f314e70eaafb6c7bd82e495e770b935448864446302143019151f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + +[[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 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustyline" +version = "15.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.29.0", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.59.0", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[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 = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "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", +] + +[[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.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +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", +] + +[[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", +] + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..81425b95da80035ec1afe879c2c31a79345949bd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "markdownfs" +version = "0.2.0" +edition = "2024" + +[[bin]] +name = "markdownfs" +path = "src/main.rs" + +[[bin]] +name = "mdfs-server" +path = "src/bin/mdfs_server.rs" + +[[bin]] +name = "markdownfs-server" +path = "src/bin/mdfs_server.rs" + +[[bin]] +name = "mdfs-mcp" +path = "src/bin/mdfs_mcp.rs" + +[[bin]] +name = "markdownfs-mcp" +path = "src/bin/mdfs_mcp.rs" + +[[bin]] +name = "mdfs-mount" +path = "src/bin/mdfs_mount.rs" +required-features = ["fuser"] + +[[bin]] +name = "markdownfs-mount" +path = "src/bin/mdfs_mount.rs" +required-features = ["fuser"] + +[dependencies] +sha2 = "0.10" +memmap2 = "0.9" +bytes = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +bincode = "1" +regex = "1" +glob = "0.3" +rustyline = "15" +thiserror = "2" +chrono = "0.4" +tokio = { version = "1", features = ["full"] } +axum = "0.8" +tower-http = { version = "0.6", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +rmcp = { version = "1.4", features = ["server", "transport-io"] } +clap = { version = "4", features = ["derive", "env"] } +fuser = { version = "0.17.0", optional = true, default-features = false, features = ["abi-7-31", "macfuse-4-compat"] } + +[features] +fuser = ["dep:fuser"] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..60b8ff8838666415266d6efddbf03d9ec8f6ea76 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1.6 +# +# Hugging Face Space image for MarkdownFS. +# Builds the `mdfs-server` REST binary and runs it on port 7860 +# (HF Spaces routes external traffic to this port by convention). + +FROM rust:1.83-slim-bookworm AS builder + +WORKDIR /build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY tests ./tests +COPY examples ./examples + +RUN cargo build --release --bin mdfs-server + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 1000 mdfs + +COPY --from=builder /build/target/release/mdfs-server /usr/local/bin/mdfs-server + +USER mdfs +WORKDIR /home/mdfs + +ENV MARKDOWNFS_DATA_DIR=/home/mdfs/data \ + MARKDOWNFS_LISTEN=0.0.0.0:7860 \ + RUST_LOG=markdownfs=info,tower_http=info + +RUN mkdir -p /home/mdfs/data + +EXPOSE 7860 + +CMD ["mdfs-server"] diff --git a/README.md b/README.md index a3c26b4b676e7f92fe56838cd856fed828517f79..6e4c8c17e424d5c8782271c449cf9dc09652327d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,77 @@ --- -title: Markdownfs -emoji: 📈 -colorFrom: red -colorTo: yellow +title: MarkdownFS +emoji: 📝 +colorFrom: indigo +colorTo: purple sdk: docker +app_port: 7860 pinned: false license: mit short_description: A versioned markdown filesystem with a REST API for agents. --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# MarkdownFS on Hugging Face + +MarkdownFS is a high-performance concurrent markdown database with built-in +versioning, permissions, and a REST API. This Space hosts the `mdfs-server` +binary so any agent or app can use it as durable, inspectable workspace memory. + +## Endpoints + +Base URL: `https://-.hf.space` + +| Method | Path | Purpose | +|--------|-----------------------|----------------------------------| +| GET | `/health` | Health check | +| GET | `/fs/{path}` | Read file or list directory | +| PUT | `/fs/{path}` | Write file or create directory | +| DELETE | `/fs/{path}` | Delete file or directory | +| GET | `/search/grep` | Grep across markdown | +| GET | `/search/find` | Find by name | +| GET | `/tree/{path}` | Directory tree | +| POST | `/vcs/commit` | Commit current state | +| GET | `/vcs/log` | Commit history | +| POST | `/vcs/revert` | Revert to a commit | + +See full docs at the project repo. + +## Quick examples + +```bash +SPACE=https://your-username-markdownfs.hf.space + +# write a file +curl -X PUT "$SPACE/fs/notes/hello.md" \ + -H "Content-Type: text/markdown" \ + --data-binary "# Hello from MarkdownFS" + +# read it back +curl "$SPACE/fs/notes/hello.md" + +# commit +curl -X POST "$SPACE/vcs/commit" \ + -H "Content-Type: application/json" \ + -d '{"message": "first note"}' +``` + +## Storage + +By default the Space uses ephemeral container storage at +`/home/mdfs/data`. **State is lost when the Space restarts.** + +For durable storage, either: +- Enable Hugging Face **Persistent Storage** for this Space and point + `MARKDOWNFS_DATA_DIR` at the mounted volume, or +- Periodically sync the data directory to an HF Dataset repo. + +## Configuration + +Override via Space → Settings → Variables: + +| Variable | Default | +|--------------------------------|--------------------| +| `MARKDOWNFS_DATA_DIR` | `/home/mdfs/data` | +| `MARKDOWNFS_LISTEN` | `0.0.0.0:7860` | +| `MARKDOWNFS_AUTOSAVE_SECS` | `5` | +| `MARKDOWNFS_MAX_FILE_SIZE` | `10485760` | +| `RUST_LOG` | `markdownfs=info` | diff --git a/examples/notebooks/01_getting_started_http.ipynb b/examples/notebooks/01_getting_started_http.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..2d1a04d755423aed206413cca8949f9874ab73df --- /dev/null +++ b/examples/notebooks/01_getting_started_http.ipynb @@ -0,0 +1,278 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Getting Started With mdfs Over HTTP\n", + "\n", + "This notebook starts a local `mdfs-server`, writes markdown files, reads them back, searches the workspace, and commits the result.\n", + "\n", + "Run it from the repository root. The notebook uses a throwaway data directory under `.demo/notebook-http`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Start A Local Server\n", + "\n", + "The HTTP server is the safest integration point for notebooks and agents because it acts as the single writer to the mdfs state file." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pathlib import Path\n", + "import atexit\n", + "import json\n", + "import os\n", + "import shutil\n", + "import subprocess\n", + "import time\n", + "import urllib.error\n", + "import urllib.parse\n", + "import urllib.request\n", + "\n", + "\n", + "def find_repo_root(start=None):\n", + " \"\"\"Find the mdfs repo even when Jupyter starts in examples/notebooks.\"\"\"\n", + " env_root = os.environ.get(\"MDFS_REPO_ROOT\") or os.environ.get(\"MARKDOWNFS_REPO_ROOT\")\n", + " candidates = []\n", + " if env_root:\n", + " candidates.append(Path(env_root).expanduser())\n", + "\n", + " start_path = Path(start or Path.cwd()).resolve()\n", + " candidates.extend([start_path, *start_path.parents])\n", + "\n", + " for candidate in candidates:\n", + " if (candidate / \"Cargo.toml\").exists() and (candidate / \"src\").is_dir():\n", + " return candidate\n", + "\n", + " raise RuntimeError(\n", + " \"Could not find the mdfs repository root. \"\n", + " \"Run the notebook from inside the repo or set MDFS_REPO_ROOT.\"\n", + " )\n", + "\n", + "\n", + "def find_cargo():\n", + " \"\"\"Find Cargo even when the Jupyter kernel has a minimal PATH.\"\"\"\n", + " candidates = []\n", + " env_cargo = os.environ.get(\"CARGO\")\n", + " if env_cargo:\n", + " candidates.append(Path(env_cargo).expanduser())\n", + "\n", + " path_cargo = shutil.which(\"cargo\")\n", + " if path_cargo:\n", + " candidates.append(Path(path_cargo))\n", + "\n", + " candidates.extend([\n", + " Path.home() / \".cargo\" / \"bin\" / \"cargo\",\n", + " Path(\"/opt/homebrew/bin/cargo\"),\n", + " Path(\"/usr/local/bin/cargo\"),\n", + " ])\n", + "\n", + " for candidate in candidates:\n", + " if candidate.exists() and candidate.is_file():\n", + " return str(candidate)\n", + "\n", + " raise RuntimeError(\n", + " \"Could not find Cargo. Install Rust from https://rustup.rs, \"\n", + " \"or set CARGO=/absolute/path/to/cargo before running this notebook.\"\n", + " )\n", + "\n", + "\n", + "ROOT = find_repo_root()\n", + "CARGO = find_cargo()\n", + "DATA_DIR = ROOT / \".demo\" / \"notebook-http\"\n", + "BASE_URL = \"http://127.0.0.1:3000\"\n", + "print(f\"Using mdfs repo at {ROOT}\")\n", + "print(f\"Using cargo at {CARGO}\")\n", + "\n", + "if DATA_DIR.exists():\n", + " shutil.rmtree(DATA_DIR)\n", + "DATA_DIR.mkdir(parents=True)\n", + "\n", + "env = os.environ.copy()\n", + "env.update({\n", + " \"MARKDOWNFS_DATA_DIR\": str(DATA_DIR),\n", + " \"MARKDOWNFS_LISTEN\": \"127.0.0.1:3000\",\n", + "})\n", + "env[\"PATH\"] = os.pathsep.join([str(Path(CARGO).parent), env.get(\"PATH\", \"\")])\n", + "\n", + "server = subprocess.Popen(\n", + " [CARGO, \"run\", \"--quiet\", \"--bin\", \"mdfs-server\"],\n", + " cwd=ROOT,\n", + " env=env,\n", + " stdout=subprocess.PIPE,\n", + " stderr=subprocess.STDOUT,\n", + " text=True,\n", + ")\n", + "\n", + "def stop_server():\n", + " if server.poll() is None:\n", + " server.terminate()\n", + " try:\n", + " server.wait(timeout=5)\n", + " except subprocess.TimeoutExpired:\n", + " server.kill()\n", + "\n", + "atexit.register(stop_server)\n", + "\n", + "def request(method, path, body=None, headers=None):\n", + " url = f\"{BASE_URL}{path}\"\n", + " data = body.encode(\"utf-8\") if isinstance(body, str) else body\n", + " req = urllib.request.Request(url, data=data, method=method, headers=headers or {})\n", + " try:\n", + " with urllib.request.urlopen(req, timeout=5) as response:\n", + " raw = response.read().decode(\"utf-8\")\n", + " content_type = response.headers.get(\"content-type\", \"\")\n", + " if \"application/json\" in content_type:\n", + " return json.loads(raw)\n", + " return raw\n", + " except urllib.error.HTTPError as exc:\n", + " message = exc.read().decode(\"utf-8\")\n", + " raise RuntimeError(f\"{method} {path} failed: {exc.code} {message}\") from exc\n", + "\n", + "for _ in range(60):\n", + " try:\n", + " print(request(\"GET\", \"/health\"))\n", + " break\n", + " except Exception:\n", + " if server.poll() is not None:\n", + " output = server.stdout.read() if server.stdout else \"\"\n", + " raise RuntimeError(f\"mdfs-server exited early:\\n{output}\")\n", + " time.sleep(0.25)\n", + "else:\n", + " raise TimeoutError(\"mdfs-server did not become ready\")" + ], + "execution_count": null, + "outputs": [], + "id": "92ffc9f6" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Write Markdown Files\n", + "\n", + "Write markdown files directly. The HTTP API creates missing parent directories for new files." + ], + "id": "e16a5812" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "readme = \"\"\"# Notebook Demo\n", + "\n", + "This file was written through the mdfs HTTP API.\n", + "\n", + "TODO: delegate the next draft to an agent.\n", + "\"\"\"\n", + "\n", + "plan = \"\"\"# Agent Plan\n", + "\n", + "- Inspect the workspace before writing.\n", + "- Save every meaningful result as markdown.\n", + "- Commit the state after each handoff.\n", + "\"\"\"\n", + "\n", + "print(request(\"PUT\", \"/fs/notebook-demo/readme.md\", readme))\n", + "print(request(\"PUT\", \"/fs/notebook-demo/agent-plan.md\", plan))" + ], + "execution_count": null, + "outputs": [], + "id": "55dd6495" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Read, List, Search" + ], + "id": "36d31719" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "print(request(\"GET\", \"/fs/notebook-demo/readme.md\"))\n", + "print(json.dumps(request(\"GET\", \"/fs/notebook-demo/\"), indent=2))\n", + "\n", + "query = urllib.parse.urlencode({\"pattern\": \"TODO|agent\", \"path\": \"notebook-demo\", \"recursive\": \"true\"})\n", + "print(json.dumps(request(\"GET\", f\"/search/grep?{query}\"), indent=2))" + ], + "execution_count": null, + "outputs": [], + "id": "902143d9" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Commit And Inspect History" + ], + "id": "b9ab61a5" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "commit = request(\n", + " \"POST\",\n", + " \"/vcs/commit\",\n", + " json.dumps({\"message\": \"notebook getting started demo\"}),\n", + " {\"Content-Type\": \"application/json\"},\n", + ")\n", + "print(json.dumps(commit, indent=2))\n", + "print(request(\"GET\", \"/vcs/status\"))\n", + "print(json.dumps(request(\"GET\", \"/vcs/log\"), indent=2))" + ], + "execution_count": null, + "outputs": [], + "id": "1a2f578e" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Clean Up" + ], + "id": "11dbd153" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "stop_server()\n", + "print(\"server stopped\")" + ], + "execution_count": null, + "outputs": [], + "id": "0d14d3ee" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/02_mount_and_start_using_mdfs.ipynb b/examples/notebooks/02_mount_and_start_using_mdfs.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..e9de04f49103ff91857e01cf4048b02392bd8fab --- /dev/null +++ b/examples/notebooks/02_mount_and_start_using_mdfs.ipynb @@ -0,0 +1,319 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Mount And Start Using mdfs\n", + "\n", + "This notebook shows the optional OS mount path using the `mdfs-mount` binary.\n", + "\n", + "The mount binary is behind the Rust `fuser` feature. On macOS you need macFUSE and `pkg-config` available so `fuser` can link against the real mount library. If your machine is not configured for FUSE, use `01_getting_started_http.ipynb` first; the HTTP path is the recommended agent integration path today." + ], + "id": "c516b31b" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Build The Mount Binary\n", + "\n", + "On macOS, install prerequisites with something like `brew install macfuse pkg-config` if this build cannot find FUSE." + ], + "id": "3b607607" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pathlib import Path\n", + "import atexit\n", + "import os\n", + "import platform\n", + "import shutil\n", + "import subprocess\n", + "import time\n", + "\n", + "\n", + "def find_repo_root(start=None):\n", + " \"\"\"Find the mdfs repo even when Jupyter starts in examples/notebooks.\"\"\"\n", + " env_root = os.environ.get(\"MDFS_REPO_ROOT\") or os.environ.get(\"MARKDOWNFS_REPO_ROOT\")\n", + " candidates = []\n", + " if env_root:\n", + " candidates.append(Path(env_root).expanduser())\n", + "\n", + " start_path = Path(start or Path.cwd()).resolve()\n", + " candidates.extend([start_path, *start_path.parents])\n", + "\n", + " for candidate in candidates:\n", + " if (candidate / \"Cargo.toml\").exists() and (candidate / \"src\").is_dir():\n", + " return candidate\n", + "\n", + " raise RuntimeError(\n", + " \"Could not find the mdfs repository root. \"\n", + " \"Run the notebook from inside the repo or set MDFS_REPO_ROOT.\"\n", + " )\n", + "\n", + "\n", + "def find_cargo():\n", + " \"\"\"Find Cargo even when the Jupyter kernel has a minimal PATH.\"\"\"\n", + " candidates = []\n", + " env_cargo = os.environ.get(\"CARGO\")\n", + " if env_cargo:\n", + " candidates.append(Path(env_cargo).expanduser())\n", + "\n", + " path_cargo = shutil.which(\"cargo\")\n", + " if path_cargo:\n", + " candidates.append(Path(path_cargo))\n", + "\n", + " candidates.extend([\n", + " Path.home() / \".cargo\" / \"bin\" / \"cargo\",\n", + " Path(\"/opt/homebrew/bin/cargo\"),\n", + " Path(\"/usr/local/bin/cargo\"),\n", + " ])\n", + "\n", + " for candidate in candidates:\n", + " if candidate.exists() and candidate.is_file():\n", + " return str(candidate)\n", + "\n", + " raise RuntimeError(\n", + " \"Could not find Cargo. Install Rust from https://rustup.rs, \"\n", + " \"or set CARGO=/absolute/path/to/cargo before running this notebook.\"\n", + " )\n", + "\n", + "\n", + "def find_pkg_config():\n", + " \"\"\"Find pkg-config even when the Jupyter kernel has a minimal PATH.\"\"\"\n", + " candidates = []\n", + " env_pkg_config = os.environ.get(\"PKG_CONFIG\")\n", + " if env_pkg_config:\n", + " candidates.append(Path(env_pkg_config).expanduser())\n", + "\n", + " path_pkg_config = shutil.which(\"pkg-config\")\n", + " if path_pkg_config:\n", + " candidates.append(Path(path_pkg_config))\n", + "\n", + " candidates.extend([\n", + " Path(\"/opt/homebrew/bin/pkg-config\"),\n", + " Path(\"/usr/local/bin/pkg-config\"),\n", + " Path(\"/opt/local/bin/pkg-config\"),\n", + " ])\n", + "\n", + " for candidate in candidates:\n", + " if candidate.exists() and candidate.is_file():\n", + " return str(candidate)\n", + "\n", + " raise RuntimeError(\n", + " \"Could not find pkg-config. On macOS with Homebrew, run `brew install pkg-config`, \"\n", + " \"or set PKG_CONFIG=/absolute/path/to/pkg-config before running this notebook.\"\n", + " )\n", + "\n", + "\n", + "def add_common_fuse_pkg_config_paths(env):\n", + " \"\"\"Help pkg-config find macFUSE when Jupyter has a minimal environment.\"\"\"\n", + " candidates = [\n", + " Path(\"/opt/homebrew/lib/pkgconfig\"),\n", + " Path(\"/usr/local/lib/pkgconfig\"),\n", + " Path(\"/usr/local/share/pkgconfig\"),\n", + " Path(\"/opt/local/lib/pkgconfig\"),\n", + " Path(\"/Library/Filesystems/macfuse.fs/Contents/Resources/lib/pkgconfig\"),\n", + " ]\n", + " existing = [str(path) for path in candidates if path.exists()]\n", + " current = env.get(\"PKG_CONFIG_PATH\", \"\")\n", + " if current:\n", + " existing.append(current)\n", + " if existing:\n", + " env[\"PKG_CONFIG_PATH\"] = os.pathsep.join(existing)\n", + "\n", + "\n", + "ROOT = find_repo_root()\n", + "CARGO = find_cargo()\n", + "PKG_CONFIG = find_pkg_config()\n", + "DEMO_ROOT = ROOT / \".demo\" / \"notebook-mount\"\n", + "DATA_DIR = DEMO_ROOT / \"data\"\n", + "MOUNTPOINT = DEMO_ROOT / \"mnt\"\n", + "print(f\"Using mdfs repo at {ROOT}\")\n", + "print(f\"Using cargo at {CARGO}\")\n", + "print(f\"Using pkg-config at {PKG_CONFIG}\")\n", + "\n", + "DEMO_ROOT.mkdir(parents=True, exist_ok=True)\n", + "DATA_DIR.mkdir(parents=True, exist_ok=True)\n", + "MOUNTPOINT.mkdir(parents=True, exist_ok=True)\n", + "\n", + "cargo_env = os.environ.copy()\n", + "cargo_env[\"PKG_CONFIG\"] = PKG_CONFIG\n", + "cargo_env[\"PATH\"] = os.pathsep.join([\n", + " str(Path(CARGO).parent),\n", + " str(Path(PKG_CONFIG).parent),\n", + " cargo_env.get(\"PATH\", \"\"),\n", + "])\n", + "add_common_fuse_pkg_config_paths(cargo_env)\n", + "\n", + "build = subprocess.run(\n", + " [CARGO, \"build\", \"--release\", \"--features\", \"fuser\", \"--bin\", \"mdfs-mount\"],\n", + " cwd=ROOT,\n", + " env=cargo_env,\n", + " text=True,\n", + " capture_output=True,\n", + ")\n", + "print(build.stdout)\n", + "print(build.stderr)\n", + "MOUNT_BUILD_OK = build.returncode == 0\n", + "MOUNT_BINARY = ROOT / \"target\" / \"release\" / \"mdfs-mount\"\n", + "\n", + "if not MOUNT_BUILD_OK:\n", + " print(\n", + " \"Mount build skipped: mdfs-mount could not be built on this machine.\\n\"\n", + " \"On macOS, install macFUSE and pkg-config so fuser can find fuse.pc.\\n\"\n", + " \"For Homebrew, try: brew install pkg-config && brew install --cask macfuse.\\n\"\n", + " \"If macFUSE is installed elsewhere, set PKG_CONFIG_PATH to the directory containing fuse.pc.\\n\"\n", + " \"You can still use 01_getting_started_http.ipynb without FUSE.\"\n", + " )\n", + "else:\n", + " print(f\"Built mdfs-mount at {MOUNT_BINARY}\")" + ], + "execution_count": null, + "outputs": [], + "id": "34720839" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Start The Mount\n", + "\n", + "This starts `target/release/mdfs-mount` in the background. Keep the process alive while you use the mounted directory." + ], + "id": "45596564" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "mount_proc = None\n", + "MOUNT_ACTIVE = False\n", + "\n", + "def unmount():\n", + " if mount_proc is None:\n", + " return\n", + " if platform.system() == \"Darwin\":\n", + " subprocess.run([\"diskutil\", \"unmount\", str(MOUNTPOINT)], capture_output=True, text=True)\n", + " else:\n", + " subprocess.run([\"fusermount\", \"-u\", str(MOUNTPOINT)], capture_output=True, text=True)\n", + " if mount_proc.poll() is None:\n", + " mount_proc.terminate()\n", + " try:\n", + " mount_proc.wait(timeout=5)\n", + " except subprocess.TimeoutExpired:\n", + " mount_proc.kill()\n", + "\n", + "if not MOUNT_BUILD_OK:\n", + " print(\"Skipping mount start because mdfs-mount was not built. See the previous cell for prerequisites.\")\n", + "else:\n", + " env = os.environ.copy()\n", + " env.update({\n", + " \"MARKDOWNFS_DATA_DIR\": str(DATA_DIR),\n", + " \"MARKDOWNFS_MOUNTPOINT\": str(MOUNTPOINT),\n", + " })\n", + " env[\"PATH\"] = os.pathsep.join([\n", + " str(Path(CARGO).parent),\n", + " str(Path(PKG_CONFIG).parent),\n", + " env.get(\"PATH\", \"\"),\n", + " ])\n", + "\n", + " mount_proc = subprocess.Popen(\n", + " [str(MOUNT_BINARY), \"--mountpoint\", str(MOUNTPOINT)],\n", + " cwd=ROOT,\n", + " env=env,\n", + " stdout=subprocess.PIPE,\n", + " stderr=subprocess.STDOUT,\n", + " text=True,\n", + " )\n", + " atexit.register(unmount)\n", + "\n", + " time.sleep(2)\n", + " if mount_proc.poll() is not None:\n", + " output = mount_proc.stdout.read() if mount_proc.stdout else \"\"\n", + " print(f\"Mount process exited early. FUSE may not be installed or permitted.\\n{output}\")\n", + " else:\n", + " MOUNT_ACTIVE = True\n", + " print(f\"Mounted markdownfs at {MOUNTPOINT}\")" + ], + "execution_count": null, + "outputs": [], + "id": "665c78b3" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Use The Mounted Directory\n", + "\n", + "`mdfs` is markdown-only by design, so create `.md` files." + ], + "id": "2e73845a" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if not MOUNT_ACTIVE:\n", + " print(\"Skipping mounted-directory example because no FUSE mount is active.\")\n", + "else:\n", + " note = MOUNTPOINT / \"hello.md\"\n", + " note.write_text(\"# Hello From A Mount\\n\\nThis markdown file was created through the OS mount.\\n\", encoding=\"utf-8\")\n", + "\n", + " print(note.read_text(encoding=\"utf-8\"))\n", + " print(sorted(path.name for path in MOUNTPOINT.iterdir()))" + ], + "execution_count": null, + "outputs": [], + "id": "8cf5a1c0" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Unmount\n", + "\n", + "The mount binary uses an explicit unmount flow, so run this cell when you are done." + ], + "id": "c86130f7" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if MOUNT_ACTIVE:\n", + " unmount()\n", + " MOUNT_ACTIVE = False\n", + " print(\"unmounted\")\n", + "else:\n", + " print(\"No active mount to unmount.\")" + ], + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/03_user_delegation_to_agents.ipynb b/examples/notebooks/03_user_delegation_to_agents.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..271e7261f697170accc8619478def3d97a502bed --- /dev/null +++ b/examples/notebooks/03_user_delegation_to_agents.ipynb @@ -0,0 +1,209 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# User Delegation To Agents\n", + "\n", + "This notebook demonstrates the CLI/session delegation model: an agent can act on behalf of a user, but operations are constrained by the intersection of the agent's permissions and the delegated user's permissions.\n", + "\n", + "Delegation is currently a CLI/session feature. The HTTP API supports bearer-token agents, but it does not yet expose `delegate` and `undelegate` endpoints." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Run A Scripted CLI Session\n", + "\n", + "The helper below drives the interactive `mdfs` binary through stdin so the notebook remains reproducible." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pathlib import Path\n", + "import os\n", + "import re\n", + "import shutil\n", + "import subprocess\n", + "\n", + "\n", + "def find_repo_root(start=None):\n", + " \"\"\"Find the mdfs repo even when Jupyter starts in examples/notebooks.\"\"\"\n", + " env_root = os.environ.get(\"MDFS_REPO_ROOT\") or os.environ.get(\"MARKDOWNFS_REPO_ROOT\")\n", + " candidates = []\n", + " if env_root:\n", + " candidates.append(Path(env_root).expanduser())\n", + "\n", + " start_path = Path(start or Path.cwd()).resolve()\n", + " candidates.extend([start_path, *start_path.parents])\n", + "\n", + " for candidate in candidates:\n", + " if (candidate / \"Cargo.toml\").exists() and (candidate / \"src\").is_dir():\n", + " return candidate\n", + "\n", + " raise RuntimeError(\n", + " \"Could not find the mdfs repository root. \"\n", + " \"Run the notebook from inside the repo or set MDFS_REPO_ROOT.\"\n", + " )\n", + "\n", + "\n", + "def find_cargo():\n", + " \"\"\"Find Cargo even when the Jupyter kernel has a minimal PATH.\"\"\"\n", + " candidates = []\n", + " env_cargo = os.environ.get(\"CARGO\")\n", + " if env_cargo:\n", + " candidates.append(Path(env_cargo).expanduser())\n", + "\n", + " path_cargo = shutil.which(\"cargo\")\n", + " if path_cargo:\n", + " candidates.append(Path(path_cargo))\n", + "\n", + " candidates.extend([\n", + " Path.home() / \".cargo\" / \"bin\" / \"cargo\",\n", + " Path(\"/opt/homebrew/bin/cargo\"),\n", + " Path(\"/usr/local/bin/cargo\"),\n", + " ])\n", + "\n", + " for candidate in candidates:\n", + " if candidate.exists() and candidate.is_file():\n", + " return str(candidate)\n", + "\n", + " raise RuntimeError(\n", + " \"Could not find Cargo. Install Rust from https://rustup.rs, \"\n", + " \"or set CARGO=/absolute/path/to/cargo before running this notebook.\"\n", + " )\n", + "\n", + "\n", + "ROOT = find_repo_root()\n", + "CARGO = find_cargo()\n", + "DATA_DIR = ROOT / \".demo\" / \"notebook-delegation\"\n", + "print(f\"Using mdfs repo at {ROOT}\")\n", + "print(f\"Using cargo at {CARGO}\")\n", + "\n", + "if DATA_DIR.exists():\n", + " shutil.rmtree(DATA_DIR)\n", + "DATA_DIR.mkdir(parents=True)\n", + "\n", + "def run_mdfs_script(lines):\n", + " env = os.environ.copy()\n", + " env[\"MARKDOWNFS_DATA_DIR\"] = str(DATA_DIR)\n", + " env[\"PATH\"] = os.pathsep.join([str(Path(CARGO).parent), env.get(\"PATH\", \"\")])\n", + " script = \"\\n\".join(lines) + \"\\n\"\n", + " return subprocess.run(\n", + " [CARGO, \"run\", \"--quiet\", \"--bin\", \"markdownfs\"],\n", + " cwd=ROOT,\n", + " env=env,\n", + " input=script,\n", + " text=True,\n", + " capture_output=True,\n", + " timeout=60,\n", + " )" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Create Users, An Agent, And Delegated Work\n", + "\n", + "The session creates:\n", + "\n", + "- `alice`, the first admin user\n", + "- `bob`, a human user whose work can be delegated\n", + "- `research-bot`, an agent with a bearer token\n", + "- one private file where delegation should fail\n", + "- one shared file where delegation should succeed" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "result = run_mdfs_script([\n", + " \"alice\",\n", + " \"su root\",\n", + " \"adduser bob\",\n", + " \"addagent research-bot\",\n", + " \"mkdir /delegation\",\n", + " \"chmod 777 /delegation\",\n", + " \"touch /delegation/private-note.md\",\n", + " \"chown bob:bob /delegation/private-note.md\",\n", + " \"chmod 600 /delegation/private-note.md\",\n", + " \"touch /delegation/shared-note.md\",\n", + " \"chown bob:bob /delegation/shared-note.md\",\n", + " \"chmod 666 /delegation/shared-note.md\",\n", + " \"su research-bot\",\n", + " \"delegate bob\",\n", + " \"write /delegation/private-note.md # This should fail because the agent has no write bit\",\n", + " \"write /delegation/shared-note.md # Delegated Research Note\",\n", + " \"stat /delegation/shared-note.md\",\n", + " \"cat /delegation/shared-note.md\",\n", + " \"commit delegated research note\",\n", + " \"log\",\n", + " \"exit\",\n", + "])\n", + "\n", + "print(\"STDOUT:\\n\")\n", + "print(result.stdout)\n", + "print(\"STDERR:\\n\")\n", + "print(result.stderr)\n", + "\n", + "if result.returncode != 0:\n", + " raise RuntimeError(\"scripted mdfs session failed\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Pull Out The Agent Token\n", + "\n", + "The raw token is printed once. In a real workflow, save it immediately and pass it as `Authorization: Bearer ` to the HTTP API." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "token_match = re.search(r\"API token \\(save this .*?\\):\\n\\s+([a-f0-9]+)\", result.stdout)\n", + "agent_token = token_match.group(1) if token_match else None\n", + "print(agent_token)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. What To Notice\n", + "\n", + "- `delegate bob` changes the effective ownership of successful writes to Bob.\n", + "- The write to `private-note.md` fails because Bob can write it, but the agent cannot; delegation uses least privilege.\n", + "- The write to `shared-note.md` succeeds because both Bob and the agent have write permission.\n", + "- The commit gives the handoff a recoverable version boundary." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/README.md b/examples/notebooks/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f95cff883566caa612c5361185c8d713d30314a9 --- /dev/null +++ b/examples/notebooks/README.md @@ -0,0 +1,21 @@ +# Notebook Examples + +These notebooks are runnable walkthroughs for trying `mdfs` from Python/Jupyter. + +| Notebook | Shows | +|---|---| +| `01_getting_started_http.ipynb` | Start the HTTP server, write markdown, read it back, search, commit, and inspect history | +| `02_mount_and_start_using_mdfs.ipynb` | Build and run the optional FUSE mount, then use `mdfs` like a local directory | +| `03_user_delegation_to_agents.ipynb` | Create users and an agent, delegate work to the agent, and observe least-privilege behavior | + +The notebooks auto-detect the repository root when launched from inside the repo, including from `examples/notebooks/`. If Jupyter starts somewhere else, set `MDFS_REPO_ROOT` to the repository path before running the first cell. + +## Prerequisites + +- Rust toolchain installed (`rustc` and `cargo`) +- Python 3 Jupyter environment +- For the mount notebook only: a working FUSE/macFUSE setup for your OS. On macOS, install `pkg-config` and macFUSE so `fuser` can find `fuse.pc`, for example `brew install pkg-config && brew install --cask macfuse`. + +If Jupyter does not inherit your shell `PATH`, the notebooks will also look for Cargo at `~/.cargo/bin/cargo`, `/opt/homebrew/bin/cargo`, and `/usr/local/bin/cargo`, and for `pkg-config` at common Homebrew/MacPorts locations. You can override discovery with `CARGO=/absolute/path/to/cargo` and `PKG_CONFIG=/absolute/path/to/pkg-config`. + +The HTTP and delegation notebooks use only Python's standard library. diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..67bc98c0a117a0ac21de4b16c1dcc561155ed62a --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,28 @@ +pub mod perms; +pub mod registry; +pub mod session; + +use serde::{Deserialize, Serialize}; + +pub type Uid = u32; +pub type Gid = u32; + +pub const ROOT_UID: Uid = 0; +pub const ROOT_GID: Gid = 0; +pub const WHEEL_GID: Gid = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub uid: Uid, + pub name: String, + pub groups: Vec, + pub api_token: Option, + pub is_agent: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Group { + pub gid: Gid, + pub name: String, + pub members: Vec, +} diff --git a/src/auth/perms.rs b/src/auth/perms.rs new file mode 100644 index 0000000000000000000000000000000000000000..c0adad3f3e6e006b48a54dd6740ac2ebe23a6980 --- /dev/null +++ b/src/auth/perms.rs @@ -0,0 +1,107 @@ +use super::{Gid, Uid, ROOT_UID}; +use crate::fs::inode::Inode; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + Read, + Write, + Execute, +} + +/// Check if user `uid` with `user_groups` can perform `access` on `inode`. +/// Root (uid=0) bypasses all checks. +pub fn check_permission(inode: &Inode, uid: Uid, user_groups: &[Gid], access: Access) -> bool { + if uid == ROOT_UID { + return true; + } + + let bit = match access { + Access::Read => 4, + Access::Write => 2, + Access::Execute => 1, + }; + + if uid == inode.uid { + // Owner bits (bits 8-6) + return (inode.mode >> 6) & bit != 0; + } + + if user_groups.contains(&inode.gid) { + // Group bits (bits 5-3) + return (inode.mode >> 3) & bit != 0; + } + + // Other bits (bits 2-0) + inode.mode & bit != 0 +} + +/// Check if sticky bit is set (0o1000). +/// In a sticky directory, only the file owner, dir owner, or root can delete/rename. +pub fn has_sticky_bit(mode: u16) -> bool { + mode & 0o1000 != 0 +} + +/// Check if setgid bit is set (0o2000). +/// New files/dirs in a setgid directory inherit the directory's group. +pub fn has_setgid(mode: u16) -> bool { + mode & 0o2000 != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs::inode::Inode; + + fn make_file(mode: u16, uid: u32, gid: u32) -> Inode { + let mut inode = Inode::new_file(1, uid, gid); + inode.mode = mode; + inode + } + + #[test] + fn test_root_bypasses_all() { + let inode = make_file(0o000, 99, 99); // no permissions + assert!(check_permission(&inode, ROOT_UID, &[0], Access::Read)); + assert!(check_permission(&inode, ROOT_UID, &[0], Access::Write)); + assert!(check_permission(&inode, ROOT_UID, &[0], Access::Execute)); + } + + #[test] + fn test_owner_permissions() { + let inode = make_file(0o600, 1, 10); // rw- --- --- + assert!(check_permission(&inode, 1, &[10], Access::Read)); + assert!(check_permission(&inode, 1, &[10], Access::Write)); + assert!(!check_permission(&inode, 1, &[10], Access::Execute)); + } + + #[test] + fn test_group_permissions() { + let inode = make_file(0o050, 1, 10); // --- r-x --- + assert!(!check_permission(&inode, 2, &[10], Access::Write)); + assert!(check_permission(&inode, 2, &[10], Access::Read)); + assert!(check_permission(&inode, 2, &[10], Access::Execute)); + } + + #[test] + fn test_other_permissions() { + let inode = make_file(0o004, 1, 10); // --- --- r-- + assert!(check_permission(&inode, 99, &[99], Access::Read)); + assert!(!check_permission(&inode, 99, &[99], Access::Write)); + } + + #[test] + fn test_no_permissions() { + let inode = make_file(0o000, 1, 10); + assert!(!check_permission(&inode, 2, &[20], Access::Read)); + assert!(!check_permission(&inode, 2, &[20], Access::Write)); + assert!(!check_permission(&inode, 2, &[20], Access::Execute)); + } + + #[test] + fn test_sticky_and_setgid() { + assert!(has_sticky_bit(0o1755)); + assert!(!has_sticky_bit(0o0755)); + assert!(has_setgid(0o2755)); + assert!(!has_setgid(0o0755)); + } +} diff --git a/src/auth/registry.rs b/src/auth/registry.rs new file mode 100644 index 0000000000000000000000000000000000000000..6c5faac0aeae014fddf7de9eea890479653083fe --- /dev/null +++ b/src/auth/registry.rs @@ -0,0 +1,363 @@ +use super::{Gid, Group, Uid, User, ROOT_GID, ROOT_UID, WHEEL_GID}; +use crate::error::VfsError; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserRegistry { + users: HashMap, + groups: HashMap, + name_to_uid: HashMap, + name_to_gid: HashMap, + next_uid: Uid, + next_gid: Gid, +} + +impl UserRegistry { + pub fn new() -> Self { + let mut reg = UserRegistry { + users: HashMap::new(), + groups: HashMap::new(), + name_to_uid: HashMap::new(), + name_to_gid: HashMap::new(), + next_uid: 1, + next_gid: 2, + }; + + // Bootstrap: root group + reg.groups.insert( + ROOT_GID, + Group { + gid: ROOT_GID, + name: "root".to_string(), + members: vec![ROOT_UID], + }, + ); + reg.name_to_gid.insert("root".to_string(), ROOT_GID); + + // Bootstrap: wheel group (admin) + reg.groups.insert( + WHEEL_GID, + Group { + gid: WHEEL_GID, + name: "wheel".to_string(), + members: vec![ROOT_UID], + }, + ); + reg.name_to_gid.insert("wheel".to_string(), WHEEL_GID); + + // Bootstrap: root user + reg.users.insert( + ROOT_UID, + User { + uid: ROOT_UID, + name: "root".to_string(), + groups: vec![ROOT_GID, WHEEL_GID], + api_token: None, + is_agent: false, + }, + ); + reg.name_to_uid.insert("root".to_string(), ROOT_UID); + + reg + } + + pub fn add_user(&mut self, name: &str, is_agent: bool) -> Result<(Uid, Option), VfsError> { + if self.name_to_uid.contains_key(name) { + return Err(VfsError::AuthError { + message: format!("user already exists: {name}"), + }); + } + + let uid = self.next_uid; + self.next_uid += 1; + + // Create personal group + let gid = self.next_gid; + self.next_gid += 1; + + let group = Group { + gid, + name: name.to_string(), + members: vec![uid], + }; + self.groups.insert(gid, group); + self.name_to_gid.insert(name.to_string(), gid); + + // Generate API token for agents + let (api_token, raw_token) = if is_agent { + let raw = generate_token(); + let hash = hash_token(&raw); + (Some(hash), Some(raw)) + } else { + (None, None) + }; + + let user = User { + uid, + name: name.to_string(), + groups: vec![gid], + api_token, + is_agent, + }; + self.users.insert(uid, user); + self.name_to_uid.insert(name.to_string(), uid); + + Ok((uid, raw_token)) + } + + pub fn del_user(&mut self, name: &str) -> Result<(), VfsError> { + let uid = self.lookup_uid(name).ok_or_else(|| VfsError::AuthError { + message: format!("no such user: {name}"), + })?; + + if uid == ROOT_UID { + return Err(VfsError::AuthError { + message: "cannot delete root".to_string(), + }); + } + + // Remove from all groups + for group in self.groups.values_mut() { + group.members.retain(|&m| m != uid); + } + + self.users.remove(&uid); + self.name_to_uid.remove(name); + Ok(()) + } + + pub fn add_group(&mut self, name: &str) -> Result { + if self.name_to_gid.contains_key(name) { + return Err(VfsError::AuthError { + message: format!("group already exists: {name}"), + }); + } + + let gid = self.next_gid; + self.next_gid += 1; + + let group = Group { + gid, + name: name.to_string(), + members: Vec::new(), + }; + self.groups.insert(gid, group); + self.name_to_gid.insert(name.to_string(), gid); + Ok(gid) + } + + pub fn del_group(&mut self, name: &str) -> Result<(), VfsError> { + let gid = self.lookup_gid(name).ok_or_else(|| VfsError::AuthError { + message: format!("no such group: {name}"), + })?; + + if gid == ROOT_GID || gid == WHEEL_GID { + return Err(VfsError::AuthError { + message: format!("cannot delete system group: {name}"), + }); + } + + // Remove group from all users' group lists + for user in self.users.values_mut() { + user.groups.retain(|&g| g != gid); + } + + self.groups.remove(&gid); + self.name_to_gid.remove(name); + Ok(()) + } + + pub fn usermod_add_group(&mut self, username: &str, groupname: &str) -> Result<(), VfsError> { + let uid = self.lookup_uid(username).ok_or_else(|| VfsError::AuthError { + message: format!("no such user: {username}"), + })?; + let gid = self.lookup_gid(groupname).ok_or_else(|| VfsError::AuthError { + message: format!("no such group: {groupname}"), + })?; + + let user = self.users.get_mut(&uid).unwrap(); + if !user.groups.contains(&gid) { + user.groups.push(gid); + } + + let group = self.groups.get_mut(&gid).unwrap(); + if !group.members.contains(&uid) { + group.members.push(uid); + } + + Ok(()) + } + + pub fn usermod_remove_group(&mut self, username: &str, groupname: &str) -> Result<(), VfsError> { + let uid = self.lookup_uid(username).ok_or_else(|| VfsError::AuthError { + message: format!("no such user: {username}"), + })?; + let gid = self.lookup_gid(groupname).ok_or_else(|| VfsError::AuthError { + message: format!("no such group: {groupname}"), + })?; + + let user = self.users.get_mut(&uid).unwrap(); + user.groups.retain(|&g| g != gid); + + let group = self.groups.get_mut(&gid).unwrap(); + group.members.retain(|&m| m != uid); + + Ok(()) + } + + pub fn lookup_uid(&self, name: &str) -> Option { + self.name_to_uid.get(name).copied() + } + + pub fn lookup_gid(&self, name: &str) -> Option { + self.name_to_gid.get(name).copied() + } + + pub fn get_user(&self, uid: Uid) -> Option<&User> { + self.users.get(&uid) + } + + pub fn get_group(&self, gid: Gid) -> Option<&Group> { + self.groups.get(&gid) + } + + pub fn user_in_group(&self, uid: Uid, gid: Gid) -> bool { + self.users + .get(&uid) + .map(|u| u.groups.contains(&gid)) + .unwrap_or(false) + } + + pub fn authenticate_token(&self, raw_token: &str) -> Option { + let hash = hash_token(raw_token); + self.users + .values() + .find(|u| u.api_token.as_deref() == Some(&hash)) + .map(|u| u.uid) + } + + pub fn list_users(&self) -> Vec<&User> { + let mut users: Vec<&User> = self.users.values().collect(); + users.sort_by_key(|u| u.uid); + users + } + + pub fn list_groups(&self) -> Vec<&Group> { + let mut groups: Vec<&Group> = self.groups.values().collect(); + groups.sort_by_key(|g| g.gid); + groups + } + + pub fn group_name(&self, gid: Gid) -> Option<&str> { + self.groups.get(&gid).map(|g| g.name.as_str()) + } + + pub fn user_name(&self, uid: Uid) -> Option<&str> { + self.users.get(&uid).map(|u| u.name.as_str()) + } + + pub fn is_wheel_member(&self, uid: Uid) -> bool { + self.user_in_group(uid, WHEEL_GID) + } +} + +fn generate_token() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut hasher = Sha256::new(); + hasher.update(seed.to_le_bytes()); + hasher.update(b"markdownfs-agent-token"); + let result = hasher.finalize(); + result.iter().map(|b| format!("{b:02x}")).collect() +} + +fn hash_token(raw: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(raw.as_bytes()); + let result = hasher.finalize(); + result.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bootstrap() { + let reg = UserRegistry::new(); + assert!(reg.get_user(ROOT_UID).is_some()); + assert_eq!(reg.get_user(ROOT_UID).unwrap().name, "root"); + assert!(reg.get_group(ROOT_GID).is_some()); + assert!(reg.get_group(WHEEL_GID).is_some()); + assert!(reg.is_wheel_member(ROOT_UID)); + } + + #[test] + fn test_add_user() { + let mut reg = UserRegistry::new(); + let (uid, token) = reg.add_user("alice", false).unwrap(); + assert_eq!(uid, 1); + assert!(token.is_none()); + assert_eq!(reg.lookup_uid("alice"), Some(1)); + assert!(reg.get_user(1).is_some()); + // Personal group created + assert!(reg.lookup_gid("alice").is_some()); + } + + #[test] + fn test_add_agent() { + let mut reg = UserRegistry::new(); + let (uid, token) = reg.add_user("bot1", true).unwrap(); + assert!(token.is_some()); + // Authenticate with token + let raw = token.unwrap(); + assert_eq!(reg.authenticate_token(&raw), Some(uid)); + assert_eq!(reg.authenticate_token("wrong-token"), None); + } + + #[test] + fn test_del_user() { + let mut reg = UserRegistry::new(); + reg.add_user("bob", false).unwrap(); + assert!(reg.lookup_uid("bob").is_some()); + reg.del_user("bob").unwrap(); + assert!(reg.lookup_uid("bob").is_none()); + } + + #[test] + fn test_cannot_delete_root() { + let mut reg = UserRegistry::new(); + assert!(reg.del_user("root").is_err()); + } + + #[test] + fn test_add_group_and_membership() { + let mut reg = UserRegistry::new(); + reg.add_user("alice", false).unwrap(); + let gid = reg.add_group("devs").unwrap(); + reg.usermod_add_group("alice", "devs").unwrap(); + assert!(reg.user_in_group(1, gid)); + // Remove from group + reg.usermod_remove_group("alice", "devs").unwrap(); + assert!(!reg.user_in_group(1, gid)); + } + + #[test] + fn test_duplicate_user() { + let mut reg = UserRegistry::new(); + reg.add_user("alice", false).unwrap(); + assert!(reg.add_user("alice", false).is_err()); + } + + #[test] + fn test_cannot_delete_system_groups() { + let mut reg = UserRegistry::new(); + assert!(reg.del_group("root").is_err()); + assert!(reg.del_group("wheel").is_err()); + } +} diff --git a/src/auth/session.rs b/src/auth/session.rs new file mode 100644 index 0000000000000000000000000000000000000000..f443a925c66c7221a315de6aea58ed961f8862f5 --- /dev/null +++ b/src/auth/session.rs @@ -0,0 +1,161 @@ +use super::perms::{check_permission, Access}; +use super::{Gid, Uid, ROOT_UID}; +use crate::fs::inode::Inode; + +/// Context for the user an agent is acting on behalf of. +#[derive(Debug, Clone)] +pub struct DelegateContext { + pub uid: Uid, + pub gid: Gid, + pub groups: Vec, + pub username: String, +} + +#[derive(Debug, Clone)] +pub struct Session { + pub uid: Uid, + pub gid: Gid, + pub groups: Vec, + pub username: String, + /// When set, the session acts on behalf of this user. + /// All permission checks require BOTH the principal AND + /// the delegate to have access (intersection / least-privilege). + pub delegate: Option, +} + +impl Session { + pub fn new(uid: Uid, gid: Gid, groups: Vec, username: String) -> Self { + Session { + uid, + gid, + groups, + username, + delegate: None, + } + } + + pub fn root() -> Self { + Session { + uid: ROOT_UID, + gid: 0, + groups: vec![0, 1], + username: "root".to_string(), + delegate: None, + } + } + + pub fn is_root(&self) -> bool { + self.uid == ROOT_UID + } + + /// Check permission with delegation intersection. + /// Returns true only if both the principal AND the delegate (if any) have access. + pub fn has_permission(&self, inode: &Inode, access: Access) -> bool { + if !check_permission(inode, self.uid, &self.groups, access) { + return false; + } + if let Some(ref delegate) = self.delegate { + if !check_permission(inode, delegate.uid, &delegate.groups, access) { + return false; + } + } + true + } + + /// Check permission using raw mode/uid/gid bits (for LsEntry filtering). + /// Respects delegation intersection. + pub fn has_permission_bits( + &self, + mode: u16, + file_uid: Uid, + file_gid: Gid, + access: Access, + ) -> bool { + if !check_bits(self.uid, &self.groups, mode, file_uid, file_gid, access) { + return false; + } + if let Some(ref delegate) = self.delegate { + if !check_bits( + delegate.uid, + &delegate.groups, + mode, + file_uid, + file_gid, + access, + ) { + return false; + } + } + true + } + + /// The effective uid for file ownership — if delegating, use the delegate's uid. + pub fn effective_uid(&self) -> Uid { + match &self.delegate { + Some(d) => d.uid, + None => self.uid, + } + } + + /// The effective gid for file ownership — if delegating, use the delegate's gid. + pub fn effective_gid(&self) -> Gid { + match &self.delegate { + Some(d) => d.gid, + None => self.gid, + } + } + + /// Whether either the principal or (if delegating) the delegate is root. + /// For intersection: both must pass, so root status only helps if the OTHER side passes. + /// This is used for checks like "is this session effectively root?" — answer: only if + /// there's no delegate constraining it. + pub fn is_effectively_root(&self) -> bool { + if self.uid != ROOT_UID { + return false; + } + match &self.delegate { + Some(d) => d.uid == ROOT_UID, + None => true, + } + } + + /// Whether the principal (ignoring delegation) is the owner of a file. + /// When delegating, checks if the delegate is the owner. + pub fn is_effective_owner(&self, file_uid: Uid) -> bool { + match &self.delegate { + Some(d) => { + // Both must be owner or root + let principal_ok = self.uid == ROOT_UID || self.uid == file_uid; + let delegate_ok = d.uid == ROOT_UID || d.uid == file_uid; + principal_ok && delegate_ok + } + None => self.uid == ROOT_UID || self.uid == file_uid, + } + } +} + +/// Raw bit-level permission check for a single principal. +fn check_bits( + uid: Uid, + groups: &[Gid], + mode: u16, + file_uid: Uid, + file_gid: Gid, + access: Access, +) -> bool { + if uid == ROOT_UID { + return true; + } + let bit = match access { + Access::Read => 4, + Access::Write => 2, + Access::Execute => 1, + }; + if uid == file_uid { + return (mode >> 6) & bit != 0; + } + if groups.contains(&file_gid) { + return (mode >> 3) & bit != 0; + } + mode & bit != 0 +} diff --git a/src/bin/mdfs_mcp.rs b/src/bin/mdfs_mcp.rs new file mode 100644 index 0000000000000000000000000000000000000000..fe0ad6994a90f396849684130702962e5ecd5476 --- /dev/null +++ b/src/bin/mdfs_mcp.rs @@ -0,0 +1,286 @@ +use markdownfs::config::Config; +use markdownfs::db::MarkdownDb; + +use rmcp::model::*; +use rmcp::service::RoleServer; +use rmcp::{ErrorData as McpError, ServerHandler, ServiceExt}; + +use std::sync::Arc; + +struct McpServer { + db: MarkdownDb, +} + +fn tool_schema(props: serde_json::Value) -> Arc { + let mut obj = serde_json::Map::new(); + obj.insert("type".into(), "object".into()); + if let Some(p) = props.get("properties") { + obj.insert("properties".into(), p.clone()); + } + if let Some(r) = props.get("required") { + obj.insert("required".into(), r.clone()); + } + Arc::new(obj.into()) +} + +fn make_tool(name: &'static str, desc: &'static str, schema: serde_json::Value) -> Tool { + Tool::new(name, desc, tool_schema(schema)) +} + +impl McpServer { + fn tool_defs() -> Vec { + vec![ + make_tool("read_file", "Read a markdown file by path", serde_json::json!({ + "properties": {"path": {"type": "string"}}, + "required": ["path"] + })), + make_tool("write_file", "Write content to a markdown file (creates if needed)", serde_json::json!({ + "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, + "required": ["path", "content"] + })), + make_tool("list_directory", "List files in a directory", serde_json::json!({ + "properties": {"path": {"type": "string"}} + })), + make_tool("search_files", "Search file contents with a regex pattern", serde_json::json!({ + "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}, "recursive": {"type": "boolean"}}, + "required": ["pattern"] + })), + make_tool("find_files", "Find files by glob pattern", serde_json::json!({ + "properties": {"path": {"type": "string"}, "name": {"type": "string"}} + })), + make_tool("create_directory", "Create a directory (with parents)", serde_json::json!({ + "properties": {"path": {"type": "string"}}, + "required": ["path"] + })), + make_tool("delete_file", "Delete a file or directory", serde_json::json!({ + "properties": {"path": {"type": "string"}, "recursive": {"type": "boolean"}}, + "required": ["path"] + })), + make_tool("move_file", "Move or rename a file/directory", serde_json::json!({ + "properties": {"source": {"type": "string"}, "destination": {"type": "string"}}, + "required": ["source", "destination"] + })), + make_tool("commit", "Commit current filesystem state", serde_json::json!({ + "properties": {"message": {"type": "string"}}, + "required": ["message"] + })), + make_tool("get_history", "Show commit history", serde_json::json!({ + "properties": {} + })), + make_tool("revert", "Revert to a previous commit", serde_json::json!({ + "properties": {"hash": {"type": "string"}}, + "required": ["hash"] + })), + ] + } + + async fn handle_tool(&self, name: &str, args: &serde_json::Value) -> Result { + match name { + "read_file" => { + let path = args["path"].as_str().ok_or("missing path")?; + let content = self.db.cat(path).await.map_err(|e| e.to_string())?; + Ok(String::from_utf8_lossy(&content).into_owned()) + } + "write_file" => { + let path = args["path"].as_str().ok_or("missing path")?; + let content = args["content"].as_str().ok_or("missing content")?; + if self.db.stat(path).await.is_err() { + let trimmed = path.trim_end_matches('/'); + if let Some(idx) = trimmed.rfind('/') { + let parent = &trimmed[..idx]; + if !parent.is_empty() && self.db.stat(parent).await.is_err() { + self.db.mkdir_p(parent, 0, 0).await.map_err(|e| e.to_string())?; + } + } + self.db.touch(path, 0, 0).await.map_err(|e| e.to_string())?; + } + self.db.write_file(path, content.as_bytes().to_vec()).await.map_err(|e| e.to_string())?; + Ok(format!("Written {} bytes to {path}", content.len())) + } + "list_directory" => { + let path = args.get("path").and_then(|v| v.as_str()); + let entries = self.db.ls(path).await.map_err(|e| e.to_string())?; + let mut out = String::new(); + for e in &entries { + let suffix = if e.is_dir { "/" } else { "" }; + out.push_str(&format!("{}{suffix}\n", e.name)); + } + Ok(out) + } + "search_files" => { + let pattern = args["pattern"].as_str().ok_or("missing pattern")?; + let path = args.get("path").and_then(|v| v.as_str()); + let recursive = args.get("recursive").and_then(|v| v.as_bool()).unwrap_or(true); + let results = self.db.grep(pattern, path, recursive, None).await.map_err(|e| e.to_string())?; + let mut out = String::new(); + for r in &results { out.push_str(&format!("{}:{}: {}\n", r.file, r.line_num, r.line)); } + if out.is_empty() { out = "No matches found.".to_string(); } + Ok(out) + } + "find_files" => { + let path = args.get("path").and_then(|v| v.as_str()); + let name = args.get("name").and_then(|v| v.as_str()); + let results = self.db.find(path, name, None).await.map_err(|e| e.to_string())?; + Ok(results.join("\n")) + } + "create_directory" => { + let path = args["path"].as_str().ok_or("missing path")?; + self.db.mkdir_p(path, 0, 0).await.map_err(|e| e.to_string())?; + Ok(format!("Created directory: {path}")) + } + "delete_file" => { + let path = args["path"].as_str().ok_or("missing path")?; + let recursive = args.get("recursive").and_then(|v| v.as_bool()).unwrap_or(false); + if recursive { self.db.rm_rf(path).await } else { self.db.rm(path).await } + .map_err(|e| e.to_string())?; + Ok(format!("Deleted: {path}")) + } + "move_file" => { + let src = args["source"].as_str().ok_or("missing source")?; + let dst = args["destination"].as_str().ok_or("missing destination")?; + self.db.mv(src, dst).await.map_err(|e| e.to_string())?; + Ok(format!("Moved {src} -> {dst}")) + } + "commit" => { + let message = args["message"].as_str().ok_or("missing message")?; + let hash = self.db.commit(message, "mcp-agent").await.map_err(|e| e.to_string())?; + Ok(format!("[{hash}] {message}")) + } + "get_history" => { + let commits = self.db.vcs_log().await; + if commits.is_empty() { return Ok("No commits yet.".to_string()); } + let mut out = String::new(); + for c in &commits { out.push_str(&format!("{} {} {}\n", c.id.short_hex(), c.author, c.message)); } + Ok(out) + } + "revert" => { + let hash = args["hash"].as_str().ok_or("missing hash")?; + self.db.revert(hash).await.map_err(|e| e.to_string())?; + Ok(format!("Reverted to {hash}")) + } + _ => Err(format!("unknown tool: {name}")), + } + } +} + +impl ServerHandler for McpServer { + fn get_info(&self) -> ServerInfo { + let mut caps = ServerCapabilities::default(); + caps.tools = Some(ToolsCapability { list_changed: None }); + caps.resources = Some(ResourcesCapability { + subscribe: None, + list_changed: None, + }); + InitializeResult::new(caps) + .with_instructions( + "mdfs is a markdown-only virtual filesystem with Git-like versioning. \ + Use tools to read, write, search, and manage markdown files. \ + All files must have .md extension.", + ) + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + async { + let mut result = ListToolsResult::default(); + result.tools = Self::tool_defs(); + Ok(result) + } + } + + fn call_tool( + &self, + request: CallToolRequestParams, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + async move { + let args = serde_json::to_value(&request.arguments).unwrap_or_default(); + match self.handle_tool(&request.name, &args).await { + Ok(text) => { + let mut result = CallToolResult::default(); + result.content = vec![Content::text(text)]; + result.is_error = Some(false); + Ok(result) + } + Err(e) => { + let mut result = CallToolResult::default(); + result.content = vec![Content::text(e)]; + result.is_error = Some(true); + Ok(result) + } + } + } + } + + fn list_resources( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + async { + let resource = RawResource::new("mdfs://tree", "Directory Tree") + .with_description("Full directory tree of the filesystem") + .with_mime_type("text/plain"); + let legacy_resource = RawResource::new("markdownfs://tree", "Directory Tree (legacy)") + .with_description("Legacy alias for mdfs://tree") + .with_mime_type("text/plain"); + let mut result = ListResourcesResult::default(); + result.resources = vec![ + Annotated::new(resource, None), + Annotated::new(legacy_resource, None), + ]; + Ok(result) + } + } + + fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + async move { + let uri = request.uri.as_str(); + if uri == "mdfs://tree" || uri == "markdownfs://tree" { + let tree = self.db.tree(None, None).await + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + Ok(ReadResourceResult::new(vec![ResourceContents::text(tree, uri)])) + } else if let Some(path) = uri + .strip_prefix("mdfs://files/") + .or_else(|| uri.strip_prefix("markdownfs://files/")) + { + let content = self.db.cat(path).await + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + Ok(ReadResourceResult::new(vec![ResourceContents::text( + String::from_utf8_lossy(&content).into_owned(), uri, + )])) + } else { + Err(McpError::invalid_params(format!("unknown resource: {uri}"), None)) + } + } + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "markdownfs=info".parse().unwrap()), + ) + .with_writer(std::io::stderr) + .init(); + + let config = Config::from_env(); + tracing::info!(data_dir = %config.data_dir.display(), "starting mdfs MCP server"); + + let db = MarkdownDb::open(config).expect("failed to open database"); + let _save_handle = db.spawn_auto_save(); + + let server = McpServer { db }; + let transport = rmcp::transport::io::stdio(); + let service = server.serve(transport).await.expect("failed to start MCP server"); + service.waiting().await.expect("MCP server error"); +} diff --git a/src/bin/mdfs_mount.rs b/src/bin/mdfs_mount.rs new file mode 100644 index 0000000000000000000000000000000000000000..24c81defcebfbd31fe34d60e06c133d8ee226697 --- /dev/null +++ b/src/bin/mdfs_mount.rs @@ -0,0 +1,35 @@ +#![cfg(feature = "fuser")] + +use clap::Parser; +use markdownfs::config::{CompatibilityTarget, Config}; +use markdownfs::db::MarkdownDb; +use markdownfs::fuse_mount; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +#[derive(Parser)] +#[command(name = "mdfs-mount", version, about = "Mount mdfs through FUSE")] +struct Cli { + #[arg(long, env = "MARKDOWNFS_MOUNTPOINT")] + mountpoint: PathBuf, + + #[arg(long)] + read_only: bool, +} + +fn main() { + let cli = Cli::parse(); + let config = Config::from_env().with_compatibility_target(CompatibilityTarget::Posix); + let db = match MarkdownDb::open(config) { + Ok(db) => db, + Err(err) => { + eprintln!("failed to open database: {err}"); + std::process::exit(1); + } + }; + let fs = Arc::new(Mutex::new(db.snapshot_fs())); + if let Err(err) = fuse_mount::mount(fs, cli.mountpoint, cli.read_only) { + eprintln!("failed to mount filesystem: {err}"); + std::process::exit(1); + } +} diff --git a/src/bin/mdfs_server.rs b/src/bin/mdfs_server.rs new file mode 100644 index 0000000000000000000000000000000000000000..5ab596b44743ddaf91430b1e7ce18e39d25bc6b5 --- /dev/null +++ b/src/bin/mdfs_server.rs @@ -0,0 +1,65 @@ +use markdownfs::config::Config; +use markdownfs::db::MarkdownDb; +use markdownfs::server; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "markdownfs=info,tower_http=info".parse().unwrap()), + ) + .init(); + + let config = Config::from_env(); + let listen_addr = config.listen_addr.clone(); + + tracing::info!(data_dir = %config.data_dir.display(), "starting mdfs server"); + + let db = MarkdownDb::open(config).expect("failed to open database"); + + let save_handle = db.spawn_auto_save(); + + let app = server::build_router(db.clone()); + + let listener = tokio::net::TcpListener::bind(&listen_addr) + .await + .unwrap_or_else(|e| panic!("failed to bind {listen_addr}: {e}")); + + tracing::info!("listening on {listen_addr}"); + tracing::info!("endpoints:"); + tracing::info!(" GET /health — health check"); + tracing::info!(" POST /auth/login — login (JSON: {{\"username\": \"...\" }})"); + tracing::info!(" GET /workspaces — list hosted workspaces"); + tracing::info!(" POST /workspaces — create hosted workspace"); + tracing::info!(" POST /workspaces/{{id}}/tokens — issue workspace-scoped token"); + tracing::info!(" GET /fs/{{path}} — read file or list directory"); + tracing::info!(" PUT /fs/{{path}} — write file or create directory"); + tracing::info!(" DELETE /fs/{{path}} — delete file or directory"); + tracing::info!(" POST /fs/{{path}} — copy/move (op=copy|move&dst=...)"); + tracing::info!(" GET /search/grep — grep (pattern=...&path=...&recursive=true)"); + tracing::info!(" GET /search/find — find (path=...&name=...)"); + tracing::info!(" GET /tree/{{path}} — directory tree"); + tracing::info!(" POST /vcs/commit — commit (JSON: {{\"message\": \"...\" }})"); + tracing::info!(" GET /vcs/log — commit history"); + tracing::info!(" POST /vcs/revert — revert (JSON: {{\"hash\": \"...\" }})"); + tracing::info!(" GET /vcs/status — VCS status"); + + let shutdown = async { + tokio::signal::ctrl_c().await.ok(); + tracing::info!("shutting down..."); + }; + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await + .expect("server error"); + + save_handle.abort(); + + if let Err(e) = db.save().await { + tracing::error!("failed to save on shutdown: {e}"); + } else { + tracing::info!("state saved"); + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..51eba45aab04a230f3cd23e561f73802ca306744 --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,1152 @@ +pub mod parser; + +use crate::auth::perms::{has_sticky_bit, Access}; +use crate::auth::session::Session; +use crate::auth::{ROOT_GID, ROOT_UID, WHEEL_GID}; +use crate::error::VfsError; +use crate::fs::VirtualFs; +use parser::{ParsedCommand, Pipeline}; + +pub fn execute_pipeline( + pipeline: &Pipeline, + fs: &mut VirtualFs, + session: &mut Session, +) -> Result { + let mut stdin = String::new(); + for (i, cmd) in pipeline.commands.iter().enumerate() { + let is_last = i == pipeline.commands.len() - 1; + let has_stdin = i > 0; + stdin = execute_command(cmd, fs, session, if has_stdin { Some(&stdin) } else { None })?; + if !is_last && stdin.is_empty() { + break; + } + } + Ok(stdin) +} + +fn execute_command( + cmd: &ParsedCommand, + fs: &mut VirtualFs, + session: &mut Session, + stdin: Option<&str>, +) -> Result { + match cmd.program.as_str() { + "ls" => cmd_ls(fs, &cmd.args, session), + "cd" => cmd_cd(fs, &cmd.args, session), + "pwd" => Ok(format!("{}\n", fs.pwd())), + "mkdir" => cmd_mkdir(fs, &cmd.args, session), + "touch" => cmd_touch(fs, &cmd.args, session), + "cat" => cmd_cat(fs, &cmd.args, stdin, session), + "rm" => cmd_rm(fs, &cmd.args, session), + "rmdir" => cmd_rmdir(fs, &cmd.args, session), + "mv" => cmd_mv(fs, &cmd.args, session), + "cp" => cmd_cp(fs, &cmd.args, session), + "stat" => cmd_stat(fs, &cmd.args, session), + "tree" => cmd_tree(fs, &cmd.args, session), + "find" => cmd_find(fs, &cmd.args, session), + "grep" => cmd_grep(fs, &cmd.args, stdin, session), + "head" => cmd_head(&cmd.args, stdin), + "tail" => cmd_tail(&cmd.args, stdin), + "wc" => cmd_wc(&cmd.args, stdin), + "chmod" => cmd_chmod(fs, &cmd.args, session), + "chown" => cmd_chown(fs, &cmd.args, session), + "ln" => cmd_ln(fs, &cmd.args, session), + "echo" => cmd_echo(&cmd.args), + "write" => cmd_write(fs, &cmd.args, stdin, session), + // User management + "adduser" => cmd_adduser(fs, &cmd.args, session), + "addagent" => cmd_addagent(fs, &cmd.args, session), + "deluser" => cmd_deluser(fs, &cmd.args, session), + "addgroup" => cmd_addgroup(fs, &cmd.args, session), + "delgroup" => cmd_delgroup(fs, &cmd.args, session), + "usermod" => cmd_usermod(fs, &cmd.args, session), + "groups" => cmd_groups(fs, &cmd.args, session), + "whoami" => Ok(format!("{}\n", session.username)), + "id" => cmd_id(fs, &cmd.args, session), + "su" => cmd_su(fs, &cmd.args, session), + "delegate" => cmd_delegate(fs, &cmd.args, session), + "undelegate" => cmd_undelegate(session), + "help" => cmd_help(), + "clear" => Ok("\x1b[2J\x1b[H".to_string()), + name => Err(VfsError::UnknownCommand { + name: name.to_string(), + }), + } +} + +// ───── Permission helpers ───── + +/// Check that the session has the given access on an inode. +/// Respects delegation: both principal and delegate must have access. +fn require_access( + fs: &VirtualFs, + inode_id: u64, + session: &Session, + access: Access, + path: &str, +) -> Result<(), VfsError> { + let inode = fs.get_inode(inode_id)?; + if !session.has_permission(inode, access) { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + Ok(()) +} + +/// Require admin (root or wheel) — respects delegation intersection. +/// Both the principal and delegate must be admin. +fn require_admin(session: &Session) -> Result<(), VfsError> { + let principal_admin = + session.uid == ROOT_UID || session.groups.contains(&WHEEL_GID); + if !principal_admin { + return Err(VfsError::PermissionDenied { + path: "admin operation".to_string(), + }); + } + if let Some(ref delegate) = session.delegate { + let delegate_admin = + delegate.uid == ROOT_UID || delegate.groups.contains(&WHEEL_GID); + if !delegate_admin { + return Err(VfsError::PermissionDenied { + path: "admin operation (delegate lacks admin)".to_string(), + }); + } + } + Ok(()) +} + +// ───── File commands ───── + +fn cmd_ls(fs: &VirtualFs, args: &[String], session: &Session) -> Result { + let mut long_format = false; + let mut all = false; + let mut path = None; + + for arg in args { + match arg.as_str() { + "-l" => long_format = true, + "-a" => all = true, + "-la" | "-al" => { + long_format = true; + all = true; + } + p => path = Some(p), + } + } + + let _ = all; + + // Permission: Read + Execute on the directory + let dir_path = path.unwrap_or("."); + let dir_id = fs.resolve_path_checked(dir_path, session)?; + require_access(fs, dir_id, session, Access::Read, dir_path)?; + require_access(fs, dir_id, session, Access::Execute, dir_path)?; + + let all_entries = fs.ls(path)?; + // Filter: only show entries the user can read — respects delegation intersection + let entries: Vec<_> = all_entries + .into_iter() + .filter(|e| session.has_permission_bits(e.mode, e.uid, e.gid, Access::Read)) + .collect(); + + let mut output = String::new(); + + if long_format { + for e in &entries { + let kind = if e.is_dir { + 'd' + } else if e.is_symlink { + 'l' + } else { + '-' + }; + let perms = format_permissions(e.mode); + let time = format_time(e.modified); + let owner = fs.registry.user_name(e.uid).unwrap_or("?"); + let group = fs.registry.group_name(e.gid).unwrap_or("?"); + output.push_str(&format!( + "{kind}{perms} {owner:<8} {group:<8} {:>8} {time} {}{}\n", + e.size, + e.name, + if e.is_dir { "/" } else { "" } + )); + } + } else { + for e in &entries { + output.push_str(&e.name); + if e.is_dir { + output.push('/'); + } + output.push('\n'); + } + } + + Ok(output) +} + +fn cmd_cd(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + let path = args.first().map(|s| s.as_str()).unwrap_or("/"); + // Permission: Execute on target directory + let target = fs.resolve_path_checked(path, session)?; + require_access(fs, target, session, Access::Execute, path)?; + fs.cd(path)?; + Ok(String::new()) +} + +fn cmd_mkdir(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + let mut parents = false; + let mut paths = Vec::new(); + + for arg in args { + if arg == "-p" { + parents = true; + } else { + paths.push(arg.as_str()); + } + } + + if paths.is_empty() { + return Err(VfsError::InvalidArgs { + message: "mkdir: missing operand".to_string(), + }); + } + + for path in paths { + if parents { + // mkdir -p: intermediate dirs may not exist yet, so we can't resolve parent. + // Permission check happens inside mkdir_p for each existing parent. + fs.mkdir_p(path, session.effective_uid(), session.effective_gid())?; + } else { + // Permission: Write + Execute on parent + let (parent_id, _) = fs.resolve_parent_checked(path, session)?; + require_access(fs, parent_id, session, Access::Write, path)?; + require_access(fs, parent_id, session, Access::Execute, path)?; + fs.mkdir(path, session.effective_uid(), session.effective_gid())?; + } + } + Ok(String::new()) +} + +fn cmd_touch(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "touch: missing operand".to_string(), + }); + } + for arg in args { + if fs.resolve_path(arg).is_ok() { + // File exists — update timestamp, need Write on file + let id = fs.resolve_path_checked(arg, session)?; + require_access(fs, id, session, Access::Write, arg)?; + fs.touch(arg, session.effective_uid(), session.effective_gid())?; + } else { + // New file — need Write + Execute on parent + let (parent_id, _) = fs.resolve_parent_checked(arg, session)?; + require_access(fs, parent_id, session, Access::Write, arg)?; + require_access(fs, parent_id, session, Access::Execute, arg)?; + fs.touch(arg, session.effective_uid(), session.effective_gid())?; + } + } + Ok(String::new()) +} + +fn cmd_cat( + fs: &VirtualFs, + args: &[String], + stdin: Option<&str>, + session: &Session, +) -> Result { + if let Some(input) = stdin { + if args.is_empty() { + return Ok(input.to_string()); + } + } + + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "cat: missing operand".to_string(), + }); + } + + let mut output = String::new(); + for path in args { + // Permission: Read on file + let id = fs.resolve_path_checked(path, session)?; + require_access(fs, id, session, Access::Read, path)?; + let content = fs.cat(path)?; + output.push_str(&String::from_utf8_lossy(content)); + } + Ok(output) +} + +fn cmd_rm(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + let mut recursive = false; + let mut paths = Vec::new(); + + for arg in args { + match arg.as_str() { + "-r" | "-rf" | "-fr" => recursive = true, + p => paths.push(p), + } + } + + if paths.is_empty() { + return Err(VfsError::InvalidArgs { + message: "rm: missing operand".to_string(), + }); + } + + for path in paths { + // Permission: Write + Execute on parent + let (parent_id, _) = fs.resolve_parent_checked(path, session)?; + require_access(fs, parent_id, session, Access::Write, path)?; + require_access(fs, parent_id, session, Access::Execute, path)?; + + // Sticky bit check: only owner of file, owner of dir, or root can delete + // With delegation: use effective identity + let parent = fs.get_inode(parent_id)?; + if has_sticky_bit(parent.mode) && !session.is_effectively_root() { + let file_id = fs.resolve_path(path)?; + let file_inode = fs.get_inode(file_id)?; + let eff_uid = session.effective_uid(); + if file_inode.uid != eff_uid && parent.uid != eff_uid { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + } + + if recursive { + fs.rm_rf(path)?; + } else { + fs.rm(path)?; + } + } + Ok(String::new()) +} + +fn cmd_rmdir(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "rmdir: missing operand".to_string(), + }); + } + for path in args { + let (parent_id, _) = fs.resolve_parent_checked(path, session)?; + require_access(fs, parent_id, session, Access::Write, path)?; + require_access(fs, parent_id, session, Access::Execute, path)?; + fs.rmdir(path)?; + } + Ok(String::new()) +} + +fn cmd_mv(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + if args.len() < 2 { + return Err(VfsError::InvalidArgs { + message: "mv: need source and destination".to_string(), + }); + } + + // Permission: W+X on source parent, W+X on dest parent + let (src_parent, _) = fs.resolve_parent_checked(&args[0], session)?; + require_access(fs, src_parent, session, Access::Write, &args[0])?; + require_access(fs, src_parent, session, Access::Execute, &args[0])?; + + // Sticky bit on source parent — use effective identity for delegation + let src_parent_inode = fs.get_inode(src_parent)?; + if has_sticky_bit(src_parent_inode.mode) && !session.is_effectively_root() { + let src_id = fs.resolve_path(&args[0])?; + let src_inode = fs.get_inode(src_id)?; + let eff_uid = session.effective_uid(); + if src_inode.uid != eff_uid && src_parent_inode.uid != eff_uid { + return Err(VfsError::PermissionDenied { + path: args[0].to_string(), + }); + } + } + + // Check destination parent + if let Ok((dst_parent, _)) = fs.resolve_parent_checked(&args[1], session) { + require_access(fs, dst_parent, session, Access::Write, &args[1])?; + require_access(fs, dst_parent, session, Access::Execute, &args[1])?; + } + + fs.mv(&args[0], &args[1])?; + Ok(String::new()) +} + +fn cmd_cp(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + if args.len() < 2 { + return Err(VfsError::InvalidArgs { + message: "cp: need source and destination".to_string(), + }); + } + + // Permission: Read on source + let src_id = fs.resolve_path_checked(&args[0], session)?; + require_access(fs, src_id, session, Access::Read, &args[0])?; + + // Permission: Write + Execute on destination parent + if let Ok((dst_parent, _)) = fs.resolve_parent_checked(&args[1], session) { + require_access(fs, dst_parent, session, Access::Write, &args[1])?; + require_access(fs, dst_parent, session, Access::Execute, &args[1])?; + } + + fs.cp(&args[0], &args[1], session.effective_uid(), session.effective_gid())?; + Ok(String::new()) +} + +fn cmd_stat(fs: &VirtualFs, args: &[String], session: &Session) -> Result { + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "stat: missing operand".to_string(), + }); + } + let _id = fs.resolve_path_checked(&args[0], session)?; + let info = fs.stat(&args[0])?; + let owner = fs.registry.user_name(info.uid).unwrap_or("?"); + let group = fs.registry.group_name(info.gid).unwrap_or("?"); + Ok(format!( + " File: {}\n Size: {}\n Blocks: {}\n IO Block: {}\n Type: {}\n Inode: {}\n Links: {}\n Mode: {:04o}\n Uid: {} ({})\n Gid: {} ({})\nCreated: {}.{}\nAccessed: {}.{}\nModified: {}.{}\n Changed: {}.{}\n", + args[0], info.size, info.blocks, info.block_size, info.kind, info.inode_id, info.nlink, info.mode, + info.uid, owner, info.gid, group, + format_time(info.created), info.created_nanos, + format_time(info.accessed), info.accessed_nanos, + format_time(info.modified), info.modified_nanos, + format_time(info.changed), info.changed_nanos, + )) +} + +fn cmd_tree(fs: &VirtualFs, args: &[String], session: &Session) -> Result { + let path = args.first().map(|s| s.as_str()); + if let Some(p) = path { + let id = fs.resolve_path_checked(p, session)?; + require_access(fs, id, session, Access::Read, p)?; + require_access(fs, id, session, Access::Execute, p)?; + } + fs.tree(path, "", Some(session)) +} + +fn cmd_find(fs: &VirtualFs, args: &[String], session: &Session) -> Result { + let mut path = None; + let mut pattern = None; + let mut i = 0; + + while i < args.len() { + match args[i].as_str() { + "-name" => { + i += 1; + if i < args.len() { + pattern = Some(args[i].as_str()); + } + } + p => { + if path.is_none() { + path = Some(p); + } + } + } + i += 1; + } + + if let Some(p) = path { + let id = fs.resolve_path_checked(p, session)?; + require_access(fs, id, session, Access::Read, p)?; + require_access(fs, id, session, Access::Execute, p)?; + } + + let results = fs.find(path, pattern, Some(session))?; + Ok(results.join("\n") + if results.is_empty() { "" } else { "\n" }) +} + +fn cmd_grep( + fs: &VirtualFs, + args: &[String], + stdin: Option<&str>, + session: &Session, +) -> Result { + if let Some(input) = stdin { + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "grep: missing pattern".to_string(), + }); + } + let re = regex::Regex::new(&args[0]).map_err(|e| VfsError::InvalidArgs { + message: format!("invalid regex: {e}"), + })?; + let mut output = String::new(); + for line in input.lines() { + if re.is_match(line) { + output.push_str(line); + output.push('\n'); + } + } + return Ok(output); + } + + let mut recursive = false; + let mut pattern = None; + let mut path = None; + + for arg in args { + match arg.as_str() { + "-r" | "-R" => recursive = true, + _ => { + if pattern.is_none() { + pattern = Some(arg.as_str()); + } else if path.is_none() { + path = Some(arg.as_str()); + } + } + } + } + + let pattern = pattern.ok_or_else(|| VfsError::InvalidArgs { + message: "grep: missing pattern".to_string(), + })?; + + // Permission: Read on file/dir + if let Some(p) = path { + let id = fs.resolve_path_checked(p, session)?; + require_access(fs, id, session, Access::Read, p)?; + } + + let results = fs.grep(pattern, path, recursive, Some(session))?; + let mut output = String::new(); + for r in &results { + output.push_str(&format!("{}:{}:{}\n", r.file, r.line_num, r.line)); + } + Ok(output) +} + +fn cmd_head(args: &[String], stdin: Option<&str>) -> Result { + let input = stdin.ok_or_else(|| VfsError::InvalidArgs { + message: "head: no input".to_string(), + })?; + + let mut n = 10usize; + for i in 0..args.len() { + if args[i] == "-n" || args[i].starts_with('-') { + if args[i] == "-n" && i + 1 < args.len() { + n = args[i + 1].parse().unwrap_or(10); + } else if args[i].starts_with('-') { + n = args[i][1..].parse().unwrap_or(10); + } + } + } + + let lines: Vec<&str> = input.lines().take(n).collect(); + Ok(lines.join("\n") + "\n") +} + +fn cmd_tail(args: &[String], stdin: Option<&str>) -> Result { + let input = stdin.ok_or_else(|| VfsError::InvalidArgs { + message: "tail: no input".to_string(), + })?; + + let mut n = 10usize; + for i in 0..args.len() { + if args[i] == "-n" || args[i].starts_with('-') { + if args[i] == "-n" && i + 1 < args.len() { + n = args[i + 1].parse().unwrap_or(10); + } else if args[i].starts_with('-') { + n = args[i][1..].parse().unwrap_or(10); + } + } + } + + let all_lines: Vec<&str> = input.lines().collect(); + let start = all_lines.len().saturating_sub(n); + let lines = &all_lines[start..]; + Ok(lines.join("\n") + "\n") +} + +fn cmd_wc(args: &[String], stdin: Option<&str>) -> Result { + let input = stdin.ok_or_else(|| VfsError::InvalidArgs { + message: "wc: no input".to_string(), + })?; + + let lines = input.lines().count(); + let words = input.split_whitespace().count(); + let bytes = input.len(); + + let count_lines = args.contains(&"-l".to_string()); + let count_words = args.contains(&"-w".to_string()); + let count_bytes = args.contains(&"-c".to_string()); + + if count_lines { + Ok(format!("{lines}\n")) + } else if count_words { + Ok(format!("{words}\n")) + } else if count_bytes { + Ok(format!("{bytes}\n")) + } else { + Ok(format!("{lines} {words} {bytes}\n")) + } +} + +fn cmd_chmod(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + if args.len() < 2 { + return Err(VfsError::InvalidArgs { + message: "chmod: need mode and file".to_string(), + }); + } + let mode = u16::from_str_radix(&args[0], 8).map_err(|_| VfsError::InvalidArgs { + message: format!("chmod: invalid mode: {}", args[0]), + })?; + + // Permission: must be owner or root (delegation: both must qualify) + let id = fs.resolve_path_checked(&args[1], session)?; + let inode = fs.get_inode(id)?; + if !session.is_effective_owner(inode.uid) { + return Err(VfsError::PermissionDenied { + path: args[1].to_string(), + }); + } + + fs.chmod(&args[1], mode)?; + Ok(String::new()) +} + +fn cmd_chown(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + if args.len() < 2 { + return Err(VfsError::InvalidArgs { + message: "chown: need owner[:group] and file".to_string(), + }); + } + + let spec = &args[0]; + let path = &args[1]; + + let (user_str, group_str) = if let Some(pos) = spec.find(':') { + (Some(&spec[..pos]), Some(&spec[pos + 1..])) + } else { + (Some(spec.as_str()), None) + }; + + let id = fs.resolve_path_checked(path, session)?; + let inode = fs.get_inode(id)?; + + // Changing uid: root only (delegation: must be effectively root) + // Changing gid: root or owner (if member of target group) + if let Some(user) = user_str { + if !user.is_empty() { + if !session.is_effectively_root() { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + let new_uid = fs.registry.lookup_uid(user).ok_or_else(|| VfsError::AuthError { + message: format!("no such user: {user}"), + })?; + let current_gid = inode.gid; + fs.chown(path, new_uid, current_gid)?; + } + } + + if let Some(group) = group_str { + if !group.is_empty() { + let new_gid = fs.registry.lookup_gid(group).ok_or_else(|| VfsError::AuthError { + message: format!("no such group: {group}"), + })?; + // Owner can change group if they're a member of the target group + // Delegation: effective identity must be owner and in target group + let inode = fs.get_inode(fs.resolve_path(path)?)?; + if !session.is_effectively_root() { + if !session.is_effective_owner(inode.uid) { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + // Both principal and delegate must be in target group + if !session.groups.contains(&new_gid) { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + if let Some(ref delegate) = session.delegate { + if !delegate.groups.contains(&new_gid) { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + } + } + fs.chown(path, inode.uid, new_gid)?; + } + } + + Ok(String::new()) +} + +fn cmd_ln(fs: &mut VirtualFs, args: &[String], session: &Session) -> Result { + let mut symlink = false; + let mut targets = Vec::new(); + + for arg in args { + if arg == "-s" { + symlink = true; + } else { + targets.push(arg.as_str()); + } + } + + if targets.len() < 2 { + return Err(VfsError::InvalidArgs { + message: "ln: need target and link name".to_string(), + }); + } + + // Permission: Write + Execute on link's parent + let (parent_id, _) = fs.resolve_parent_checked(targets[1], session)?; + require_access(fs, parent_id, session, Access::Write, targets[1])?; + require_access(fs, parent_id, session, Access::Execute, targets[1])?; + + if symlink { + fs.ln_s(targets[0], targets[1], session.effective_uid(), session.effective_gid())?; + } else { + let target_id = fs.resolve_path_checked(targets[0], session)?; + require_access(fs, target_id, session, Access::Read, targets[0])?; + fs.link(targets[0], targets[1])?; + } + Ok(String::new()) +} + +fn cmd_echo(args: &[String]) -> Result { + Ok(args.join(" ") + "\n") +} + +fn cmd_write( + fs: &mut VirtualFs, + args: &[String], + stdin: Option<&str>, + session: &Session, +) -> Result { + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "write: need file path".to_string(), + }); + } + + let content = if let Some(input) = stdin { + input.to_string() + } else if args.len() > 1 { + args[1..].join(" ") + } else { + return Err(VfsError::InvalidArgs { + message: "write: need content (pipe input or provide as args)".to_string(), + }); + }; + + // Create file if it doesn't exist + if fs.resolve_path(&args[0]).is_err() { + let (parent_id, _) = fs.resolve_parent_checked(&args[0], session)?; + require_access(fs, parent_id, session, Access::Write, &args[0])?; + fs.touch(&args[0], session.effective_uid(), session.effective_gid())?; + } else { + // File exists — need Write on file + let id = fs.resolve_path_checked(&args[0], session)?; + require_access(fs, id, session, Access::Write, &args[0])?; + } + fs.write_file(&args[0], content.into_bytes())?; + Ok(String::new()) +} + +// ───── User management commands ───── + +fn cmd_adduser( + fs: &mut VirtualFs, + args: &[String], + _session: &Session, +) -> Result { + require_admin(_session)?; + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "adduser: missing username".to_string(), + }); + } + let name = &args[0]; + let (uid, _) = fs.registry.add_user(name, false)?; + let gid = fs.registry.get_user(uid).map(|u| u.groups.first().copied().unwrap_or(0)).unwrap_or(0); + + let mut output = format!("User '{name}' created (uid={uid})\n"); + + let _ = fs.mkdir_p("/home", ROOT_UID, ROOT_GID); + let home_path = format!("/home/{name}"); + if fs.mkdir(&home_path, uid, gid).is_ok() { + output.push_str(&format!("Home directory: /home/{name}\n")); + } + + Ok(output) +} + +fn cmd_addagent( + fs: &mut VirtualFs, + args: &[String], + _session: &Session, +) -> Result { + require_admin(_session)?; + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "addagent: missing agent name".to_string(), + }); + } + let (uid, raw_token) = fs.registry.add_user(&args[0], true)?; + let token = raw_token.unwrap(); + Ok(format!( + "Agent '{}' created (uid={uid})\nAPI token (save this — shown only once):\n {token}\n", + args[0] + )) +} + +fn cmd_deluser( + fs: &mut VirtualFs, + args: &[String], + _session: &Session, +) -> Result { + if !_session.is_effectively_root() { + return Err(VfsError::PermissionDenied { + path: "deluser: root only".to_string(), + }); + } + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "deluser: missing username".to_string(), + }); + } + fs.registry.del_user(&args[0])?; + Ok(format!("User '{}' deleted\n", args[0])) +} + +fn cmd_addgroup( + fs: &mut VirtualFs, + args: &[String], + _session: &Session, +) -> Result { + require_admin(_session)?; + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "addgroup: missing group name".to_string(), + }); + } + let gid = fs.registry.add_group(&args[0])?; + Ok(format!("Group '{}' created (gid={gid})\n", args[0])) +} + +fn cmd_delgroup( + fs: &mut VirtualFs, + args: &[String], + _session: &Session, +) -> Result { + if !_session.is_effectively_root() { + return Err(VfsError::PermissionDenied { + path: "delgroup: root only".to_string(), + }); + } + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "delgroup: missing group name".to_string(), + }); + } + fs.registry.del_group(&args[0])?; + Ok(format!("Group '{}' deleted\n", args[0])) +} + +fn cmd_usermod( + fs: &mut VirtualFs, + args: &[String], + _session: &Session, +) -> Result { + require_admin(_session)?; + + // usermod -aG or usermod -rG + if args.len() < 3 { + return Err(VfsError::InvalidArgs { + message: "usermod: usage: usermod -aG | usermod -rG " + .to_string(), + }); + } + + match args[0].as_str() { + "-aG" => { + fs.registry.usermod_add_group(&args[2], &args[1])?; + Ok(format!("Added '{}' to group '{}'\n", args[2], args[1])) + } + "-rG" => { + fs.registry.usermod_remove_group(&args[2], &args[1])?; + Ok(format!( + "Removed '{}' from group '{}'\n", + args[2], args[1] + )) + } + _ => Err(VfsError::InvalidArgs { + message: "usermod: usage: usermod -aG | usermod -rG " + .to_string(), + }), + } +} + +fn cmd_groups( + fs: &VirtualFs, + args: &[String], + session: &Session, +) -> Result { + let username = if args.is_empty() { + &session.username + } else { + // Root, wheel members, or the user themselves can see others' groups + let is_admin = + session.uid == ROOT_UID || session.groups.contains(&WHEEL_GID); + if !is_admin && args[0] != session.username { + return Err(VfsError::PermissionDenied { + path: "groups".to_string(), + }); + } + &args[0] + }; + + let uid = fs + .registry + .lookup_uid(username) + .ok_or_else(|| VfsError::AuthError { + message: format!("no such user: {username}"), + })?; + let user = fs.registry.get_user(uid).unwrap(); + + let group_names: Vec<&str> = user + .groups + .iter() + .filter_map(|&gid| fs.registry.group_name(gid)) + .collect(); + Ok(format!("{}\n", group_names.join(" "))) +} + +fn cmd_id( + fs: &VirtualFs, + args: &[String], + session: &Session, +) -> Result { + let username = if args.is_empty() { + &session.username + } else { + &args[0] + }; + + let uid = fs + .registry + .lookup_uid(username) + .ok_or_else(|| VfsError::AuthError { + message: format!("no such user: {username}"), + })?; + let user = fs.registry.get_user(uid).unwrap(); + + let primary_gid = user.groups.first().copied().unwrap_or(0); + let primary_group = fs.registry.group_name(primary_gid).unwrap_or("?"); + + let groups_str: Vec = user + .groups + .iter() + .filter_map(|&gid| { + fs.registry + .group_name(gid) + .map(|name| format!("{gid}({name})")) + }) + .collect(); + + Ok(format!( + "uid={uid}({}) gid={primary_gid}({primary_group}) groups={}\n", + user.name, + groups_str.join(",") + )) +} + +fn cmd_su( + fs: &VirtualFs, + args: &[String], + session: &mut Session, +) -> Result { + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "su: missing username".to_string(), + }); + } + + let target = &args[0]; + + // Root can su to anyone; wheel members can su to anyone + // Delegation: the principal (not delegate) must be root or wheel + if session.uid != ROOT_UID && !session.groups.contains(&WHEEL_GID) { + return Err(VfsError::PermissionDenied { + path: format!("su: must be root or wheel member to switch user"), + }); + } + + let uid = fs + .registry + .lookup_uid(target) + .ok_or_else(|| VfsError::AuthError { + message: format!("no such user: {target}"), + })?; + let user = fs.registry.get_user(uid).unwrap(); + + session.uid = user.uid; + session.gid = user.groups.first().copied().unwrap_or(0); + session.groups = user.groups.clone(); + session.username = user.name.clone(); + + Ok(format!("Switched to user '{}'\n", user.name)) +} + +fn cmd_delegate( + fs: &VirtualFs, + args: &[String], + session: &mut Session, +) -> Result { + if args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "delegate: usage: delegate or delegate :".to_string(), + }); + } + + // Only agents or admins can delegate + let is_agent = fs + .registry + .get_user(session.uid) + .map(|u| u.is_agent) + .unwrap_or(false); + if !is_agent && session.uid != ROOT_UID && !session.groups.contains(&WHEEL_GID) { + return Err(VfsError::PermissionDenied { + path: "delegate: only agents or admins can delegate".to_string(), + }); + } + + let target = &args[0]; + + if let Some(group_name) = target.strip_prefix(':') { + // Delegate for a group: permissions limited to what a generic member of that group can do + let gid = + fs.registry + .lookup_gid(group_name) + .ok_or_else(|| VfsError::AuthError { + message: format!("no such group: {group_name}"), + })?; + session.delegate = Some(crate::auth::session::DelegateContext { + // Use a synthetic uid that won't match any file owner — forces group/other bits only + uid: u32::MAX, + gid, + groups: vec![gid], + username: format!(":{group_name}"), + }); + Ok(format!( + "Now acting on behalf of group '{group_name}' (effective: intersection)\n" + )) + } else { + // Delegate for a specific user + let uid = + fs.registry + .lookup_uid(target) + .ok_or_else(|| VfsError::AuthError { + message: format!("no such user: {target}"), + })?; + let user = fs.registry.get_user(uid).unwrap(); + session.delegate = Some(crate::auth::session::DelegateContext { + uid: user.uid, + gid: user.groups.first().copied().unwrap_or(0), + groups: user.groups.clone(), + username: user.name.clone(), + }); + Ok(format!( + "Now acting on behalf of '{}' (effective: intersection)\n", + user.name + )) + } +} + +fn cmd_undelegate(session: &mut Session) -> Result { + if session.delegate.is_none() { + return Ok("No active delegation\n".to_string()); + } + let name = session + .delegate + .as_ref() + .map(|d| d.username.clone()) + .unwrap_or_default(); + session.delegate = None; + Ok(format!("Delegation for '{name}' ended\n")) +} + +// ───── Help ───── + +fn cmd_help() -> Result { + Ok(r#"markdownfs — Markdown Virtual File System + +File commands: + ls [-l] [path] List directory contents + cd [path] Change directory + pwd Print working directory + mkdir [-p] Create directory + touch Create empty markdown file + cat Display file contents + write [content] Write content to file (or pipe: echo "text" | write file.md) + rm [-r] Remove file or directory + rmdir Remove empty directory + mv Move/rename + cp Copy file + stat File information + tree [path] Directory tree view + find [path] [-name pat] Find files by pattern + grep [-r] [path] Search file contents + head [-n N] First N lines (pipe) + tail [-n N] Last N lines (pipe) + wc [-l|-w|-c] Count lines/words/bytes (pipe) + chmod Change permissions (owner or root) + chown Change ownership (root or owner) + ln -s Create symbolic link + echo Print text + edit Edit file (multi-line input, auto-commits) + +User management: + adduser Create user (admin) + addagent Create agent with API token (admin) + deluser Delete user (root only) + addgroup Create group (admin) + delgroup Delete group (root only) + usermod -aG Add user to group (admin) + usermod -rG Remove user from group (admin) + groups [user] Show group memberships + whoami Show current user + id [user] Show user identity + su Switch user (root or wheel) + +VCS commands: + commit Commit current state + log Show commit history + revert Revert to a commit + status Show modified files + +Pipes: grep "TODO" notes/ | head -5 | wc -l +"# + .to_string()) +} + +// ───── Formatting ───── + +fn format_permissions(mode: u16) -> String { + let mut s = String::with_capacity(9); + for shift in [6, 3, 0] { + let bits = (mode >> shift) & 0o7; + s.push(if bits & 4 != 0 { 'r' } else { '-' }); + s.push(if bits & 2 != 0 { 'w' } else { '-' }); + s.push(if bits & 1 != 0 { 'x' } else { '-' }); + } + s +} + +fn format_time(epoch: u64) -> String { + let dt = chrono::DateTime::from_timestamp(epoch as i64, 0); + match dt { + Some(d) => d.format("%b %d %H:%M").to_string(), + None => "???".to_string(), + } +} diff --git a/src/cmd/parser.rs b/src/cmd/parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..59e48986a18a69bdaa5e67dbe2578a8ec715d3a9 --- /dev/null +++ b/src/cmd/parser.rs @@ -0,0 +1,119 @@ +/// Tokenize a command line, respecting quotes. +pub fn tokenize(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escape_next = false; + + for ch in input.chars() { + if escape_next { + current.push(ch); + escape_next = false; + continue; + } + + match ch { + '\\' if !in_single_quote => { + escape_next = true; + } + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + ' ' | '\t' if !in_single_quote && !in_double_quote => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + _ => { + current.push(ch); + } + } + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +#[derive(Debug)] +pub struct ParsedCommand { + pub program: String, + pub args: Vec, +} + +#[derive(Debug)] +pub struct Pipeline { + pub commands: Vec, +} + +pub fn parse_pipeline(input: &str) -> Pipeline { + let segments: Vec<&str> = split_on_pipes(input); + let commands = segments + .into_iter() + .filter_map(|seg| { + let tokens = tokenize(seg.trim()); + if tokens.is_empty() { + return None; + } + Some(ParsedCommand { + program: tokens[0].clone(), + args: tokens[1..].to_vec(), + }) + }) + .collect(); + Pipeline { commands } +} + +fn split_on_pipes(input: &str) -> Vec<&str> { + let mut segments = Vec::new(); + let mut start = 0; + let mut in_single = false; + let mut in_double = false; + + for (i, ch) in input.char_indices() { + match ch { + '\'' if !in_double => in_single = !in_single, + '"' if !in_single => in_double = !in_double, + '|' if !in_single && !in_double => { + segments.push(&input[start..i]); + start = i + 1; + } + _ => {} + } + } + segments.push(&input[start..]); + segments +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tokenize_simple() { + assert_eq!(tokenize("ls -la /foo"), vec!["ls", "-la", "/foo"]); + } + + #[test] + fn test_tokenize_quotes() { + assert_eq!( + tokenize(r#"grep "hello world" file.md"#), + vec!["grep", "hello world", "file.md"] + ); + } + + #[test] + fn test_pipeline() { + let pipeline = parse_pipeline("grep TODO notes/ | head -5 | wc -l"); + assert_eq!(pipeline.commands.len(), 3); + assert_eq!(pipeline.commands[0].program, "grep"); + assert_eq!(pipeline.commands[1].program, "head"); + assert_eq!(pipeline.commands[2].program, "wc"); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..161b25039c6dacd7b33474fc29a70272096cda0c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,110 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CompatibilityTarget { + Markdown, + Posix, +} + +impl CompatibilityTarget { + pub fn from_env_value(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "markdown" | "markdown-only" | "markdown_only" => Some(Self::Markdown), + "posix" | "mount" | "fuse" => Some(Self::Posix), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Markdown => "markdown", + Self::Posix => "posix", + } + } +} + +#[derive(Debug, Clone)] +pub struct Config { + pub data_dir: PathBuf, + pub listen_addr: String, + pub auto_save_interval_secs: u64, + pub auto_save_write_threshold: u64, + pub max_file_size: usize, + pub max_inodes: usize, + pub max_dir_depth: usize, + pub compatibility_target: CompatibilityTarget, +} + +impl Config { + pub fn from_env() -> Self { + let data_dir = std::env::var("MARKDOWNFS_DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + + let listen_addr = std::env::var("MARKDOWNFS_LISTEN") + .unwrap_or_else(|_| "127.0.0.1:3000".to_string()); + + let auto_save_interval_secs = std::env::var("MARKDOWNFS_AUTOSAVE_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5); + + let auto_save_write_threshold = std::env::var("MARKDOWNFS_AUTOSAVE_WRITES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100); + + let max_file_size = std::env::var("MARKDOWNFS_MAX_FILE_SIZE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10 * 1024 * 1024); // 10MB + + let max_inodes = std::env::var("MARKDOWNFS_MAX_INODES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1_000_000); + + let max_dir_depth = std::env::var("MARKDOWNFS_MAX_DEPTH") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(256); + + let compatibility_target = std::env::var("MARKDOWNFS_COMPAT_TARGET") + .ok() + .and_then(|value| CompatibilityTarget::from_env_value(&value)) + .unwrap_or(CompatibilityTarget::Markdown); + + Config { + data_dir, + listen_addr, + auto_save_interval_secs, + auto_save_write_threshold, + max_file_size, + max_inodes, + max_dir_depth, + compatibility_target, + } + } + + pub fn with_data_dir(mut self, dir: impl AsRef) -> Self { + self.data_dir = dir.as_ref().to_path_buf(); + self + } + + pub fn with_listen_addr(mut self, addr: impl Into) -> Self { + self.listen_addr = addr.into(); + self + } + + pub fn with_compatibility_target(mut self, target: CompatibilityTarget) -> Self { + self.compatibility_target = target; + self + } +} + +impl Default for Config { + fn default() -> Self { + Self::from_env() + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000000000000000000000000000000000000..5f40f986da86795a503cc77a940e5892e526b1be --- /dev/null +++ b/src/db.rs @@ -0,0 +1,597 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::sync::{Notify, RwLock}; + +use crate::auth::session::Session; +use crate::auth::{Gid, Uid}; +use crate::config::Config; +use crate::error::VfsError; +use crate::fs::{FsOptions, GrepResult, HandleId, LsEntry, StatInfo, VirtualFs}; +use crate::persist::{ + LocalStateBackend, MemoryPersistenceBackend, PersistenceBackend, PersistenceInfo, +}; +use crate::store::commit::CommitObject; +use crate::vcs::Vcs; + +struct DbInner { + fs: VirtualFs, + vcs: Vcs, +} + +/// Thread-safe, concurrent markdown database. +/// +/// All methods take `&self` (not `&mut self`). The struct is `Clone` +/// via the inner `Arc`, so it can be shared across threads cheaply. +#[derive(Clone)] +pub struct MarkdownDb { + inner: Arc>, + persist: Arc, + config: Arc, + write_count: Arc, + save_notify: Arc, +} + +impl MarkdownDb { + pub fn open(config: Config) -> Result { + let persist: Arc = Arc::new(LocalStateBackend::new(&config.data_dir)); + Ok(Self::open_with_backend(config, persist)) + } + + pub fn open_with_backend(config: Config, persist: Arc) -> Self { + let options = FsOptions { + compatibility_target: config.compatibility_target, + }; + let (fs, vcs) = persist + .load() + .ok() + .flatten() + .map(|state| (state.fs, state.vcs)) + .unwrap_or_else(|| (VirtualFs::new_with_options(options), Vcs::new())); + + MarkdownDb { + inner: Arc::new(RwLock::new(DbInner { fs, vcs })), + persist, + config: Arc::new(config), + write_count: Arc::new(AtomicU64::new(0)), + save_notify: Arc::new(Notify::new()), + } + } + + pub fn open_memory() -> Self { + let config = Config::from_env(); + let options = FsOptions { + compatibility_target: config.compatibility_target, + }; + MarkdownDb { + inner: Arc::new(RwLock::new(DbInner { + fs: VirtualFs::new_with_options(options), + vcs: Vcs::new(), + })), + persist: Arc::new(MemoryPersistenceBackend), + config: Arc::new(config), + write_count: Arc::new(AtomicU64::new(0)), + save_notify: Arc::new(Notify::new()), + } + } + + fn mark_dirty(&self) { + let count = self.write_count.fetch_add(1, Ordering::Relaxed); + if count + 1 >= self.config.auto_save_write_threshold { + self.save_notify.notify_one(); + } + } + + /// Spawn a background auto-save task. Returns a handle that can be aborted on shutdown. + pub fn spawn_auto_save(&self) -> tokio::task::JoinHandle<()> { + let db = self.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_secs( + db.config.auto_save_interval_secs, + )) => {} + _ = db.save_notify.notified() => {} + } + + let prev = db.write_count.swap(0, Ordering::Relaxed); + if prev > 0 { + if let Err(e) = db.save().await { + tracing::error!("auto-save failed: {e}"); + } else { + tracing::debug!("auto-saved after {prev} writes"); + } + } + } + }) + } + + pub async fn save(&self) -> Result<(), VfsError> { + let guard = self.inner.read().await; + self.persist.save(&guard.fs, &guard.vcs) + } + + pub fn config(&self) -> &Config { + &self.config + } + + pub fn persist_info(&self) -> PersistenceInfo { + self.persist.info() + } + + // ─── Read operations (take read lock) ─── + + pub async fn cat(&self, path: &str) -> Result, VfsError> { + let guard = self.inner.read().await; + guard.fs.cat_owned(path) + } + + pub async fn ls(&self, path: Option<&str>) -> Result, VfsError> { + let guard = self.inner.read().await; + guard.fs.ls(path) + } + + pub async fn stat(&self, path: &str) -> Result { + let guard = self.inner.read().await; + guard.fs.stat(path) + } + + pub async fn pwd(&self) -> String { + let guard = self.inner.read().await; + guard.fs.pwd() + } + + pub async fn tree( + &self, + path: Option<&str>, + session: Option<&Session>, + ) -> Result { + let guard = self.inner.read().await; + guard.fs.tree(path, "", session) + } + + pub async fn find( + &self, + path: Option<&str>, + pattern: Option<&str>, + session: Option<&Session>, + ) -> Result, VfsError> { + let guard = self.inner.read().await; + guard.fs.find(path, pattern, session) + } + + pub async fn grep( + &self, + pattern: &str, + path: Option<&str>, + recursive: bool, + session: Option<&Session>, + ) -> Result, VfsError> { + let guard = self.inner.read().await; + guard.fs.grep(pattern, path, recursive, session) + } + + pub async fn vcs_log(&self) -> Vec { + let guard = self.inner.read().await; + guard.vcs.log().into_iter().cloned().collect() + } + + pub async fn vcs_status(&self) -> Result { + let guard = self.inner.read().await; + let inner = &*guard; + inner.vcs.status(&inner.fs) + } + + // ─── Write operations (take write lock) ─── + + pub async fn touch(&self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.touch(path, uid, gid)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn write_file(&self, path: &str, content: Vec) -> Result<(), VfsError> { + if content.len() > self.config.max_file_size { + return Err(VfsError::InvalidArgs { + message: format!( + "file size {} exceeds max {}", + content.len(), + self.config.max_file_size + ), + }); + } + let mut guard = self.inner.write().await; + guard.fs.write_file(path, content)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn mkdir(&self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.mkdir(path, uid, gid)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn mkdir_p(&self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.mkdir_p(path, uid, gid)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn rm(&self, path: &str) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.rm(path)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn rm_rf(&self, path: &str) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.rm_rf(path)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn mv(&self, src: &str, dst: &str) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.mv(src, dst)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn cp( + &self, + src: &str, + dst: &str, + uid: Uid, + gid: Gid, + ) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.cp(src, dst, uid, gid)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn chmod(&self, path: &str, mode: u16) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.chmod(path, mode)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn chown(&self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.chown(path, uid, gid)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn ln_s( + &self, + target: &str, + link_path: &str, + uid: Uid, + gid: Gid, + ) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.ln_s(target, link_path, uid, gid)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn link(&self, target: &str, link_path: &str) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.link(target, link_path)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn readlink(&self, path: &str) -> Result { + let guard = self.inner.read().await; + guard.fs.readlink(path) + } + + pub async fn truncate(&self, path: &str, size: usize) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.truncate(path, size)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + pub async fn read_file_at( + &self, + path: &str, + offset: usize, + size: usize, + ) -> Result, VfsError> { + let mut guard = self.inner.write().await; + guard.fs.read_file_at(path, offset, size) + } + + pub async fn write_file_at( + &self, + path: &str, + offset: usize, + data: &[u8], + ) -> Result { + let end = offset.saturating_add(data.len()); + if end > self.config.max_file_size { + return Err(VfsError::InvalidArgs { + message: format!("write exceeds max file size {}", self.config.max_file_size), + }); + } + let mut guard = self.inner.write().await; + let written = guard.fs.write_file_at(path, offset, data)?; + drop(guard); + self.mark_dirty(); + Ok(written) + } + + pub async fn open_file(&self, path: &str, writable: bool) -> Result { + let mut guard = self.inner.write().await; + guard.fs.open(path, writable) + } + + pub async fn open_dir(&self, path: &str) -> Result { + let mut guard = self.inner.write().await; + guard.fs.opendir(path) + } + + pub async fn read_handle(&self, handle: HandleId, size: usize) -> Result, VfsError> { + let mut guard = self.inner.write().await; + guard.fs.read_handle(handle, size) + } + + pub async fn write_handle(&self, handle: HandleId, data: &[u8]) -> Result { + let mut guard = self.inner.write().await; + let written = guard.fs.write_handle(handle, data)?; + drop(guard); + self.mark_dirty(); + Ok(written) + } + + pub async fn release_handle(&self, handle: HandleId) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + guard.fs.release_handle(handle) + } + + pub async fn commit(&self, message: &str, author: &str) -> Result { + let mut guard = self.inner.write().await; + let inner = &mut *guard; + let id = inner.vcs.commit(&inner.fs, message, author)?; + drop(guard); + self.mark_dirty(); + Ok(id.short_hex()) + } + + pub async fn revert(&self, hash_prefix: &str) -> Result<(), VfsError> { + let mut guard = self.inner.write().await; + let inner = &mut *guard; + inner.vcs.revert(&mut inner.fs, hash_prefix)?; + drop(guard); + self.mark_dirty(); + Ok(()) + } + + // ─── Command execution (write lock — dispatches through cmd module) ─── + + pub async fn execute_command( + &self, + line: &str, + session: &mut Session, + ) -> Result { + use crate::cmd; + use crate::cmd::parser; + + let pipeline = parser::parse_pipeline(line); + if pipeline.commands.is_empty() { + return Ok(String::new()); + } + + if let Some(first) = pipeline.commands.first() { + match first.program.as_str() { + "commit" => { + let msg = if first.args.is_empty() { + "snapshot" + } else { + &first.args.join(" ") + }; + let hash = self.commit(msg, &session.username).await?; + return Ok(format!("[{hash}] {msg}\n")); + } + "log" => { + let commits = self.vcs_log().await; + if commits.is_empty() { + return Ok("No commits yet.\n".to_string()); + } + let mut output = String::new(); + for c in &commits { + let time = chrono::DateTime::from_timestamp(c.timestamp as i64, 0) + .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "???".to_string()); + output.push_str(&format!( + "{} {} {} {}\n", + c.id.short_hex(), + time, + c.author, + c.message + )); + } + return Ok(output); + } + "revert" => { + if first.args.is_empty() { + return Err(VfsError::InvalidArgs { + message: "revert: need commit hash prefix".to_string(), + }); + } + self.revert(&first.args[0]).await?; + return Ok(format!("Reverted to {}\n", first.args[0])); + } + "status" => { + return self.vcs_status().await; + } + _ => {} + } + } + + let mut guard = self.inner.write().await; + let inner = &mut *guard; + let result = cmd::execute_pipeline(&pipeline, &mut inner.fs, session); + let is_write = pipeline.commands.iter().any(|c| { + matches!( + c.program.as_str(), + "touch" + | "write" + | "mkdir" + | "rm" + | "rmdir" + | "mv" + | "cp" + | "chmod" + | "chown" + | "ln" + | "adduser" + | "addagent" + | "deluser" + | "addgroup" + | "delgroup" + | "usermod" + ) + }); + drop(guard); + if is_write { + self.mark_dirty(); + } + result + } + + // ─── Auth helpers ─── + + pub async fn login(&self, username: &str) -> Result { + let mut guard = self.inner.write().await; + let uid = guard + .registry_lookup_uid(username) + .ok_or_else(|| VfsError::AuthError { + message: format!("unknown user: {username}"), + })?; + let user = guard + .registry_get_user(uid) + .ok_or_else(|| VfsError::AuthError { + message: format!("user uid={uid} not found"), + })?; + let session = Session::new( + user.uid, + user.groups.first().copied().unwrap_or(0), + user.groups.clone(), + user.name.clone(), + ); + + let home_path = format!("/home/{username}"); + if guard.fs.stat(&home_path).is_ok() { + let _ = guard.fs.cd(&home_path); + } + + Ok(session) + } + + pub async fn authenticate_token(&self, raw_token: &str) -> Result { + let guard = self.inner.read().await; + let uid = guard + .fs + .registry + .authenticate_token(raw_token) + .ok_or_else(|| VfsError::AuthError { + message: "invalid token".to_string(), + })?; + let user = guard + .fs + .registry + .get_user(uid) + .ok_or_else(|| VfsError::AuthError { + message: "token user not found".to_string(), + })?; + Ok(Session::new( + user.uid, + user.groups.first().copied().unwrap_or(0), + user.groups.clone(), + user.name.clone(), + )) + } + + pub async fn has_users(&self) -> bool { + let guard = self.inner.read().await; + guard + .fs + .registry + .list_users() + .iter() + .any(|u| u.uid != crate::auth::ROOT_UID) + } + + pub async fn create_admin(&self, name: &str) -> Result { + let mut guard = self.inner.write().await; + let (uid, _) = guard.fs.registry.add_user(name, false)?; + let _ = guard.fs.registry.usermod_add_group(name, "wheel"); + let user = guard.fs.registry.get_user(uid).unwrap(); + let gid = user.groups.first().copied().unwrap_or(0); + let session = Session::new(uid, gid, user.groups.clone(), user.name.clone()); + + let _ = guard.fs.mkdir_p( + "/home", + crate::auth::ROOT_UID, + crate::auth::ROOT_GID, + ); + let home_path = format!("/home/{name}"); + let _ = guard.fs.mkdir(&home_path, uid, gid); + let _ = guard.fs.cd(&home_path); + + drop(guard); + self.mark_dirty(); + Ok(session) + } + + pub async fn commit_count(&self) -> usize { + let guard = self.inner.read().await; + guard.vcs.commits.len() + } + + pub async fn object_count(&self) -> usize { + let guard = self.inner.read().await; + guard.vcs.store.object_count() + } + + pub async fn inode_count(&self) -> usize { + let guard = self.inner.read().await; + guard.fs.all_inodes().len() + } + + pub fn snapshot_fs(&self) -> VirtualFs { + self.inner.blocking_read().fs.clone() + } +} + +impl DbInner { + fn registry_lookup_uid(&self, name: &str) -> Option { + self.fs.registry.lookup_uid(name) + } + + fn registry_get_user(&self, uid: Uid) -> Option { + self.fs.registry.get_user(uid).cloned() + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000000000000000000000000000000000000..218db4640d984ee30421179a299fbf865374c67c --- /dev/null +++ b/src/error.rs @@ -0,0 +1,64 @@ +use std::fmt; + +#[derive(Debug)] +pub enum VfsError { + InvalidExtension { name: String }, + InvalidHandle { handle: u64 }, + NotFound { path: String }, + IsDirectory { path: String }, + NotDirectory { path: String }, + AlreadyExists { path: String }, + NotEmpty { path: String }, + InvalidPath { path: String }, + IoError(std::io::Error), + UnknownCommand { name: String }, + InvalidArgs { message: String }, + SymlinkLoop { path: String }, + ObjectNotFound { id: String }, + CorruptStore { message: String }, + NoCommits, + DirtyWorkingTree, + PermissionDenied { path: String }, + AuthError { message: String }, + NotSupported { message: String }, +} + +impl fmt::Display for VfsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VfsError::InvalidExtension { name } => { + write!(f, "markdownfs: only .md files are supported: '{name}'") + } + VfsError::InvalidHandle { handle } => write!(f, "markdownfs: invalid handle: {handle}"), + VfsError::NotFound { path } => write!(f, "markdownfs: no such file or directory: '{path}'"), + VfsError::IsDirectory { path } => write!(f, "markdownfs: is a directory: '{path}'"), + VfsError::NotDirectory { path } => write!(f, "markdownfs: not a directory: '{path}'"), + VfsError::AlreadyExists { path } => write!(f, "markdownfs: already exists: '{path}'"), + VfsError::NotEmpty { path } => write!(f, "markdownfs: directory not empty: '{path}'"), + VfsError::InvalidPath { path } => write!(f, "markdownfs: invalid path: '{path}'"), + VfsError::IoError(e) => write!(f, "markdownfs: I/O error: {e}"), + VfsError::UnknownCommand { name } => write!(f, "markdownfs: unknown command: '{name}'"), + VfsError::InvalidArgs { message } => write!(f, "markdownfs: {message}"), + VfsError::SymlinkLoop { path } => write!(f, "markdownfs: symlink loop: '{path}'"), + VfsError::ObjectNotFound { id } => write!(f, "markdownfs: object not found: {id}"), + VfsError::CorruptStore { message } => write!(f, "markdownfs: corrupt store: {message}"), + VfsError::NoCommits => write!(f, "markdownfs: no commits yet"), + VfsError::DirtyWorkingTree => { + write!(f, "markdownfs: working tree has uncommitted changes") + } + VfsError::PermissionDenied { path } => { + write!(f, "markdownfs: permission denied: '{path}'") + } + VfsError::AuthError { message } => write!(f, "markdownfs: {message}"), + VfsError::NotSupported { message } => write!(f, "markdownfs: operation not supported: {message}"), + } + } +} + +impl std::error::Error for VfsError {} + +impl From for VfsError { + fn from(e: std::io::Error) -> Self { + VfsError::IoError(e) + } +} diff --git a/src/fs/inode.rs b/src/fs/inode.rs new file mode 100644 index 0000000000000000000000000000000000000000..47e9d9dd9329ca69084562d804467130aadba39a --- /dev/null +++ b/src/fs/inode.rs @@ -0,0 +1,168 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub type InodeId = u64; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +pub struct Timestamp { + pub secs: u64, + pub nanos: u32, +} + +impl Timestamp { + pub fn now() -> Self { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + Self { + secs: duration.as_secs(), + nanos: duration.subsec_nanos(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Inode { + pub id: InodeId, + pub kind: InodeKind, + pub mode: u16, + pub uid: u32, + pub gid: u32, + #[serde(default = "default_nlink")] + pub nlink: u64, + #[serde(default = "default_block_size")] + pub block_size: u64, + #[serde(default)] + pub created_at: Timestamp, + #[serde(default)] + pub modified_at: Timestamp, + #[serde(default)] + pub accessed_at: Timestamp, + #[serde(default)] + pub changed_at: Timestamp, + #[serde(default)] + pub created: u64, + #[serde(default)] + pub modified: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum InodeKind { + File { content: Vec }, + Directory { entries: BTreeMap }, + Symlink { target: String }, +} + +impl Inode { + pub fn new_dir(id: InodeId, uid: u32, gid: u32) -> Self { + let now = Timestamp::now(); + Inode { + id, + kind: InodeKind::Directory { + entries: BTreeMap::new(), + }, + mode: 0o755, + uid, + gid, + nlink: 2, + block_size: 4096, + created_at: now, + modified_at: now, + accessed_at: now, + changed_at: now, + created: now.secs, + modified: now.secs, + } + } + + pub fn new_file(id: InodeId, uid: u32, gid: u32) -> Self { + let now = Timestamp::now(); + Inode { + id, + kind: InodeKind::File { + content: Vec::new(), + }, + mode: 0o644, + uid, + gid, + nlink: 1, + block_size: 4096, + created_at: now, + modified_at: now, + accessed_at: now, + changed_at: now, + created: now.secs, + modified: now.secs, + } + } + + pub fn new_symlink(id: InodeId, target: String, uid: u32, gid: u32) -> Self { + let now = Timestamp::now(); + Inode { + id, + kind: InodeKind::Symlink { target }, + mode: 0o777, + uid, + gid, + nlink: 1, + block_size: 4096, + created_at: now, + modified_at: now, + accessed_at: now, + changed_at: now, + created: now.secs, + modified: now.secs, + } + } + + pub fn is_dir(&self) -> bool { + matches!(self.kind, InodeKind::Directory { .. }) + } + + pub fn is_file(&self) -> bool { + matches!(self.kind, InodeKind::File { .. }) + } + + pub fn size(&self) -> u64 { + match &self.kind { + InodeKind::File { content } => content.len() as u64, + InodeKind::Directory { entries } => entries.len() as u64, + InodeKind::Symlink { target } => target.len() as u64, + } + } + + pub fn blocks(&self) -> u64 { + let size = self.size(); + if size == 0 { + 0 + } else { + size.div_ceil(512) + } + } + + pub fn touch_access(&mut self) { + let now = Timestamp::now(); + self.accessed_at = now; + } + + pub fn touch_modify(&mut self) { + let now = Timestamp::now(); + self.modified_at = now; + self.changed_at = now; + self.modified = now.secs; + } + + pub fn touch_change(&mut self) { + let now = Timestamp::now(); + self.changed_at = now; + } +} + +fn default_nlink() -> u64 { + 1 +} + +fn default_block_size() -> u64 { + 4096 +} diff --git a/src/fs/mod.rs b/src/fs/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..2a0a12f7b3450688b6bf35bee228f71e30551a8a --- /dev/null +++ b/src/fs/mod.rs @@ -0,0 +1,1362 @@ +pub mod inode; + +use crate::auth::perms::{has_setgid, Access}; +use crate::auth::registry::UserRegistry; +use crate::auth::session::Session; +use crate::auth::{Gid, Uid, ROOT_GID, ROOT_UID}; +use crate::config::CompatibilityTarget; +use crate::error::VfsError; +use inode::{Inode, InodeId, InodeKind}; +use std::collections::BTreeMap; +use std::collections::{HashMap, HashSet}; + +pub type HandleId = u64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FsOptions { + pub compatibility_target: CompatibilityTarget, +} + +impl Default for FsOptions { + fn default() -> Self { + Self { + compatibility_target: CompatibilityTarget::Markdown, + } + } +} + +#[derive(Debug, Clone)] +struct OpenHandle { + inode_id: InodeId, + cursor: usize, + writable: bool, + directory: bool, +} + +#[derive(Clone)] +pub struct VirtualFs { + inodes: HashMap, + root: InodeId, + cwd: InodeId, + next_id: InodeId, + cwd_path: Vec<(String, InodeId)>, + options: FsOptions, + next_handle: HandleId, + handles: HashMap, + pending_delete: HashSet, + pub registry: UserRegistry, +} + +impl VirtualFs { + pub fn new() -> Self { + Self::new_with_options(FsOptions::default()) + } + + pub fn new_posix() -> Self { + Self::new_with_options(FsOptions { + compatibility_target: CompatibilityTarget::Posix, + }) + } + + pub fn new_with_options(options: FsOptions) -> Self { + let root_id = 0; + let root = Inode::new_dir(root_id, ROOT_UID, ROOT_GID); + let mut inodes = HashMap::new(); + inodes.insert(root_id, root); + VirtualFs { + inodes, + root: root_id, + cwd: root_id, + next_id: 1, + cwd_path: Vec::new(), + options, + next_handle: 1, + handles: HashMap::new(), + pending_delete: HashSet::new(), + registry: UserRegistry::new(), + } + } + + fn alloc_id(&mut self) -> InodeId { + let id = self.next_id; + self.next_id += 1; + id + } + + fn alloc_handle_id(&mut self) -> HandleId { + let id = self.next_handle; + self.next_handle += 1; + id + } + + pub fn options(&self) -> FsOptions { + self.options + } + + pub fn compatibility_target(&self) -> CompatibilityTarget { + self.options.compatibility_target + } + + pub fn pwd(&self) -> String { + if self.cwd_path.is_empty() { + "/".to_string() + } else { + let mut path = String::new(); + for (name, _) in &self.cwd_path { + path.push('/'); + path.push_str(name); + } + path + } + } + + pub fn get_inode(&self, id: InodeId) -> Result<&Inode, VfsError> { + self.inodes.get(&id).ok_or_else(|| VfsError::NotFound { + path: format!(""), + }) + } + + fn get_inode_mut(&mut self, id: InodeId) -> Result<&mut Inode, VfsError> { + self.inodes.get_mut(&id).ok_or_else(|| VfsError::NotFound { + path: format!(""), + }) + } + + fn dir_entries(&self, id: InodeId) -> Result<&BTreeMap, VfsError> { + match &self.get_inode(id)?.kind { + InodeKind::Directory { entries } => Ok(entries), + _ => Err(VfsError::NotDirectory { + path: format!(""), + }), + } + } + + fn dir_entries_mut( + &mut self, + id: InodeId, + ) -> Result<&mut BTreeMap, VfsError> { + match &mut self.get_inode_mut(id)?.kind { + InodeKind::Directory { entries } => Ok(entries), + _ => Err(VfsError::NotDirectory { + path: format!(""), + }), + } + } + + /// Resolve a path without permission checks (for internal/VCS use). + pub fn resolve_path(&self, path: &str) -> Result { + let (start, components) = self.parse_path(path); + let mut current = start; + for component in &components { + match component.as_str() { + "." => {} + ".." => { + current = self.parent_of(current); + } + name => { + let entries = self.dir_entries(current)?; + current = *entries.get(name).ok_or_else(|| VfsError::NotFound { + path: path.to_string(), + })?; + } + } + } + Ok(current) + } + + /// Resolve a path with Execute permission checks on every directory. + pub fn resolve_path_checked( + &self, + path: &str, + session: &Session, + ) -> Result { + let (start, components) = self.parse_path(path); + let mut current = start; + + // Check execute on starting directory + let start_inode = self.get_inode(current)?; + if !session.has_permission(start_inode, Access::Execute) { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + + for component in &components { + match component.as_str() { + "." => {} + ".." => { + current = self.parent_of(current); + } + name => { + let entries = self.dir_entries(current)?; + current = *entries.get(name).ok_or_else(|| VfsError::NotFound { + path: path.to_string(), + })?; + // Check execute on intermediate directories + let inode = self.get_inode(current)?; + if inode.is_dir() && !session.has_permission(inode, Access::Execute) { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + } + } + } + Ok(current) + } + + /// Resolve parent with permission checks. + pub fn resolve_parent_checked( + &self, + path: &str, + session: &Session, + ) -> Result<(InodeId, String), VfsError> { + let (start, components) = self.parse_path(path); + if components.is_empty() { + return Err(VfsError::InvalidPath { + path: path.to_string(), + }); + } + let name = components.last().unwrap().clone(); + let parent_path = if components.len() == 1 { + if path.starts_with('/') { + "/".to_string() + } else { + ".".to_string() + } + } else { + let parent_components = &components[..components.len() - 1]; + if path.starts_with('/') { + format!("/{}", parent_components.join("/")) + } else { + parent_components.join("/") + } + }; + let _ = start; // used via parse_path in resolve_path_checked + let parent_id = self.resolve_path_checked(&parent_path, session)?; + Ok((parent_id, name)) + } + + fn resolve_parent(&self, path: &str) -> Result<(InodeId, String), VfsError> { + let (start, components) = self.parse_path(path); + if components.is_empty() { + return Err(VfsError::InvalidPath { + path: path.to_string(), + }); + } + let name = components.last().unwrap().clone(); + let mut current = start; + for component in &components[..components.len() - 1] { + match component.as_str() { + "." => {} + ".." => { + current = self.parent_of(current); + } + n => { + let entries = self.dir_entries(current)?; + current = *entries.get(n).ok_or_else(|| VfsError::NotFound { + path: path.to_string(), + })?; + } + } + } + Ok((current, name)) + } + + fn parse_path(&self, path: &str) -> (InodeId, Vec) { + let (start, path_str) = if path.starts_with('/') { + (self.root, &path[1..]) + } else { + (self.cwd, path) + }; + let components: Vec = path_str + .split('/') + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(); + (start, components) + } + + fn parent_of(&self, id: InodeId) -> InodeId { + if id == self.root { + return self.root; + } + for i in (0..self.cwd_path.len()).rev() { + if self.cwd_path[i].1 == id && i > 0 { + return self.cwd_path[i - 1].1; + } + } + for inode in self.inodes.values() { + if let InodeKind::Directory { entries } = &inode.kind { + if entries.values().any(|&child| child == id) { + return inode.id; + } + } + } + self.root + } + + /// Determine the gid for a new child in this directory. + /// If the parent has setgid, inherit parent's gid; otherwise use caller's gid. + fn effective_gid(&self, parent_id: InodeId, caller_gid: Gid) -> Gid { + if let Ok(parent) = self.get_inode(parent_id) { + if has_setgid(parent.mode) { + return parent.gid; + } + } + caller_gid + } + + fn validate_regular_path(&self, path: &str) -> Result<(), VfsError> { + if self.compatibility_target() == CompatibilityTarget::Posix { + return Ok(()); + } + validate_markdown_filename(path) + } + + fn mark_accessed(&mut self, id: InodeId) -> Result<(), VfsError> { + let inode = self.get_inode_mut(id)?; + inode.touch_access(); + Ok(()) + } + + fn unlink_inode_if_needed(&mut self, id: InodeId) { + let open = self + .handles + .values() + .any(|handle| handle.inode_id == id); + if let Some(inode) = self.inodes.get(&id) { + if inode.nlink == 0 { + if open { + self.pending_delete.insert(id); + } else { + self.pending_delete.remove(&id); + self.inodes.remove(&id); + } + } + } + } + + fn release_orphan_if_needed(&mut self, id: InodeId) { + if !self.pending_delete.contains(&id) { + return; + } + let still_open = self + .handles + .values() + .any(|handle| handle.inode_id == id); + if !still_open { + self.pending_delete.remove(&id); + self.inodes.remove(&id); + } + } + + // ───── Commands ───── + + pub fn ls(&self, path: Option<&str>) -> Result, VfsError> { + let id = match path { + Some(p) => self.resolve_path(p)?, + None => self.cwd, + }; + let inode = self.get_inode(id)?; + match &inode.kind { + InodeKind::Directory { entries } => { + let mut result = Vec::with_capacity(entries.len()); + for (name, &child_id) in entries { + let child = self.get_inode(child_id)?; + result.push(LsEntry { + name: name.clone(), + is_dir: child.is_dir(), + is_symlink: matches!(child.kind, InodeKind::Symlink { .. }), + size: child.size(), + mode: child.mode, + uid: child.uid, + gid: child.gid, + modified: child.modified, + }); + } + Ok(result) + } + InodeKind::File { .. } => Ok(vec![LsEntry { + name: path.unwrap_or("").to_string(), + is_dir: false, + is_symlink: false, + size: inode.size(), + mode: inode.mode, + uid: inode.uid, + gid: inode.gid, + modified: inode.modified, + }]), + InodeKind::Symlink { .. } => Ok(vec![LsEntry { + name: path.unwrap_or("").to_string(), + is_dir: false, + is_symlink: true, + size: inode.size(), + mode: inode.mode, + uid: inode.uid, + gid: inode.gid, + modified: inode.modified, + }]), + } + } + + pub fn cd(&mut self, path: &str) -> Result<(), VfsError> { + if path == "/" { + self.cwd = self.root; + self.cwd_path.clear(); + return Ok(()); + } + + let target = self.resolve_path(path)?; + let inode = self.get_inode(target)?; + if !inode.is_dir() { + return Err(VfsError::NotDirectory { + path: path.to_string(), + }); + } + + if path.starts_with('/') { + self.cwd_path.clear(); + } + + for component in path.split('/').filter(|s| !s.is_empty()) { + match component { + "." => {} + ".." => { + self.cwd_path.pop(); + } + name => { + let current_dir = self + .cwd_path + .last() + .map(|(_, id)| *id) + .unwrap_or(self.root); + let entries = self.dir_entries(current_dir)?; + if let Some(&child_id) = entries.get(name) { + self.cwd_path.push((name.to_string(), child_id)); + } + } + } + } + + self.cwd = target; + Ok(()) + } + + pub fn mkdir(&mut self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + let (parent_id, name) = self.resolve_parent(path)?; + + let entries = self.dir_entries(parent_id)?; + if entries.contains_key(&name) { + return Err(VfsError::AlreadyExists { + path: path.to_string(), + }); + } + + let effective_gid = self.effective_gid(parent_id, gid); + let new_id = self.alloc_id(); + let mut new_dir = Inode::new_dir(new_id, uid, effective_gid); + + // Inherit setgid bit from parent + if let Ok(parent) = self.get_inode(parent_id) { + if has_setgid(parent.mode) { + new_dir.mode |= 0o2000; + } + } + + self.inodes.insert(new_id, new_dir); + self.dir_entries_mut(parent_id)?.insert(name, new_id); + let parent = self.get_inode_mut(parent_id)?; + parent.nlink += 1; + parent.touch_change(); + Ok(()) + } + + pub fn mkdir_p(&mut self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + let (start, components) = self.parse_path(path); + let mut current = start; + for component in &components { + match component.as_str() { + "." => {} + ".." => { + current = self.parent_of(current); + } + name => { + let entries = self.dir_entries(current)?; + if let Some(&existing) = entries.get(name) { + if !self.get_inode(existing)?.is_dir() { + return Err(VfsError::NotDirectory { + path: name.to_string(), + }); + } + current = existing; + } else { + let effective_gid = self.effective_gid(current, gid); + let new_id = self.alloc_id(); + let new_dir = Inode::new_dir(new_id, uid, effective_gid); + self.inodes.insert(new_id, new_dir); + self.dir_entries_mut(current)? + .insert(name.to_string(), new_id); + let parent = self.get_inode_mut(current)?; + parent.nlink += 1; + parent.touch_change(); + current = new_id; + } + } + } + } + Ok(()) + } + + pub fn touch(&mut self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + self.validate_regular_path(path)?; + + if let Ok(id) = self.resolve_path(path) { + let inode = self.get_inode_mut(id)?; + inode.touch_modify(); + return Ok(()); + } + + let (parent_id, name) = self.resolve_parent(path)?; + let effective_gid = self.effective_gid(parent_id, gid); + let new_id = self.alloc_id(); + let new_file = Inode::new_file(new_id, uid, effective_gid); + self.inodes.insert(new_id, new_file); + self.dir_entries_mut(parent_id)?.insert(name, new_id); + Ok(()) + } + + pub fn create_file( + &mut self, + path: &str, + uid: Uid, + gid: Gid, + mode: Option, + ) -> Result { + self.validate_regular_path(path)?; + if self.resolve_path(path).is_ok() { + return Err(VfsError::AlreadyExists { + path: path.to_string(), + }); + } + + let (parent_id, name) = self.resolve_parent(path)?; + let effective_gid = self.effective_gid(parent_id, gid); + let new_id = self.alloc_id(); + let mut new_file = Inode::new_file(new_id, uid, effective_gid); + if let Some(mode) = mode { + new_file.mode = mode; + } + self.inodes.insert(new_id, new_file); + self.dir_entries_mut(parent_id)?.insert(name, new_id); + Ok(new_id) + } + + pub fn cat(&self, path: &str) -> Result<&[u8], VfsError> { + let id = self.resolve_path(path)?; + let inode = self.get_inode(id)?; + match &inode.kind { + InodeKind::File { content } => Ok(content), + InodeKind::Directory { .. } => Err(VfsError::IsDirectory { + path: path.to_string(), + }), + InodeKind::Symlink { target } => self.cat(target), + } + } + + pub fn cat_owned(&self, path: &str) -> Result, VfsError> { + self.cat(path).map(|b| b.to_vec()) + } + + pub fn write_file(&mut self, path: &str, content: Vec) -> Result<(), VfsError> { + self.validate_regular_path(path)?; + let id = self.resolve_path(path)?; + let inode = self.get_inode_mut(id)?; + match &mut inode.kind { + InodeKind::File { content: c, .. } => { + *c = content; + inode.touch_modify(); + Ok(()) + } + InodeKind::Directory { .. } => Err(VfsError::IsDirectory { + path: path.to_string(), + }), + InodeKind::Symlink { target } => { + let target = target.clone(); + self.write_file(&target, content) + } + } + } + + pub fn read_file_at( + &mut self, + path: &str, + offset: usize, + size: usize, + ) -> Result, VfsError> { + let id = self.resolve_path(path)?; + self.read_inode_at(id, offset, size) + } + + pub fn write_file_at( + &mut self, + path: &str, + offset: usize, + data: &[u8], + ) -> Result { + self.validate_regular_path(path)?; + let id = self.resolve_path(path)?; + let inode = self.get_inode_mut(id)?; + match &mut inode.kind { + InodeKind::File { content } => { + if offset > content.len() { + content.resize(offset, 0); + } + let end = offset.saturating_add(data.len()); + if end > content.len() { + content.resize(end, 0); + } + content[offset..end].copy_from_slice(data); + inode.touch_modify(); + Ok(data.len()) + } + InodeKind::Directory { .. } => Err(VfsError::IsDirectory { + path: path.to_string(), + }), + InodeKind::Symlink { target } => { + let target = target.clone(); + self.write_file_at(&target, offset, data) + } + } + } + + pub fn truncate(&mut self, path: &str, size: usize) -> Result<(), VfsError> { + let id = self.resolve_path(path)?; + let inode = self.get_inode_mut(id)?; + match &mut inode.kind { + InodeKind::File { content } => { + content.resize(size, 0); + inode.touch_modify(); + Ok(()) + } + InodeKind::Directory { .. } => Err(VfsError::IsDirectory { + path: path.to_string(), + }), + InodeKind::Symlink { target } => { + let target = target.clone(); + self.truncate(&target, size) + } + } + } + + pub fn rm(&mut self, path: &str) -> Result<(), VfsError> { + let id = self.resolve_path(path)?; + let inode = self.get_inode(id)?; + if inode.is_dir() { + return Err(VfsError::IsDirectory { + path: path.to_string(), + }); + } + + let (parent_id, name) = self.resolve_parent(path)?; + self.dir_entries_mut(parent_id)?.remove(&name); + let inode = self.get_inode_mut(id)?; + inode.nlink = inode.nlink.saturating_sub(1); + inode.touch_change(); + self.unlink_inode_if_needed(id); + Ok(()) + } + + pub fn rmdir(&mut self, path: &str) -> Result<(), VfsError> { + let id = self.resolve_path(path)?; + let inode = self.get_inode(id)?; + match &inode.kind { + InodeKind::Directory { entries } => { + if !entries.is_empty() { + return Err(VfsError::NotEmpty { + path: path.to_string(), + }); + } + } + _ => { + return Err(VfsError::NotDirectory { + path: path.to_string(), + }) + } + } + + if id == self.root { + return Err(VfsError::InvalidPath { + path: "cannot remove root".to_string(), + }); + } + + let (parent_id, name) = self.resolve_parent(path)?; + self.dir_entries_mut(parent_id)?.remove(&name); + let parent = self.get_inode_mut(parent_id)?; + parent.nlink = parent.nlink.saturating_sub(1); + parent.touch_change(); + self.inodes.remove(&id); + Ok(()) + } + + pub fn rm_rf(&mut self, path: &str) -> Result<(), VfsError> { + let id = self.resolve_path(path)?; + if id == self.root { + return Err(VfsError::InvalidPath { + path: "cannot remove root".to_string(), + }); + } + + let (parent_id, name) = self.resolve_parent(path)?; + self.dir_entries_mut(parent_id)?.remove(&name); + self.remove_inode_recursive(id)?; + let parent = self.get_inode_mut(parent_id)?; + parent.touch_change(); + Ok(()) + } + + fn remove_inode_recursive(&mut self, id: InodeId) -> Result<(), VfsError> { + let child_entries = match &self.get_inode(id)?.kind { + InodeKind::Directory { entries } => entries.values().copied().collect::>(), + _ => Vec::new(), + }; + + for child_id in child_entries { + self.remove_inode_recursive(child_id)?; + } + + let nlink = self.get_inode(id)?.nlink; + if nlink > 0 { + let inode = self.get_inode_mut(id)?; + inode.nlink = inode.nlink.saturating_sub(1); + inode.touch_change(); + } + self.unlink_inode_if_needed(id); + Ok(()) + } + + pub fn mv(&mut self, src: &str, dst: &str) -> Result<(), VfsError> { + self.rename(src, dst) + } + + pub fn rename(&mut self, src: &str, dst: &str) -> Result<(), VfsError> { + let src_id = self.resolve_path(src)?; + if src_id == self.root { + return Err(VfsError::InvalidPath { + path: "cannot rename root".to_string(), + }); + } + + if self.get_inode(src_id)?.is_file() { + let dst_name = dst.rsplit('/').next().unwrap_or(dst); + self.validate_regular_path(dst_name)?; + } + + let (src_parent, src_name) = self.resolve_parent(src)?; + + if let Ok(dst_id) = self.resolve_path(dst) { + if self.get_inode(dst_id)?.is_dir() { + self.dir_entries_mut(src_parent)?.remove(&src_name); + self.dir_entries_mut(dst_id)?.insert(src_name, src_id); + let inode = self.get_inode_mut(src_id)?; + inode.touch_change(); + return Ok(()); + } else { + self.rm(dst)?; + } + } + + let (dst_parent, dst_name) = self.resolve_parent(dst)?; + self.dir_entries_mut(src_parent)?.remove(&src_name); + self.dir_entries_mut(dst_parent)?.insert(dst_name, src_id); + let inode = self.get_inode_mut(src_id)?; + inode.touch_change(); + Ok(()) + } + + pub fn cp(&mut self, src: &str, dst: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + let src_id = self.resolve_path(src)?; + let src_inode = self.get_inode(src_id)?.clone(); + + if src_inode.is_dir() { + return Err(VfsError::IsDirectory { + path: src.to_string(), + }); + } + + let dst_name = dst.rsplit('/').next().unwrap_or(dst); + if src_inode.is_file() { + self.validate_regular_path(dst_name)?; + } + + let (parent_id, name) = if let Ok(dst_id) = self.resolve_path(dst) { + if self.get_inode(dst_id)?.is_dir() { + let src_name = src.rsplit('/').next().unwrap_or(src); + (dst_id, src_name.to_string()) + } else { + self.resolve_parent(dst)? + } + } else { + self.resolve_parent(dst)? + }; + + let new_id = self.alloc_id(); + let mut new_inode = src_inode; + new_inode.id = new_id; + new_inode.uid = uid; // Copy is owned by the caller + new_inode.gid = self.effective_gid(parent_id, gid); + new_inode.nlink = 1; + new_inode.touch_change(); + self.inodes.insert(new_id, new_inode); + self.dir_entries_mut(parent_id)?.insert(name, new_id); + Ok(()) + } + + pub fn stat(&self, path: &str) -> Result { + let id = self.resolve_path(path)?; + let inode = self.get_inode(id)?; + Ok(StatInfo { + inode_id: id, + kind: match &inode.kind { + InodeKind::File { .. } => "file", + InodeKind::Directory { .. } => "directory", + InodeKind::Symlink { .. } => "symlink", + }, + size: inode.size(), + mode: inode.mode, + uid: inode.uid, + gid: inode.gid, + nlink: inode.nlink, + block_size: inode.block_size, + blocks: inode.blocks(), + created: inode.created_at.secs, + modified: inode.modified_at.secs, + accessed: inode.accessed_at.secs, + changed: inode.changed_at.secs, + created_nanos: inode.created_at.nanos, + modified_nanos: inode.modified_at.nanos, + accessed_nanos: inode.accessed_at.nanos, + changed_nanos: inode.changed_at.nanos, + }) + } + + pub fn chmod(&mut self, path: &str, mode: u16) -> Result<(), VfsError> { + let id = self.resolve_path(path)?; + let inode = self.get_inode_mut(id)?; + inode.mode = mode; + inode.touch_change(); + Ok(()) + } + + pub fn chown(&mut self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + let id = self.resolve_path(path)?; + let inode = self.get_inode_mut(id)?; + inode.uid = uid; + inode.gid = gid; + inode.touch_change(); + Ok(()) + } + + pub fn ln_s(&mut self, target: &str, link_path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { + let (parent_id, name) = self.resolve_parent(link_path)?; + let entries = self.dir_entries(parent_id)?; + if entries.contains_key(&name) { + return Err(VfsError::AlreadyExists { + path: link_path.to_string(), + }); + } + + let effective_gid = self.effective_gid(parent_id, gid); + let new_id = self.alloc_id(); + let symlink = Inode::new_symlink(new_id, target.to_string(), uid, effective_gid); + self.inodes.insert(new_id, symlink); + self.dir_entries_mut(parent_id)?.insert(name, new_id); + Ok(()) + } + + pub fn link(&mut self, target: &str, link_path: &str) -> Result<(), VfsError> { + let target_id = self.resolve_path(target)?; + if self.get_inode(target_id)?.is_dir() { + return Err(VfsError::NotSupported { + message: "hard links to directories".to_string(), + }); + } + let (parent_id, name) = self.resolve_parent(link_path)?; + if self.dir_entries(parent_id)?.contains_key(&name) { + return Err(VfsError::AlreadyExists { + path: link_path.to_string(), + }); + } + self.dir_entries_mut(parent_id)?.insert(name, target_id); + let inode = self.get_inode_mut(target_id)?; + inode.nlink += 1; + inode.touch_change(); + Ok(()) + } + + pub fn readlink(&self, path: &str) -> Result { + let id = self.resolve_path(path)?; + let inode = self.get_inode(id)?; + match &inode.kind { + InodeKind::Symlink { target } => Ok(target.clone()), + _ => Err(VfsError::InvalidArgs { + message: format!("not a symlink: {path}"), + }), + } + } + + pub fn open(&mut self, path: &str, writable: bool) -> Result { + let inode_id = self.resolve_path(path)?; + let directory = self.get_inode(inode_id)?.is_dir(); + let handle_id = self.alloc_handle_id(); + self.handles.insert( + handle_id, + OpenHandle { + inode_id, + cursor: 0, + writable, + directory, + }, + ); + self.mark_accessed(inode_id)?; + Ok(handle_id) + } + + pub fn opendir(&mut self, path: &str) -> Result { + let inode_id = self.resolve_path(path)?; + if !self.get_inode(inode_id)?.is_dir() { + return Err(VfsError::NotDirectory { + path: path.to_string(), + }); + } + self.open(path, false) + } + + pub fn release_handle(&mut self, handle: HandleId) -> Result<(), VfsError> { + let open = self + .handles + .remove(&handle) + .ok_or(VfsError::InvalidHandle { handle })?; + self.release_orphan_if_needed(open.inode_id); + Ok(()) + } + + pub fn read_handle(&mut self, handle: HandleId, size: usize) -> Result, VfsError> { + let (inode_id, cursor) = { + let open = self + .handles + .get(&handle) + .ok_or(VfsError::InvalidHandle { handle })?; + if open.directory { + return Err(VfsError::IsDirectory { + path: format!(""), + }); + } + (open.inode_id, open.cursor) + }; + + let data = self.read_inode_at(inode_id, cursor, size)?; + if let Some(open) = self.handles.get_mut(&handle) { + open.cursor = open.cursor.saturating_add(data.len()); + } + Ok(data) + } + + pub fn write_handle(&mut self, handle: HandleId, data: &[u8]) -> Result { + let (inode_id, cursor, writable) = { + let open = self + .handles + .get(&handle) + .ok_or(VfsError::InvalidHandle { handle })?; + (open.inode_id, open.cursor, open.writable) + }; + if !writable { + return Err(VfsError::PermissionDenied { + path: format!(""), + }); + } + let written = self.write_inode_at(inode_id, cursor, data)?; + if let Some(open) = self.handles.get_mut(&handle) { + open.cursor = open.cursor.saturating_add(written); + } + Ok(written) + } + + fn write_inode_at( + &mut self, + inode_id: InodeId, + offset: usize, + data: &[u8], + ) -> Result { + let inode = self.get_inode_mut(inode_id)?; + match &mut inode.kind { + InodeKind::File { content } => { + if offset > content.len() { + content.resize(offset, 0); + } + let end = offset.saturating_add(data.len()); + if end > content.len() { + content.resize(end, 0); + } + content[offset..end].copy_from_slice(data); + inode.touch_modify(); + Ok(data.len()) + } + InodeKind::Directory { .. } => Err(VfsError::IsDirectory { + path: format!(""), + }), + InodeKind::Symlink { target } => { + let target = target.clone(); + self.write_file_at(&target, offset, data) + } + } + } + + fn read_inode_at( + &mut self, + inode_id: InodeId, + offset: usize, + size: usize, + ) -> Result, VfsError> { + let inode = self.get_inode_mut(inode_id)?; + match &mut inode.kind { + InodeKind::File { content } => { + let start = offset.min(content.len()); + let end = start.saturating_add(size).min(content.len()); + let out = content[start..end].to_vec(); + inode.touch_access(); + Ok(out) + } + InodeKind::Directory { .. } => Err(VfsError::IsDirectory { + path: format!(""), + }), + InodeKind::Symlink { target } => { + let target = target.clone(); + self.read_file_at(&target, offset, size) + } + } + } + + pub fn tree( + &self, + path: Option<&str>, + prefix: &str, + session: Option<&Session>, + ) -> Result { + let id = match path { + Some(p) => self.resolve_path(p)?, + None => self.cwd, + }; + let mut output = String::new(); + if prefix.is_empty() { + output.push_str(".\n"); + } + self.tree_recursive(id, prefix, &mut output, session)?; + Ok(output) + } + + fn tree_recursive( + &self, + id: InodeId, + prefix: &str, + output: &mut String, + session: Option<&Session>, + ) -> Result<(), VfsError> { + let entries = self.dir_entries(id)?; + // Filter entries by read permission + let visible: Vec<_> = entries + .iter() + .filter(|&(_, child_id)| self.is_visible(*child_id, session)) + .collect(); + let count = visible.len(); + for (i, (name, child_id)) in visible.iter().enumerate() { + let is_last = i == count - 1; + let connector = if is_last { + "\u{2514}\u{2500}\u{2500} " + } else { + "\u{251c}\u{2500}\u{2500} " + }; + let child = self.get_inode(**child_id)?; + + output.push_str(prefix); + output.push_str(connector); + output.push_str(name); + if child.is_dir() { + output.push('/'); + } + output.push('\n'); + + if child.is_dir() { + let new_prefix = if is_last { + format!("{prefix} ") + } else { + format!("{prefix}\u{2502} ") + }; + self.tree_recursive(**child_id, &new_prefix, output, session)?; + } + } + Ok(()) + } + + pub fn find( + &self, + path: Option<&str>, + pattern: Option<&str>, + session: Option<&Session>, + ) -> Result, VfsError> { + let id = match path { + Some(p) => self.resolve_path(p)?, + None => self.cwd, + }; + let base = match path { + Some(p) => p.to_string(), + None => ".".to_string(), + }; + let mut results = Vec::new(); + self.find_recursive(id, &base, pattern, &mut results, session)?; + Ok(results) + } + + fn find_recursive( + &self, + id: InodeId, + current_path: &str, + pattern: Option<&str>, + results: &mut Vec, + session: Option<&Session>, + ) -> Result<(), VfsError> { + let entries = self.dir_entries(id)?; + for (name, &child_id) in entries { + if !self.is_visible(child_id, session) { + continue; + } + + let child_path = if current_path == "." { + format!("./{name}") + } else { + format!("{current_path}/{name}") + }; + let child = self.get_inode(child_id)?; + + let matches = match pattern { + Some(pat) => glob_match(pat, name), + None => true, + }; + + if matches { + results.push(child_path.clone()); + } + + if child.is_dir() { + self.find_recursive(child_id, &child_path, pattern, results, session)?; + } + } + Ok(()) + } + + pub fn grep( + &self, + pattern: &str, + path: Option<&str>, + recursive: bool, + session: Option<&Session>, + ) -> Result, VfsError> { + let re = regex::Regex::new(pattern).map_err(|e| VfsError::InvalidArgs { + message: format!("invalid regex: {e}"), + })?; + + let id = match path { + Some(p) => self.resolve_path(p)?, + None => self.cwd, + }; + let base = path.unwrap_or("."); + let mut results = Vec::new(); + let inode = self.get_inode(id)?; + + match &inode.kind { + InodeKind::File { content } => { + let text = String::from_utf8_lossy(content); + for (line_num, line) in text.lines().enumerate() { + if re.is_match(line) { + results.push(GrepResult { + file: base.to_string(), + line_num: line_num + 1, + line: line.to_string(), + }); + } + } + } + InodeKind::Directory { .. } if recursive => { + self.grep_recursive(id, base, &re, &mut results, session)?; + } + InodeKind::Directory { .. } => { + return Err(VfsError::IsDirectory { + path: base.to_string(), + }); + } + _ => {} + } + Ok(results) + } + + fn grep_recursive( + &self, + id: InodeId, + base: &str, + re: ®ex::Regex, + results: &mut Vec, + session: Option<&Session>, + ) -> Result<(), VfsError> { + let entries = self.dir_entries(id)?; + for (name, &child_id) in entries { + if !self.is_visible(child_id, session) { + continue; + } + + let child_path = if base == "." { + format!("./{name}") + } else { + format!("{base}/{name}") + }; + let child = self.get_inode(child_id)?; + match &child.kind { + InodeKind::File { content } => { + let text = String::from_utf8_lossy(content); + for (line_num, line) in text.lines().enumerate() { + if re.is_match(line) { + results.push(GrepResult { + file: child_path.clone(), + line_num: line_num + 1, + line: line.to_string(), + }); + } + } + } + InodeKind::Directory { .. } => { + self.grep_recursive(child_id, &child_path, re, results, session)?; + } + _ => {} + } + } + Ok(()) + } + + /// Check if a user can see this inode (has read permission). + /// If session is None, everything is visible (internal/root use). + /// Respects delegation: both principal and delegate must have read access. + fn is_visible(&self, id: InodeId, session: Option<&Session>) -> bool { + let session = match session { + Some(s) => s, + None => return true, + }; + let inode = match self.get_inode(id) { + Ok(i) => i, + Err(_) => return false, + }; + session.has_permission(inode, Access::Read) + } + + pub fn all_inodes(&self) -> &HashMap { + &self.inodes + } + + pub fn root_id(&self) -> InodeId { + self.root + } + + pub fn cwd_id(&self) -> InodeId { + self.cwd + } + + pub fn next_inode_id(&self) -> InodeId { + self.next_id + } + + pub fn cwd_path_clone(&self) -> Vec<(String, InodeId)> { + self.cwd_path.clone() + } + + pub fn from_persisted( + inodes: HashMap, + root: InodeId, + cwd: InodeId, + next_id: InodeId, + cwd_path: Vec<(String, InodeId)>, + options: FsOptions, + registry: UserRegistry, + ) -> Self { + VirtualFs { + inodes, + root, + cwd, + next_id, + cwd_path, + options, + next_handle: 1, + handles: HashMap::new(), + pending_delete: HashSet::new(), + registry, + } + } +} + +fn glob_match(pattern: &str, name: &str) -> bool { + let pat = pattern + .replace('.', "\\.") + .replace('*', ".*") + .replace('?', "."); + regex::Regex::new(&format!("^{pat}$")) + .map(|re| re.is_match(name)) + .unwrap_or(false) +} + +fn validate_markdown_filename(path: &str) -> Result<(), VfsError> { + let filename = path.rsplit('/').next().unwrap_or(path); + if !filename.ends_with(".md") { + return Err(VfsError::InvalidExtension { + name: filename.to_string(), + }); + } + Ok(()) +} + +#[derive(Debug)] +pub struct LsEntry { + pub name: String, + pub is_dir: bool, + pub is_symlink: bool, + pub size: u64, + pub mode: u16, + pub uid: u32, + pub gid: u32, + pub modified: u64, +} + +#[derive(Debug, Clone)] +pub struct StatInfo { + pub inode_id: InodeId, + pub kind: &'static str, + pub size: u64, + pub mode: u16, + pub uid: u32, + pub gid: u32, + pub nlink: u64, + pub block_size: u64, + pub blocks: u64, + pub created: u64, + pub modified: u64, + pub accessed: u64, + pub changed: u64, + pub created_nanos: u32, + pub modified_nanos: u32, + pub accessed_nanos: u32, + pub changed_nanos: u32, +} + +#[derive(Debug, Clone)] +pub struct GrepResult { + pub file: String, + pub line_num: usize, + pub line: String, +} diff --git a/src/fuse_mount.rs b/src/fuse_mount.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb208266c7fcd4d3cc86da738884568ef230c588 --- /dev/null +++ b/src/fuse_mount.rs @@ -0,0 +1,528 @@ +#![cfg(feature = "fuser")] + +use crate::auth::session::Session; +use crate::fs::inode::InodeKind; +use crate::fs::VirtualFs; +use crate::posix::{PosixFs, PosixSetAttr}; +use fuser::{ + BsdFileFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, + Generation, INodeNo, KernelConfig, MountOption, OpenAccMode, OpenFlags, RenameFlags, + ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, + ReplyWrite, Request, TimeOrNow, WriteFlags, +}; +use std::ffi::OsStr; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const TTL: Duration = Duration::from_secs(1); + +pub struct MarkdownFsFuse { + fs: Arc>, +} + +impl MarkdownFsFuse { + pub fn new(fs: Arc>) -> Self { + Self { fs } + } + + pub fn mount_config(read_only: bool) -> Config { + let mut mount_options = vec![ + MountOption::FSName("markdownfs".to_string()), + MountOption::Subtype("markdownfs".to_string()), + MountOption::DefaultPermissions, + ]; + if read_only { + mount_options.push(MountOption::RO); + } else { + mount_options.push(MountOption::RW); + } + let mut config = Config::default(); + config.mount_options = mount_options; + config + } + + fn session_for(req: &Request) -> Session { + Session::new(req.uid(), req.gid(), vec![req.gid()], format!("uid-{}", req.uid())) + } + + fn path_for_inode(fs: &VirtualFs, ino: INodeNo) -> Option { + if ino.0 == fs.root_id() { + return Some("/".to_string()); + } + fn walk( + fs: &VirtualFs, + current: u64, + current_path: &str, + target: INodeNo, + ) -> Option { + let inode = fs.get_inode(current).ok()?; + let InodeKind::Directory { entries } = &inode.kind else { + return None; + }; + + for (name, child) in entries { + let child_path = if current_path == "/" { + format!("/{name}") + } else { + format!("{current_path}/{name}") + }; + if *child == target.0 { + return Some(child_path); + } + if let Some(found) = walk(fs, *child, &child_path, target) { + return Some(found); + } + } + None + } + + walk(fs, fs.root_id(), "/", ino) + } + + fn child_path(fs: &VirtualFs, parent: INodeNo, name: &OsStr) -> Option { + let parent_path = Self::path_for_inode(fs, parent)?; + let name = name.to_str()?; + Some(if parent_path == "/" { + format!("/{name}") + } else { + format!("{parent_path}/{name}") + }) + } + +} + +impl Filesystem for MarkdownFsFuse { + fn init(&mut self, _req: &Request, _config: &mut KernelConfig) -> io::Result<()> { + Ok(()) + } + + fn lookup(&self, req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::child_path(&guard, parent, name) else { + reply.error(Errno::ENOENT); + return; + }; + let session = Self::session_for(req); + let posix = PosixFs::new(&mut guard, &session); + match posix.lookup(&path).map(|stat| stat_to_attr(&stat)) { + Ok(attr) => reply.entry(&TTL, &attr, Generation(0)), + Err(err) => reply.error(map_error(err)), + } + } + + fn getattr(&self, req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::path_for_inode(&guard, ino) else { + reply.error(Errno::ENOENT); + return; + }; + let session = Self::session_for(req); + let posix = PosixFs::new(&mut guard, &session); + match posix.getattr(&path).map(|stat| stat_to_attr(&stat)) { + Ok(attr) => reply.attr(&TTL, &attr), + Err(err) => reply.error(map_error(err)), + } + } + + fn readlink(&self, req: &Request, ino: INodeNo, reply: ReplyData) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::path_for_inode(&guard, ino) else { + reply.error(Errno::ENOENT); + return; + }; + let session = Self::session_for(req); + let posix = PosixFs::new(&mut guard, &session); + match posix.readlink(&path) { + Ok(target) => reply.data(target.as_bytes()), + Err(err) => reply.error(map_error(err)), + } + } + + fn mkdir( + &self, + req: &Request, + parent: INodeNo, + name: &OsStr, + mode: u32, + _umask: u32, + reply: ReplyEntry, + ) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::child_path(&guard, parent, name) else { + reply.error(Errno::EINVAL); + return; + }; + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix + .mkdir(&path, mode as u16) + .and_then(|_| posix.getattr(&path)) + .map(|stat| stat_to_attr(&stat)) + { + Ok(attr) => reply.entry(&TTL, &attr, Generation(0)), + Err(err) => reply.error(map_error(err)), + } + } + + fn create( + &self, + req: &Request, + parent: INodeNo, + name: &OsStr, + mode: u32, + _umask: u32, + _flags: i32, + reply: ReplyCreate, + ) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::child_path(&guard, parent, name) else { + reply.error(Errno::EINVAL); + return; + }; + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix.create(&path, mode as u16).and_then(|_| posix.getattr(&path)) { + Ok(stat) => reply.created( + &TTL, + &stat_to_attr(&stat), + Generation(0), + FileHandle(0), + FopenFlags::empty(), + ), + Err(err) => reply.error(map_error(err)), + } + } + + fn open(&self, req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::path_for_inode(&guard, ino) else { + reply.error(Errno::ENOENT); + return; + }; + let writable = matches!(flags.acc_mode(), OpenAccMode::O_WRONLY | OpenAccMode::O_RDWR); + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix.open(&path, writable) { + Ok(_fh) => reply.opened(FileHandle(0), FopenFlags::empty()), + Err(err) => reply.error(map_error(err)), + } + } + + fn read( + &self, + req: &Request, + ino: INodeNo, + _fh: FileHandle, + offset: u64, + size: u32, + _flags: OpenFlags, + _lock_owner: Option, + reply: ReplyData, + ) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::path_for_inode(&guard, ino) else { + reply.error(Errno::ENOENT); + return; + }; + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix.read(&path, offset as usize, size as usize) { + Ok(data) => reply.data(&data), + Err(err) => reply.error(map_error(err)), + } + } + + fn write( + &self, + req: &Request, + ino: INodeNo, + _fh: FileHandle, + offset: u64, + data: &[u8], + _write_flags: WriteFlags, + _flags: OpenFlags, + _lock_owner: Option, + reply: ReplyWrite, + ) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::path_for_inode(&guard, ino) else { + reply.error(Errno::ENOENT); + return; + }; + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix.write(&path, offset as usize, data) { + Ok(written) => reply.written(written as u32), + Err(err) => reply.error(map_error(err)), + } + } + + fn release( + &self, + _req: &Request, + _ino: INodeNo, + _fh: FileHandle, + _flags: OpenFlags, + _lock_owner: Option, + _flush: bool, + reply: ReplyEmpty, + ) { + reply.ok(); + } + + fn unlink(&self, req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::child_path(&guard, parent, name) else { + reply.error(Errno::EINVAL); + return; + }; + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix.unlink(&path) { + Ok(()) => reply.ok(), + Err(err) => reply.error(map_error(err)), + } + } + + fn rmdir(&self, req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::child_path(&guard, parent, name) else { + reply.error(Errno::EINVAL); + return; + }; + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix.rmdir(&path) { + Ok(()) => reply.ok(), + Err(err) => reply.error(map_error(err)), + } + } + + fn rename( + &self, + req: &Request, + parent: INodeNo, + name: &OsStr, + newparent: INodeNo, + newname: &OsStr, + _flags: RenameFlags, + reply: ReplyEmpty, + ) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(src) = Self::child_path(&guard, parent, name) else { + reply.error(Errno::EINVAL); + return; + }; + let Some(dst) = Self::child_path(&guard, newparent, newname) else { + reply.error(Errno::EINVAL); + return; + }; + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix.rename(&src, &dst) { + Ok(()) => reply.ok(), + Err(err) => reply.error(map_error(err)), + } + } + + fn symlink( + &self, + req: &Request, + parent: INodeNo, + link_name: &OsStr, + target: &Path, + reply: ReplyEntry, + ) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::child_path(&guard, parent, link_name) else { + reply.error(Errno::EINVAL); + return; + }; + let target = target.to_string_lossy().into_owned(); + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix + .symlink(&target, &path) + .and_then(|_| posix.getattr(&path)) + .map(|stat| stat_to_attr(&stat)) + { + Ok(attr) => reply.entry(&TTL, &attr, Generation(0)), + Err(err) => reply.error(map_error(err)), + } + } + + fn link( + &self, + req: &Request, + ino: INodeNo, + newparent: INodeNo, + newname: &OsStr, + reply: ReplyEntry, + ) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(target) = Self::path_for_inode(&guard, ino) else { + reply.error(Errno::ENOENT); + return; + }; + let Some(path) = Self::child_path(&guard, newparent, newname) else { + reply.error(Errno::EINVAL); + return; + }; + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix + .link(&target, &path) + .and_then(|_| posix.getattr(&path)) + .map(|stat| stat_to_attr(&stat)) + { + Ok(attr) => reply.entry(&TTL, &attr, Generation(0)), + Err(err) => reply.error(map_error(err)), + } + } + + fn setattr( + &self, + req: &Request, + ino: INodeNo, + 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, + ) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::path_for_inode(&guard, ino) else { + reply.error(Errno::ENOENT); + return; + }; + let session = Self::session_for(req); + let mut posix = PosixFs::new(&mut guard, &session); + match posix + .setattr( + &path, + PosixSetAttr { + mode: mode.map(|mode| mode as u16), + uid, + gid, + size: size.map(|size| size as usize), + }, + ) + .map(|stat| stat_to_attr(&stat)) + { + Ok(attr) => reply.attr(&TTL, &attr), + Err(err) => reply.error(map_error(err)), + } + } + + fn readdir( + &self, + req: &Request, + ino: INodeNo, + _fh: FileHandle, + offset: u64, + mut reply: ReplyDirectory, + ) { + let mut guard = self.fs.lock().expect("fuse mutex poisoned"); + let Some(path) = Self::path_for_inode(&guard, ino) else { + reply.error(Errno::ENOENT); + return; + }; + let session = Self::session_for(req); + let posix = PosixFs::new(&mut guard, &session); + match posix.readdir(&path) { + Ok(entries) => { + if offset == 0 { + let _ = reply.add(ino, 1, FileType::Directory, "."); + let parent_ino = if path == "/" { + ino + } else { + let parent_path = Path::new(&path) + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| "/".to_string()); + let parent_path = if parent_path.is_empty() { + "/".to_string() + } else { + parent_path + }; + guard + .stat(&parent_path) + .map(|stat| INodeNo(stat.inode_id)) + .unwrap_or(ino) + }; + let _ = reply.add(parent_ino, 2, FileType::Directory, ".."); + } + let start = offset.saturating_sub(2) as usize; + for (entry_index, entry) in entries.into_iter().enumerate().skip(start) { + let full = (entry_index + 3) as u64; + let full_type = file_type(entry.stat.kind); + if reply.add(INodeNo(entry.stat.inode_id), full, full_type, entry.name) { + break; + } + } + reply.ok(); + } + Err(err) => reply.error(map_error(err)), + } + } +} + +fn stat_to_attr(stat: &crate::fs::StatInfo) -> FileAttr { + let atime = UNIX_EPOCH + Duration::new(stat.accessed, stat.accessed_nanos); + let mtime = UNIX_EPOCH + Duration::new(stat.modified, stat.modified_nanos); + let ctime = UNIX_EPOCH + Duration::new(stat.changed, stat.changed_nanos); + let crtime = UNIX_EPOCH + Duration::new(stat.created, stat.created_nanos); + + FileAttr { + ino: INodeNo(stat.inode_id), + size: stat.size, + blocks: stat.blocks, + atime, + mtime, + ctime, + crtime, + kind: file_type(stat.kind), + perm: stat.mode, + nlink: stat.nlink as u32, + uid: stat.uid, + gid: stat.gid, + rdev: 0, + blksize: stat.block_size as u32, + flags: 0, + } +} + +fn file_type(kind: &str) -> FileType { + match kind { + "directory" => FileType::Directory, + "symlink" => FileType::Symlink, + _ => FileType::RegularFile, + } +} + +fn map_error(err: crate::error::VfsError) -> Errno { + match err { + crate::error::VfsError::NotFound { .. } => Errno::ENOENT, + crate::error::VfsError::PermissionDenied { .. } => Errno::EACCES, + crate::error::VfsError::AlreadyExists { .. } => Errno::EEXIST, + crate::error::VfsError::NotDirectory { .. } => Errno::ENOTDIR, + crate::error::VfsError::IsDirectory { .. } => Errno::EISDIR, + crate::error::VfsError::NotEmpty { .. } => Errno::ENOTEMPTY, + _ => Errno::EINVAL, + } +} + +pub fn mount(fs: Arc>, mountpoint: PathBuf, read_only: bool) -> Result<(), std::io::Error> { + let config = MarkdownFsFuse::mount_config(read_only); + fuser::mount2(MarkdownFsFuse::new(fs), mountpoint, &config) +} diff --git a/src/io/mmap_reader.rs b/src/io/mmap_reader.rs new file mode 100644 index 0000000000000000000000000000000000000000..49b3d36f0c9d163807e0672e42dd746a165b7cac --- /dev/null +++ b/src/io/mmap_reader.rs @@ -0,0 +1 @@ +// Memory-mapped reader — Phase 3 implementation. diff --git a/src/io/mod.rs b/src/io/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..2748791b0ab657c17f193528fb37edb10b7eed84 --- /dev/null +++ b/src/io/mod.rs @@ -0,0 +1,4 @@ +pub mod mmap_reader; +pub mod writer; + +// I/O module — will be fleshed out in Phase 3 with mmap-backed storage. diff --git a/src/io/writer.rs b/src/io/writer.rs new file mode 100644 index 0000000000000000000000000000000000000000..3c7ae6a54cbcf46d47e081f41600dbba75206542 --- /dev/null +++ b/src/io/writer.rs @@ -0,0 +1 @@ +// Buffered append writer — Phase 3 implementation. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..be8334c0b53a1545c944568fb55d1325f7cfb153 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,15 @@ +pub mod auth; +pub mod cmd; +pub mod config; +pub mod db; +pub mod error; +pub mod fs; +pub mod io; +pub mod persist; +pub mod posix; +pub mod server; +pub mod store; +pub mod vcs; + +#[cfg(feature = "fuser")] +pub mod fuse_mount; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..76594f07a626db588dd65640f942288b1f8bdf7b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,256 @@ +use markdownfs::auth::session::Session; +use markdownfs::config::{CompatibilityTarget, Config}; +use markdownfs::db::MarkdownDb; +use rustyline::error::ReadlineError; +use rustyline::DefaultEditor; + +#[tokio::main] +async fn main() { + let config = Config::from_env(); + let db = match MarkdownDb::open(config) { + Ok(db) => { + let commits = db.commit_count().await; + let objects = db.object_count().await; + if commits > 0 { + println!( + "markdownfs v{} — Loaded from disk ({commits} commits, {objects} objects)", + env!("CARGO_PKG_VERSION") + ); + } else { + println!( + "markdownfs v{} — Markdown Virtual File System", + env!("CARGO_PKG_VERSION") + ); + } + db + } + Err(e) => { + eprintln!("Warning: failed to load state: {e}"); + eprintln!("Starting fresh.\n"); + MarkdownDb::open_memory() + } + }; + + let _save_handle = db.spawn_auto_save(); + + let mut rl = DefaultEditor::new().expect("failed to initialize readline"); + let history_path = std::env::var("HOME") + .ok() + .map(|h| format!("{h}/.markdownfs_history")); + + if let Some(ref path) = history_path { + let _ = rl.load_history(path); + } + + let mut session = login_flow(&db, &mut rl).await; + println!("\nType 'help' for available commands, 'exit' to quit.\n"); + + loop { + let pwd = db.pwd().await; + let home_prefix = format!("/home/{}", session.username); + let display_pwd = if pwd == home_prefix { + "~".to_string() + } else if let Some(rest) = pwd.strip_prefix(&home_prefix) { + format!("~{rest}") + } else { + pwd + }; + let prompt = format!("{}@markdownfs:{display_pwd} $ ", session.username); + match rl.readline(&prompt) { + Ok(line) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let _ = rl.add_history_entry(line); + + match line { + "exit" | "quit" => break, + _ if line.starts_with("edit ") => { + handle_edit(line, &db, &mut rl, &session).await; + } + _ => match db.execute_command(line, &mut session).await { + Ok(output) => { + if !output.is_empty() { + print!("{output}"); + } + } + Err(e) => eprintln!("{e}"), + }, + } + } + Err(ReadlineError::Interrupted) => { + println!("^C"); + } + Err(ReadlineError::Eof) => break, + Err(e) => { + eprintln!("readline error: {e}"); + break; + } + } + } + + match db.save().await { + Ok(()) => { + let info = db.persist_info(); + match info.location { + Some(dir) => println!("State saved via {} to {}/", info.backend, dir.display()), + None => println!("State saved via {}", info.backend), + } + } + Err(e) => eprintln!("Warning: failed to save state: {e}"), + } + + if let Some(ref path) = history_path { + let _ = rl.save_history(path); + } + + println!("Goodbye!"); +} + +async fn login_flow(db: &MarkdownDb, rl: &mut DefaultEditor) -> Session { + let has_users = db.has_users().await; + + if !has_users { + println!("\nWelcome! Let's set up your account."); + loop { + match rl.readline("Admin username: ") { + Ok(name) => { + let name = name.trim().to_string(); + if name.is_empty() { + eprintln!("Username cannot be empty."); + continue; + } + match db.create_admin(&name).await { + Ok(session) => { + println!( + "\nCreated admin '{}' (uid={}, groups=[{}, wheel])", + session.username, session.uid, session.username + ); + println!("Home directory: /home/{}", session.username); + return session; + } + Err(e) => eprintln!("{e}"), + } + } + Err(ReadlineError::Interrupted | ReadlineError::Eof) => { + return Session::root(); + } + Err(e) => { + eprintln!("readline error: {e}"); + return Session::root(); + } + } + } + } else { + loop { + match rl.readline("Login as: ") { + Ok(name) => { + let name = name.trim().to_string(); + if name.is_empty() { + continue; + } + match db.login(&name).await { + Ok(session) => { + println!( + "Logged in as '{}' (uid={}, gid={})", + session.username, session.uid, session.gid + ); + return session; + } + Err(_) => eprintln!("Unknown user: {name}"), + } + } + Err(ReadlineError::Interrupted | ReadlineError::Eof) => { + return Session::root(); + } + Err(e) => { + eprintln!("readline error: {e}"); + return Session::root(); + } + } + } + } +} + +async fn handle_edit( + line: &str, + db: &MarkdownDb, + rl: &mut DefaultEditor, + session: &Session, +) { + let path = line.strip_prefix("edit ").unwrap().trim(); + + if path.is_empty() { + eprintln!("markdownfs: edit: missing file path"); + return; + } + + if db.config().compatibility_target != CompatibilityTarget::Posix && !path.ends_with(".md") { + eprintln!("markdownfs: only .md files are supported: '{path}'"); + return; + } + + if let Ok(content) = db.cat(path).await { + let text = String::from_utf8_lossy(&content); + if !text.is_empty() { + println!("--- Current content of {path} ---"); + for (i, line) in text.lines().enumerate() { + println!("{:>4} | {line}", i + 1); + } + println!("--- End ---"); + } + } + + println!("Enter new content (type EOF on a blank line to finish, CANCEL to abort):"); + + let mut content = String::new(); + let mut line_num = 1; + loop { + match rl.readline(&format!("{line_num:>4} | ")) { + Ok(input) => { + if input.trim() == "EOF" { + break; + } + if input.trim() == "CANCEL" { + println!("Edit cancelled."); + return; + } + content.push_str(&input); + content.push('\n'); + line_num += 1; + } + Err(ReadlineError::Interrupted) => { + println!("\nEdit cancelled."); + return; + } + Err(ReadlineError::Eof) => break, + Err(e) => { + eprintln!("readline error: {e}"); + return; + } + } + } + + if content.ends_with('\n') { + content.pop(); + } + + if db.stat(path).await.is_err() { + if let Err(e) = db.touch(path, session.uid, session.gid).await { + eprintln!("{e}"); + return; + } + } + + if let Err(e) = db.write_file(path, content.into_bytes()).await { + eprintln!("{e}"); + return; + } + + match db.commit(&format!("edit {path}"), &session.username).await { + Ok(hash) => println!("[{hash}] edit {path}"), + Err(e) => eprintln!("auto-commit failed: {e}"), + } +} diff --git a/src/persist.rs b/src/persist.rs new file mode 100644 index 0000000000000000000000000000000000000000..9de73934954da114b092480d63e68fddc38780a7 --- /dev/null +++ b/src/persist.rs @@ -0,0 +1,309 @@ +use crate::auth::registry::UserRegistry; +use crate::config::CompatibilityTarget; +use crate::error::VfsError; +use crate::fs::inode::{Inode, InodeId}; +use crate::fs::{FsOptions, VirtualFs}; +use crate::store::blob::BlobStore; +use crate::store::commit::CommitObject; +use crate::store::ObjectId; +use crate::vcs::Vcs; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +const VFS_DIR: &str = ".vfs"; +const STATE_FILE: &str = "state.bin"; +const VERSION: u32 = 3; + +/// Complete persisted state of the VFS + VCS. +#[derive(Serialize, Deserialize)] +struct PersistedState { + version: u32, + fs_state: FsState, + vcs_state: VcsState, +} + +#[derive(Serialize, Deserialize)] +struct FsState { + inodes: HashMap, + root: InodeId, + cwd: InodeId, + next_id: InodeId, + cwd_path: Vec<(String, InodeId)>, + compatibility_target: CompatibilityTarget, + registry: UserRegistry, +} + +#[derive(Serialize, Deserialize)] +struct VcsState { + /// All objects in the blob store: (ObjectId bytes, kind byte, data) + objects: Vec<(Vec, u8, Vec)>, + head: Option>, + commits: Vec, +} + +// ─── V1 legacy types for migration ─── + +#[derive(Deserialize)] +struct PersistedStateV1 { + version: u32, + fs_state: FsStateV1, + vcs_state: VcsState, +} + +#[derive(Deserialize)] +struct FsStateV1 { + inodes: HashMap, + root: InodeId, + cwd: InodeId, + next_id: InodeId, + cwd_path: Vec<(String, InodeId)>, + // No registry in V1 +} + +#[derive(Deserialize)] +struct PersistedStateV2 { + version: u32, + fs_state: FsStateV2, + vcs_state: VcsState, +} + +#[derive(Deserialize)] +struct FsStateV2 { + inodes: HashMap, + root: InodeId, + cwd: InodeId, + next_id: InodeId, + cwd_path: Vec<(String, InodeId)>, + registry: UserRegistry, +} + +pub struct PersistManager { + base_dir: PathBuf, +} + +pub struct LoadedState { + pub fs: VirtualFs, + pub vcs: Vcs, +} + +#[derive(Debug, Clone)] +pub struct PersistenceInfo { + pub backend: &'static str, + pub location: Option, +} + +pub trait PersistenceBackend: Send + Sync { + fn load(&self) -> Result, VfsError>; + fn save(&self, vfs: &VirtualFs, vcs: &Vcs) -> Result<(), VfsError>; + fn info(&self) -> PersistenceInfo; +} + +pub struct LocalStateBackend { + manager: PersistManager, +} + +impl LocalStateBackend { + pub fn new(base_dir: &Path) -> Self { + Self { + manager: PersistManager::new(base_dir), + } + } +} + +pub struct MemoryPersistenceBackend; + +impl PersistenceBackend for LocalStateBackend { + fn load(&self) -> Result, VfsError> { + if !self.manager.state_exists() { + return Ok(None); + } + let (fs, vcs) = self.manager.load()?; + Ok(Some(LoadedState { fs, vcs })) + } + + fn save(&self, vfs: &VirtualFs, vcs: &Vcs) -> Result<(), VfsError> { + self.manager.save(vfs, vcs) + } + + fn info(&self) -> PersistenceInfo { + PersistenceInfo { + backend: "local-state-file", + location: Some(self.manager.data_dir().to_path_buf()), + } + } +} + +impl PersistenceBackend for MemoryPersistenceBackend { + fn load(&self) -> Result, VfsError> { + Ok(None) + } + + fn save(&self, _vfs: &VirtualFs, _vcs: &Vcs) -> Result<(), VfsError> { + Ok(()) + } + + fn info(&self) -> PersistenceInfo { + PersistenceInfo { + backend: "memory", + location: None, + } + } +} + +impl PersistManager { + pub fn new(base_dir: &Path) -> Self { + PersistManager { + base_dir: base_dir.join(VFS_DIR), + } + } + + pub fn state_exists(&self) -> bool { + self.base_dir.join(STATE_FILE).exists() + } + + pub fn save(&self, vfs: &VirtualFs, vcs: &Vcs) -> Result<(), VfsError> { + fs::create_dir_all(&self.base_dir)?; + + let fs_state = FsState { + inodes: vfs.all_inodes().clone(), + root: vfs.root_id(), + cwd: vfs.cwd_id(), + next_id: vfs.next_inode_id(), + cwd_path: vfs.cwd_path_clone(), + compatibility_target: vfs.compatibility_target(), + registry: vfs.registry.clone(), + }; + + let vcs_state = VcsState { + objects: vcs.store.export_all(), + head: vcs.head.map(|id| id.as_bytes().to_vec()), + commits: vcs.commits.clone(), + }; + + let state = PersistedState { + version: VERSION, + fs_state, + vcs_state, + }; + + let data = bincode::serialize(&state).map_err(|e| VfsError::CorruptStore { + message: format!("serialization failed: {e}"), + })?; + + let tmp_path = self.base_dir.join("state.tmp"); + let final_path = self.base_dir.join(STATE_FILE); + + // Atomic write: write to tmp, then rename + fs::write(&tmp_path, &data)?; + fs::rename(&tmp_path, &final_path)?; + + Ok(()) + } + + pub fn load(&self) -> Result<(VirtualFs, Vcs), VfsError> { + let path = self.base_dir.join(STATE_FILE); + let data = fs::read(&path)?; + + // Try V3 first + if let Ok(state) = bincode::deserialize::(&data) { + if state.version == VERSION { + return Self::load_v3(state); + } + } + + // Try V2 migration + if let Ok(state) = bincode::deserialize::(&data) { + if state.version == 2 { + return Self::load_v2(state); + } + } + + // Try V1 migration + if let Ok(state) = bincode::deserialize::(&data) { + if state.version == 1 { + return Self::load_v1(state); + } + } + + Err(VfsError::CorruptStore { + message: "failed to deserialize state (unknown version or corrupt data)".to_string(), + }) + } + + fn load_v3(state: PersistedState) -> Result<(VirtualFs, Vcs), VfsError> { + let vfs = VirtualFs::from_persisted( + state.fs_state.inodes, + state.fs_state.root, + state.fs_state.cwd, + state.fs_state.next_id, + state.fs_state.cwd_path, + FsOptions { + compatibility_target: state.fs_state.compatibility_target, + }, + state.fs_state.registry, + ); + + let vcs = Self::load_vcs(state.vcs_state)?; + Ok((vfs, vcs)) + } + + fn load_v2(state: PersistedStateV2) -> Result<(VirtualFs, Vcs), VfsError> { + let vfs = VirtualFs::from_persisted( + state.fs_state.inodes, + state.fs_state.root, + state.fs_state.cwd, + state.fs_state.next_id, + state.fs_state.cwd_path, + FsOptions::default(), + state.fs_state.registry, + ); + + let vcs = Self::load_vcs(state.vcs_state)?; + Ok((vfs, vcs)) + } + + fn load_v1(state: PersistedStateV1) -> Result<(VirtualFs, Vcs), VfsError> { + // V1 inodes already have uid/gid=0 defaults from serde (they were added with defaults) + // Create a fresh registry (root user only) + let registry = UserRegistry::new(); + + let vfs = VirtualFs::from_persisted( + state.fs_state.inodes, + state.fs_state.root, + state.fs_state.cwd, + state.fs_state.next_id, + state.fs_state.cwd_path, + FsOptions::default(), + registry, + ); + + let vcs = Self::load_vcs(state.vcs_state)?; + Ok((vfs, vcs)) + } + + fn load_vcs(vcs_state: VcsState) -> Result { + let mut store = BlobStore::new(); + store.import_all(vcs_state.objects)?; + + let head = match vcs_state.head { + Some(bytes) => { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Some(ObjectId::from_raw(arr)) + } + None => None, + }; + + Ok(Vcs { + store, + head, + commits: vcs_state.commits, + }) + } + + pub fn data_dir(&self) -> &Path { + &self.base_dir + } +} diff --git a/src/posix.rs b/src/posix.rs new file mode 100644 index 0000000000000000000000000000000000000000..f00f50efe55a6fac2e78ef020a89252fb7b20302 --- /dev/null +++ b/src/posix.rs @@ -0,0 +1,251 @@ +use crate::auth::perms::Access; +use crate::auth::session::Session; +use crate::auth::{Gid, Uid}; +use crate::error::VfsError; +use crate::fs::{HandleId, LsEntry, StatInfo, VirtualFs}; + +#[derive(Debug, Clone)] +pub struct PosixDirEntry { + pub name: String, + pub stat: StatInfo, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct PosixSetAttr { + pub mode: Option, + pub uid: Option, + pub gid: Option, + pub size: Option, +} + +pub struct PosixFs<'a> { + fs: &'a mut VirtualFs, + session: &'a Session, +} + +impl<'a> PosixFs<'a> { + pub fn new(fs: &'a mut VirtualFs, session: &'a Session) -> Self { + Self { fs, session } + } + + pub fn getattr(&self, path: &str) -> Result { + let _ = self.fs.resolve_path_checked(path, self.session)?; + self.fs.stat(path) + } + + pub fn lookup(&self, path: &str) -> Result { + self.getattr(path) + } + + pub fn opendir(&mut self, path: &str) -> Result { + let dir_id = self.fs.resolve_path_checked(path, self.session)?; + require_access(self.fs, dir_id, self.session, Access::Read, path)?; + require_access(self.fs, dir_id, self.session, Access::Execute, path)?; + self.fs.opendir(path) + } + + pub fn readdir(&self, path: &str) -> Result, VfsError> { + let dir_id = self.fs.resolve_path_checked(path, self.session)?; + require_access(self.fs, dir_id, self.session, Access::Read, path)?; + require_access(self.fs, dir_id, self.session, Access::Execute, path)?; + + let entries = self.fs.ls(Some(path))?; + let mut results = Vec::new(); + for entry in entries { + if !self.can_see(&entry) { + continue; + } + let child_path = join_paths(path, &entry.name); + results.push(PosixDirEntry { + name: entry.name, + stat: self.fs.stat(&child_path)?, + }); + } + Ok(results) + } + + pub fn open(&mut self, path: &str, writable: bool) -> Result { + let inode_id = self.fs.resolve_path_checked(path, self.session)?; + let access = if writable { Access::Write } else { Access::Read }; + require_access(self.fs, inode_id, self.session, access, path)?; + self.fs.open(path, writable) + } + + pub fn create(&mut self, path: &str, mode: u16) -> Result { + let (parent_id, _) = self.fs.resolve_parent_checked(path, self.session)?; + require_access(self.fs, parent_id, self.session, Access::Write, path)?; + require_access(self.fs, parent_id, self.session, Access::Execute, path)?; + self.fs.create_file( + path, + self.session.effective_uid(), + self.session.effective_gid(), + Some(mode), + )?; + self.fs.open(path, true) + } + + pub fn mkdir(&mut self, path: &str, mode: u16) -> Result<(), VfsError> { + let (parent_id, _) = self.fs.resolve_parent_checked(path, self.session)?; + require_access(self.fs, parent_id, self.session, Access::Write, path)?; + require_access(self.fs, parent_id, self.session, Access::Execute, path)?; + self.fs + .mkdir(path, self.session.effective_uid(), self.session.effective_gid())?; + self.fs.chmod(path, mode) + } + + pub fn read(&mut self, path: &str, offset: usize, size: usize) -> Result, VfsError> { + let inode_id = self.fs.resolve_path_checked(path, self.session)?; + require_access(self.fs, inode_id, self.session, Access::Read, path)?; + self.fs.read_file_at(path, offset, size) + } + + pub fn write(&mut self, path: &str, offset: usize, data: &[u8]) -> Result { + let inode_id = self.fs.resolve_path_checked(path, self.session)?; + require_access(self.fs, inode_id, self.session, Access::Write, path)?; + self.fs.write_file_at(path, offset, data) + } + + pub fn read_handle(&mut self, handle: HandleId, size: usize) -> Result, VfsError> { + self.fs.read_handle(handle, size) + } + + pub fn write_handle(&mut self, handle: HandleId, data: &[u8]) -> Result { + self.fs.write_handle(handle, data) + } + + pub fn release(&mut self, handle: HandleId) -> Result<(), VfsError> { + self.fs.release_handle(handle) + } + + pub fn truncate(&mut self, path: &str, size: usize) -> Result<(), VfsError> { + let inode_id = self.fs.resolve_path_checked(path, self.session)?; + require_access(self.fs, inode_id, self.session, Access::Write, path)?; + self.fs.truncate(path, size) + } + + pub fn setattr(&mut self, path: &str, attr: PosixSetAttr) -> Result { + let inode_id = self.fs.resolve_path_checked(path, self.session)?; + let (current_uid, current_gid) = { + let inode = self.fs.get_inode(inode_id)?; + (inode.uid, inode.gid) + }; + + if let Some(mode) = attr.mode { + if !self.session.is_effective_owner(current_uid) { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + self.fs.chmod(path, mode)?; + } + + if let Some(uid) = attr.uid { + if !self.session.is_effectively_root() { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + let gid = attr.gid.unwrap_or(current_gid); + self.fs.chown(path, uid, gid)?; + } else if let Some(gid) = attr.gid { + if !self.session.is_effective_owner(current_uid) && !self.session.is_effectively_root() { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + self.fs.chown(path, current_uid, gid)?; + } + + if let Some(size) = attr.size { + require_access(self.fs, inode_id, self.session, Access::Write, path)?; + self.fs.truncate(path, size)?; + } + + self.fs.stat(path) + } + + pub fn unlink(&mut self, path: &str) -> Result<(), VfsError> { + let (parent_id, _) = self.fs.resolve_parent_checked(path, self.session)?; + require_access(self.fs, parent_id, self.session, Access::Write, path)?; + require_access(self.fs, parent_id, self.session, Access::Execute, path)?; + self.fs.rm(path) + } + + pub fn rmdir(&mut self, path: &str) -> Result<(), VfsError> { + let (parent_id, _) = self.fs.resolve_parent_checked(path, self.session)?; + require_access(self.fs, parent_id, self.session, Access::Write, path)?; + require_access(self.fs, parent_id, self.session, Access::Execute, path)?; + self.fs.rmdir(path) + } + + pub fn rename(&mut self, src: &str, dst: &str) -> Result<(), VfsError> { + let (src_parent, _) = self.fs.resolve_parent_checked(src, self.session)?; + require_access(self.fs, src_parent, self.session, Access::Write, src)?; + require_access(self.fs, src_parent, self.session, Access::Execute, src)?; + + if let Ok((dst_parent, _)) = self.fs.resolve_parent_checked(dst, self.session) { + require_access(self.fs, dst_parent, self.session, Access::Write, dst)?; + require_access(self.fs, dst_parent, self.session, Access::Execute, dst)?; + } + + self.fs.rename(src, dst) + } + + pub fn symlink(&mut self, target: &str, link_path: &str) -> Result<(), VfsError> { + let (parent_id, _) = self.fs.resolve_parent_checked(link_path, self.session)?; + require_access(self.fs, parent_id, self.session, Access::Write, link_path)?; + require_access(self.fs, parent_id, self.session, Access::Execute, link_path)?; + self.fs.ln_s( + target, + link_path, + self.session.effective_uid(), + self.session.effective_gid(), + ) + } + + pub fn link(&mut self, target: &str, link_path: &str) -> Result<(), VfsError> { + let target_id = self.fs.resolve_path_checked(target, self.session)?; + require_access(self.fs, target_id, self.session, Access::Read, target)?; + let (parent_id, _) = self.fs.resolve_parent_checked(link_path, self.session)?; + require_access(self.fs, parent_id, self.session, Access::Write, link_path)?; + require_access(self.fs, parent_id, self.session, Access::Execute, link_path)?; + self.fs.link(target, link_path) + } + + pub fn readlink(&self, path: &str) -> Result { + let inode_id = self.fs.resolve_path_checked(path, self.session)?; + require_access(self.fs, inode_id, self.session, Access::Read, path)?; + self.fs.readlink(path) + } + + fn can_see(&self, entry: &LsEntry) -> bool { + self.session + .has_permission_bits(entry.mode, entry.uid, entry.gid, Access::Read) + } +} + +fn require_access( + fs: &VirtualFs, + inode_id: u64, + session: &Session, + access: Access, + path: &str, +) -> Result<(), VfsError> { + let inode = fs.get_inode(inode_id)?; + if !session.has_permission(inode, access) { + return Err(VfsError::PermissionDenied { + path: path.to_string(), + }); + } + Ok(()) +} + +fn join_paths(parent: &str, child: &str) -> String { + if parent == "/" { + format!("/{child}") + } else if parent == "." { + format!("./{child}") + } else { + format!("{}/{}", parent.trim_end_matches('/'), child) + } +} diff --git a/src/server/middleware.rs b/src/server/middleware.rs new file mode 100644 index 0000000000000000000000000000000000000000..30567152c6fec10bab899060461763001f9a47bb --- /dev/null +++ b/src/server/middleware.rs @@ -0,0 +1,26 @@ +use axum::http::HeaderMap; + +use crate::auth::session::Session; +use crate::error::VfsError; +use crate::server::AppState; + +pub async fn session_from_headers( + state: &AppState, + headers: &HeaderMap, +) -> Result { + if let Some(auth_header) = headers.get("authorization") { + let header_str = auth_header.to_str().map_err(|_| VfsError::AuthError { + message: "invalid authorization header".to_string(), + })?; + + if let Some(token) = header_str.strip_prefix("Bearer ") { + return state.db.authenticate_token(token).await; + } + + if let Some(username) = header_str.strip_prefix("User ") { + return state.db.login(username).await; + } + } + + Ok(Session::root()) +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..920ec484b69a8d340384c6a19fdeddfa096524e0 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,32 @@ +pub mod middleware; +pub mod routes_auth; +pub mod routes_fs; +pub mod routes_vcs; + +use axum::Router; +use std::sync::Arc; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +use crate::db::MarkdownDb; + +#[derive(Clone)] +pub struct ServerState { + pub db: Arc, +} + +pub type AppState = Arc; + +pub fn build_router(db: MarkdownDb) -> Router { + let state: AppState = Arc::new(ServerState { + db: Arc::new(db), + }); + + Router::new() + .merge(routes_auth::routes()) + .merge(routes_fs::routes()) + .merge(routes_vcs::routes()) + .with_state(state) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()) +} diff --git a/src/server/routes_auth.rs b/src/server/routes_auth.rs new file mode 100644 index 0000000000000000000000000000000000000000..256ec9134644a011a8eaf5715c2073019772b29b --- /dev/null +++ b/src/server/routes_auth.rs @@ -0,0 +1,71 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; + +use super::AppState; + +#[derive(Deserialize)] +pub struct LoginRequest { + pub username: String, +} + +#[derive(Serialize)] +pub struct LoginResponse { + pub username: String, + pub uid: u32, + pub gid: u32, + pub groups: Vec, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +pub fn routes() -> Router { + Router::new() + .route("/auth/login", post(login)) + .route("/health", axum::routing::get(health)) +} + +async fn login( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + match state.db.login(&req.username).await { + Ok(session) => ( + StatusCode::OK, + Json(LoginResponse { + username: session.username.clone(), + uid: session.uid, + gid: session.gid, + groups: session.groups.clone(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + .into_response(), + } +} + +async fn health(State(state): State) -> impl IntoResponse { + let commits = state.db.commit_count().await; + let inodes = state.db.inode_count().await; + let objects = state.db.object_count().await; + + Json(serde_json::json!({ + "status": "ok", + "version": env!("CARGO_PKG_VERSION"), + "commits": commits, + "inodes": inodes, + "objects": objects, + })) +} diff --git a/src/server/routes_fs.rs b/src/server/routes_fs.rs new file mode 100644 index 0000000000000000000000000000000000000000..ffbab4c4404680db9661a618a7f1b437a1ba9a05 --- /dev/null +++ b/src/server/routes_fs.rs @@ -0,0 +1,312 @@ +use axum::body::{Body, Bytes}; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; + +use super::middleware::session_from_headers; +use super::AppState; + +#[derive(Deserialize, Default)] +pub struct FsQuery { + pub stat: Option, + pub op: Option, + pub dst: Option, + pub recursive: Option, +} + +#[derive(Deserialize, Default)] +pub struct SearchQuery { + pub pattern: Option, + pub path: Option, + pub name: Option, + pub recursive: Option, +} + +fn err_json(status: StatusCode, msg: impl Into) -> impl IntoResponse { + (status, Json(serde_json::json!({"error": msg.into()}))) +} + +fn parent_path(path: &str) -> Option { + let trimmed = path.trim_end_matches('/'); + let idx = trimmed.rfind('/')?; + Some(trimmed[..idx].to_string()) +} + +pub fn routes() -> Router { + Router::new() + .route("/fs", get(get_fs_root)) + .route("/fs/{*path}", get(get_fs).put(put_fs).delete(delete_fs).post(post_fs)) + .route("/search/grep", get(search_grep)) + .route("/search/find", get(search_find)) + .route("/tree", get(get_tree_root)) + .route("/tree/{*path}", get(get_tree)) +} + +async fn get_fs_root( + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + let _session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return err_json(StatusCode::UNAUTHORIZED, e.to_string()).into_response(), + }; + + match state.db.ls(None).await { + Ok(entries) => Json(ls_to_json(&entries, "/")).into_response(), + Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_fs( + State(state): State, + Path(path): Path, + Query(query): Query, + headers: HeaderMap, +) -> impl IntoResponse { + let _session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return err_json(StatusCode::UNAUTHORIZED, e.to_string()).into_response(), + }; + + if query.stat.unwrap_or(false) { + return match state.db.stat(&path).await { + Ok(info) => Json(serde_json::json!({ + "inode_id": info.inode_id, + "kind": info.kind, + "size": info.size, + "mode": format!("0{:o}", info.mode), + "uid": info.uid, + "gid": info.gid, + "created": info.created, + "modified": info.modified, + })) + .into_response(), + Err(e) => err_json(StatusCode::NOT_FOUND, e.to_string()).into_response(), + }; + } + + match state.db.cat(&path).await { + Ok(content) => { + (StatusCode::OK, [("content-type", "text/markdown")], Body::from(content)) + .into_response() + } + Err(crate::error::VfsError::IsDirectory { .. }) => { + match state.db.ls(Some(&path)).await { + Ok(entries) => Json(ls_to_json(&entries, &path)).into_response(), + Err(e) => { + err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } + } + Err(e) => err_json(StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +async fn put_fs( + State(state): State, + Path(path): Path, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + let session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return err_json(StatusCode::UNAUTHORIZED, e.to_string()).into_response(), + }; + + let is_dir = headers + .get("x-markdownfs-type") + .and_then(|v| v.to_str().ok()) + .map(|v| v == "directory") + .unwrap_or(false); + + if is_dir { + match state.db.mkdir_p(&path, session.uid, session.gid).await { + Ok(()) => { + Json(serde_json::json!({"created": path, "type": "directory"})).into_response() + } + Err(e) => err_json(StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } + } else { + if state.db.stat(&path).await.is_err() { + if let Some(parent) = parent_path(&path) { + if !parent.is_empty() && state.db.stat(&parent).await.is_err() { + if let Err(e) = state.db.mkdir_p(&parent, session.uid, session.gid).await { + return err_json(StatusCode::BAD_REQUEST, e.to_string()).into_response(); + } + } + } + if let Err(e) = state.db.touch(&path, session.uid, session.gid).await { + return err_json(StatusCode::BAD_REQUEST, e.to_string()).into_response(); + } + } + + let size = body.len(); + match state.db.write_file(&path, body.to_vec()).await { + Ok(()) => Json(serde_json::json!({"written": path, "size": size})).into_response(), + Err(e) => err_json(StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } + } +} + +async fn delete_fs( + State(state): State, + Path(path): Path, + Query(query): Query, + headers: HeaderMap, +) -> impl IntoResponse { + let _session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return err_json(StatusCode::UNAUTHORIZED, e.to_string()).into_response(), + }; + + let recursive = query.recursive.unwrap_or(false); + let result = if recursive { + state.db.rm_rf(&path).await + } else { + state.db.rm(&path).await + }; + + match result { + Ok(()) => Json(serde_json::json!({"deleted": path})).into_response(), + Err(e) => err_json(StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn post_fs( + State(state): State, + Path(path): Path, + Query(query): Query, + headers: HeaderMap, +) -> impl IntoResponse { + let session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return err_json(StatusCode::UNAUTHORIZED, e.to_string()).into_response(), + }; + + match query.op.as_deref() { + Some("copy") => { + let dst = match &query.dst { + Some(d) => d.as_str(), + None => return err_json(StatusCode::BAD_REQUEST, "missing dst parameter").into_response(), + }; + match state.db.cp(&path, dst, session.uid, session.gid).await { + Ok(()) => Json(serde_json::json!({"copied": path, "to": dst})).into_response(), + Err(e) => err_json(StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } + } + Some("move") => { + let dst = match &query.dst { + Some(d) => d.as_str(), + None => return err_json(StatusCode::BAD_REQUEST, "missing dst parameter").into_response(), + }; + match state.db.mv(&path, dst).await { + Ok(()) => Json(serde_json::json!({"moved": path, "to": dst})).into_response(), + Err(e) => err_json(StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } + } + Some(op) => err_json(StatusCode::BAD_REQUEST, format!("unknown op: {op}")).into_response(), + None => err_json(StatusCode::BAD_REQUEST, "missing op parameter").into_response(), + } +} + +async fn search_grep( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> impl IntoResponse { + let session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return err_json(StatusCode::UNAUTHORIZED, e.to_string()).into_response(), + }; + + let pattern = match &query.pattern { + Some(p) => p.clone(), + None => return err_json(StatusCode::BAD_REQUEST, "missing pattern parameter").into_response(), + }; + + let path = query.path.as_deref(); + let recursive = query.recursive.unwrap_or(true); + + match state.db.grep(&pattern, path, recursive, Some(&session)).await { + Ok(results) => { + let items: Vec = results + .iter() + .map(|r| serde_json::json!({"file": r.file, "line_num": r.line_num, "line": r.line})) + .collect(); + Json(serde_json::json!({"results": items, "count": items.len()})).into_response() + } + Err(e) => err_json(StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn search_find( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> impl IntoResponse { + let session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return err_json(StatusCode::UNAUTHORIZED, e.to_string()).into_response(), + }; + + let path = query.path.as_deref(); + let name = query.name.as_deref(); + + match state.db.find(path, name, Some(&session)).await { + Ok(results) => { + Json(serde_json::json!({"results": results, "count": results.len()})).into_response() + } + Err(e) => err_json(StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn get_tree_root( + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + let session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return err_json(StatusCode::UNAUTHORIZED, e.to_string()).into_response(), + }; + match state.db.tree(None, Some(&session)).await { + Ok(tree) => (StatusCode::OK, tree).into_response(), + Err(e) => err_json(StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +async fn get_tree( + State(state): State, + Path(path): Path, + headers: HeaderMap, +) -> impl IntoResponse { + let session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return err_json(StatusCode::UNAUTHORIZED, e.to_string()).into_response(), + }; + match state.db.tree(Some(&path), Some(&session)).await { + Ok(tree) => (StatusCode::OK, tree).into_response(), + Err(e) => err_json(StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +fn ls_to_json(entries: &[crate::fs::LsEntry], path: &str) -> serde_json::Value { + let items: Vec = entries + .iter() + .map(|e| { + serde_json::json!({ + "name": e.name, + "is_dir": e.is_dir, + "is_symlink": e.is_symlink, + "size": e.size, + "mode": format!("0{:o}", e.mode), + "uid": e.uid, + "gid": e.gid, + "modified": e.modified, + }) + }) + .collect(); + serde_json::json!({"entries": items, "path": path}) +} diff --git a/src/server/routes_vcs.rs b/src/server/routes_vcs.rs new file mode 100644 index 0000000000000000000000000000000000000000..ecd20255c959d2ebbcd85fed23f398dc6f82a13b --- /dev/null +++ b/src/server/routes_vcs.rs @@ -0,0 +1,81 @@ +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; + +use super::middleware::session_from_headers; +use super::AppState; + +#[derive(Deserialize)] +pub struct CommitRequest { + pub message: String, +} + +#[derive(Deserialize)] +pub struct RevertRequest { + pub hash: String, +} + +pub fn routes() -> Router { + Router::new() + .route("/vcs/commit", post(vcs_commit)) + .route("/vcs/log", get(vcs_log)) + .route("/vcs/revert", post(vcs_revert)) + .route("/vcs/status", get(vcs_status)) +} + +async fn vcs_commit( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> impl IntoResponse { + let session = match session_from_headers(&state, &headers).await { + Ok(s) => s, + Err(e) => return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": e.to_string()}))).into_response(), + }; + + match state.db.commit(&req.message, &session.username).await { + Ok(hash) => Json(serde_json::json!({ + "hash": hash, + "message": req.message, + "author": session.username, + })) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response(), + } +} + +async fn vcs_log(State(state): State) -> impl IntoResponse { + let commits = state.db.vcs_log().await; + let items: Vec = commits + .iter() + .map(|c| { + serde_json::json!({ + "hash": c.id.short_hex(), + "message": c.message, + "author": c.author, + "timestamp": c.timestamp, + }) + }) + .collect(); + Json(serde_json::json!({"commits": items})) +} + +async fn vcs_revert( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + match state.db.revert(&req.hash).await { + Ok(()) => Json(serde_json::json!({"reverted_to": req.hash})).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": e.to_string()}))).into_response(), + } +} + +async fn vcs_status(State(state): State) -> impl IntoResponse { + match state.db.vcs_status().await { + Ok(status) => (StatusCode::OK, status).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response(), + } +} diff --git a/src/store/blob.rs b/src/store/blob.rs new file mode 100644 index 0000000000000000000000000000000000000000..31a653e6345c61dc1916cb0b14f2cf0160c27556 --- /dev/null +++ b/src/store/blob.rs @@ -0,0 +1,82 @@ +use super::{ObjectId, ObjectKind}; +use crate::error::VfsError; +use std::collections::HashMap; + +/// In-memory content-addressable blob store. +/// Will be upgraded to mmap-backed pack file in Phase 3. +pub struct BlobStore { + objects: HashMap)>, +} + +impl BlobStore { + pub fn new() -> Self { + BlobStore { + objects: HashMap::new(), + } + } + + pub fn put(&mut self, data: &[u8], kind: ObjectKind) -> ObjectId { + let id = ObjectId::from_bytes(data); + // Dedup: if already stored, skip + self.objects.entry(id).or_insert_with(|| (kind, data.to_vec())); + id + } + + pub fn get(&self, id: &ObjectId) -> Result<&[u8], VfsError> { + self.objects + .get(id) + .map(|(_, data)| data.as_slice()) + .ok_or_else(|| VfsError::ObjectNotFound { + id: id.short_hex(), + }) + } + + pub fn contains(&self, id: &ObjectId) -> bool { + self.objects.contains_key(id) + } + + pub fn object_count(&self) -> usize { + self.objects.len() + } + + /// Export all objects for persistence: (ObjectId bytes, kind as u8, data). + pub fn export_all(&self) -> Vec<(Vec, u8, Vec)> { + self.objects + .iter() + .map(|(id, (kind, data))| { + let kind_byte = match kind { + ObjectKind::Blob => 0u8, + ObjectKind::Tree => 1u8, + ObjectKind::Commit => 2u8, + }; + (id.as_bytes().to_vec(), kind_byte, data.clone()) + }) + .collect() + } + + /// Import objects from persisted state. + pub fn import_all(&mut self, objects: Vec<(Vec, u8, Vec)>) -> Result<(), crate::error::VfsError> { + for (id_bytes, kind_byte, data) in objects { + let mut arr = [0u8; 32]; + if id_bytes.len() != 32 { + return Err(crate::error::VfsError::CorruptStore { + message: "invalid object ID length".to_string(), + }); + } + arr.copy_from_slice(&id_bytes); + let id = ObjectId::from_raw(arr); + let kind = match kind_byte { + 0 => ObjectKind::Blob, + 1 => ObjectKind::Tree, + 2 => ObjectKind::Commit, + _ => { + return Err(crate::error::VfsError::CorruptStore { + message: format!("unknown object kind: {kind_byte}"), + }) + } + }; + self.objects.insert(id, (kind, data)); + } + Ok(()) + } +} diff --git a/src/store/commit.rs b/src/store/commit.rs new file mode 100644 index 0000000000000000000000000000000000000000..9f8cfa611d370e43e62647792bdf5c897507e99d --- /dev/null +++ b/src/store/commit.rs @@ -0,0 +1,22 @@ +use super::ObjectId; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitObject { + pub id: ObjectId, + pub tree: ObjectId, + pub parent: Option, + pub timestamp: u64, + pub message: String, + pub author: String, +} + +impl CommitObject { + pub fn serialize(&self) -> Vec { + bincode::serialize(self).expect("commit serialization should not fail") + } + + pub fn deserialize(data: &[u8]) -> Result { + bincode::deserialize(data) + } +} diff --git a/src/store/index.rs b/src/store/index.rs new file mode 100644 index 0000000000000000000000000000000000000000..9c633080d6ba39b03e7c9efe8b0e15b23c28e85b --- /dev/null +++ b/src/store/index.rs @@ -0,0 +1,2 @@ +// Pack index — will be implemented in Phase 3 for disk-backed storage. +// Currently the BlobStore uses an in-memory HashMap. diff --git a/src/store/mod.rs b/src/store/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..41ba058dd89a514a248f77c776ce69dfed7d0f2b --- /dev/null +++ b/src/store/mod.rs @@ -0,0 +1,73 @@ +pub mod blob; +pub mod commit; +pub mod index; +pub mod tree; + +use crate::error::VfsError; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ObjectId([u8; 32]); + +impl ObjectId { + pub fn from_bytes(data: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(data); + let result = hasher.finalize(); + let mut id = [0u8; 32]; + id.copy_from_slice(&result); + ObjectId(id) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(&self) -> String { + self.0.iter().map(|b| format!("{b:02x}")).collect() + } + + pub fn short_hex(&self) -> String { + self.to_hex()[..8].to_string() + } + + pub fn from_raw(bytes: [u8; 32]) -> Self { + ObjectId(bytes) + } + + pub fn from_hex(hex: &str) -> Result { + if hex.len() != 64 { + return Err(VfsError::InvalidArgs { + message: format!("invalid object ID: {hex}"), + }); + } + let mut bytes = [0u8; 32]; + for i in 0..32 { + bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| { + VfsError::InvalidArgs { + message: format!("invalid hex: {hex}"), + } + })?; + } + Ok(ObjectId(bytes)) + } +} + +impl std::fmt::Debug for ObjectId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ObjectId({})", self.short_hex()) + } +} + +impl std::fmt::Display for ObjectId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_hex()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ObjectKind { + Blob, + Tree, + Commit, +} diff --git a/src/store/tree.rs b/src/store/tree.rs new file mode 100644 index 0000000000000000000000000000000000000000..181bdc97808e4be1612079a09e218088e3ac9024 --- /dev/null +++ b/src/store/tree.rs @@ -0,0 +1,53 @@ +use super::ObjectId; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TreeEntry { + pub name: String, + pub kind: TreeEntryKind, + pub id: ObjectId, + pub mode: u16, + pub uid: u32, + pub gid: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TreeEntryKind { + Blob, + Tree, + Symlink, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TreeObject { + pub entries: Vec, +} + +impl TreeObject { + pub fn serialize(&self) -> Vec { + bincode::serialize(self).expect("tree serialization should not fail") + } + + pub fn deserialize(data: &[u8]) -> Result { + bincode::deserialize(data) + } +} + +// Need serde impls for ObjectId +impl Serialize for ObjectId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for ObjectId { + fn deserialize>(deserializer: D) -> Result { + let bytes: Vec = Deserialize::deserialize(deserializer)?; + if bytes.len() != 32 { + return Err(serde::de::Error::custom("expected 32 bytes for ObjectId")); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Ok(ObjectId(arr)) + } +} diff --git a/src/vcs/mod.rs b/src/vcs/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..aaabe98766eab193e58fb2a0efa9ccbbef3118ff --- /dev/null +++ b/src/vcs/mod.rs @@ -0,0 +1,169 @@ +pub mod revert; +pub mod snapshot; + +use crate::error::VfsError; +use crate::fs::inode::{InodeKind, InodeId}; +use crate::fs::VirtualFs; +use crate::store::blob::BlobStore; +use crate::store::commit::CommitObject; +use crate::store::tree::{TreeEntry, TreeEntryKind, TreeObject}; +use crate::store::{ObjectId, ObjectKind}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub struct Vcs { + pub store: BlobStore, + pub head: Option, + pub commits: Vec, +} + +impl Vcs { + pub fn new() -> Self { + Vcs { + store: BlobStore::new(), + head: None, + commits: Vec::new(), + } + } + + pub fn commit( + &mut self, + fs: &VirtualFs, + message: &str, + author: &str, + ) -> Result { + let root_tree_id = self.snapshot_dir(fs, fs.root_id())?; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let commit = CommitObject { + id: ObjectId::from_bytes(&[0; 32]), // placeholder + tree: root_tree_id, + parent: self.head, + timestamp, + message: message.to_string(), + author: author.to_string(), + }; + + let commit_data = commit.serialize(); + let commit_id = self.store.put(&commit_data, ObjectKind::Commit); + + let final_commit = CommitObject { + id: commit_id, + ..commit + }; + + self.commits.push(final_commit); + self.head = Some(commit_id); + Ok(commit_id) + } + + fn snapshot_dir(&mut self, fs: &VirtualFs, dir_id: InodeId) -> Result { + let inode = fs.get_inode(dir_id)?; + let entries = match &inode.kind { + InodeKind::Directory { entries } => entries, + _ => { + return Err(VfsError::NotDirectory { + path: format!(""), + }) + } + }; + + let mut tree_entries = Vec::with_capacity(entries.len()); + for (name, &child_id) in entries { + let child = fs.get_inode(child_id)?; + let (kind, id) = match &child.kind { + InodeKind::File { content } => { + let blob_id = self.store.put(content, ObjectKind::Blob); + (TreeEntryKind::Blob, blob_id) + } + InodeKind::Directory { .. } => { + let tree_id = self.snapshot_dir(fs, child_id)?; + (TreeEntryKind::Tree, tree_id) + } + InodeKind::Symlink { target } => { + let blob_id = self.store.put(target.as_bytes(), ObjectKind::Blob); + (TreeEntryKind::Symlink, blob_id) + } + }; + + tree_entries.push(TreeEntry { + name: name.clone(), + kind, + id, + mode: child.mode, + uid: child.uid, + gid: child.gid, + }); + } + + let tree = TreeObject { + entries: tree_entries, + }; + let tree_data = tree.serialize(); + Ok(self.store.put(&tree_data, ObjectKind::Tree)) + } + + pub fn log(&self) -> Vec<&CommitObject> { + self.commits.iter().rev().collect() + } + + pub fn revert(&mut self, fs: &mut VirtualFs, hash_prefix: &str) -> Result<(), VfsError> { + let commit = self.find_commit(hash_prefix)?; + let tree_id = commit.tree; + let commit_id = commit.id; + + // Reconstruct VFS from tree + revert::restore_from_tree(fs, &self.store, tree_id)?; + + self.head = Some(commit_id); + Ok(()) + } + + pub fn find_commit(&self, hash_prefix: &str) -> Result<&CommitObject, VfsError> { + let matches: Vec<&CommitObject> = self + .commits + .iter() + .filter(|c| c.id.to_hex().starts_with(hash_prefix)) + .collect(); + + match matches.len() { + 0 => Err(VfsError::ObjectNotFound { + id: hash_prefix.to_string(), + }), + 1 => Ok(matches[0]), + _ => Err(VfsError::InvalidArgs { + message: format!("ambiguous commit prefix: {hash_prefix}"), + }), + } + } + + pub fn status(&self, fs: &VirtualFs) -> Result { + if self.head.is_none() { + return Ok("No commits yet.\n".to_string()); + } + + let mut output = String::new(); + output.push_str(&format!( + "On commit {}\n", + self.head.unwrap().short_hex() + )); + output.push_str(&format!( + "Objects in store: {}\n", + self.store.object_count() + )); + + let mut file_count = 0u64; + let mut total_size = 0u64; + for inode in fs.all_inodes().values() { + if let InodeKind::File { content } = &inode.kind { + file_count += 1; + total_size += content.len() as u64; + } + } + output.push_str(&format!("Files: {file_count}, Total size: {total_size} bytes\n")); + Ok(output) + } +} diff --git a/src/vcs/revert.rs b/src/vcs/revert.rs new file mode 100644 index 0000000000000000000000000000000000000000..d6cbd4b6e32b7d1aa3e8a4b820ccaf353dbc5763 --- /dev/null +++ b/src/vcs/revert.rs @@ -0,0 +1,61 @@ +use crate::error::VfsError; +use crate::fs::VirtualFs; +use crate::store::blob::BlobStore; +use crate::store::tree::{TreeEntryKind, TreeObject}; +use crate::store::ObjectId; + +/// Reconstruct a VirtualFs from a tree object in the store. +pub fn restore_from_tree( + fs: &mut VirtualFs, + store: &BlobStore, + root_tree_id: ObjectId, +) -> Result<(), VfsError> { + // Preserve the registry across reverts + let registry = fs.registry.clone(); + *fs = VirtualFs::new(); + fs.registry = registry; + + // Recursively restore from root tree + restore_dir(fs, store, "/", root_tree_id)?; + Ok(()) +} + +fn restore_dir( + fs: &mut VirtualFs, + store: &BlobStore, + dir_path: &str, + tree_id: ObjectId, +) -> Result<(), VfsError> { + let tree_data = store.get(&tree_id)?; + let tree = TreeObject::deserialize(tree_data).map_err(|e| VfsError::CorruptStore { + message: format!("failed to deserialize tree: {e}"), + })?; + + for entry in &tree.entries { + let child_path = if dir_path == "/" { + format!("/{}", entry.name) + } else { + format!("{}/{}", dir_path, entry.name) + }; + + match entry.kind { + TreeEntryKind::Blob => { + let content = store.get(&entry.id)?; + fs.touch(&child_path, entry.uid, entry.gid)?; + fs.write_file(&child_path, content.to_vec())?; + fs.chmod(&child_path, entry.mode)?; + } + TreeEntryKind::Tree => { + fs.mkdir(&child_path, entry.uid, entry.gid)?; + fs.chmod(&child_path, entry.mode)?; + restore_dir(fs, store, &child_path, entry.id)?; + } + TreeEntryKind::Symlink => { + let target = String::from_utf8_lossy(store.get(&entry.id)?).to_string(); + fs.ln_s(&target, &child_path, entry.uid, entry.gid)?; + } + } + } + + Ok(()) +} diff --git a/src/vcs/snapshot.rs b/src/vcs/snapshot.rs new file mode 100644 index 0000000000000000000000000000000000000000..4c7089717eeaf7943c74748a71d9891825294a58 --- /dev/null +++ b/src/vcs/snapshot.rs @@ -0,0 +1,3 @@ +// Snapshot logic is in vcs/mod.rs (snapshot_dir method). +// This file is reserved for future optimization: incremental snapshots +// that skip unchanged subtrees by comparing tree hashes. diff --git a/tests/integration/dirs.rs b/tests/integration/dirs.rs new file mode 100644 index 0000000000000000000000000000000000000000..3cae7e80108f5cac7dab61828a18fc6289d707d9 --- /dev/null +++ b/tests/integration/dirs.rs @@ -0,0 +1,64 @@ +use super::*; + +#[test] +fn test_mkdir_and_ls() { + let mut fs = VirtualFs::new(); + exec("mkdir docs", &mut fs); + exec("mkdir src", &mut fs); + let output = exec("ls", &mut fs); + assert!(output.contains("docs/")); + assert!(output.contains("src/")); +} + +#[test] +fn test_mkdir_duplicate() { + let mut fs = VirtualFs::new(); + exec("mkdir docs", &mut fs); + let err = exec_err("mkdir docs", &mut fs); + assert!(err.contains("already exists")); +} + +#[test] +fn test_mkdir_p_creates_intermediate() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b/c/d/e", &mut fs); + exec("cd a/b/c/d/e", &mut fs); + assert_eq!(exec("pwd", &mut fs).trim(), "/a/b/c/d/e"); +} + +#[test] +fn test_mkdir_p_idempotent() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b/c", &mut fs); + exec("mkdir -p a/b/c", &mut fs); // should not error + exec("cd a/b/c", &mut fs); + assert_eq!(exec("pwd", &mut fs).trim(), "/a/b/c"); +} + +#[test] +fn test_rmdir_empty() { + let mut fs = VirtualFs::new(); + exec("mkdir empty", &mut fs); + exec("rmdir empty", &mut fs); + let ls = exec("ls", &mut fs); + assert!(!ls.contains("empty")); +} + +#[test] +fn test_rmdir_non_empty_fails() { + let mut fs = VirtualFs::new(); + exec("mkdir dir", &mut fs); + exec("cd dir", &mut fs); + exec("touch file.md", &mut fs); + exec("cd /", &mut fs); + let err = exec_err("rmdir dir", &mut fs); + assert!(err.contains("not empty")); +} + +#[test] +fn test_rmdir_on_file_fails() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + let err = exec_err("rmdir file.md", &mut fs); + assert!(err.contains("not a directory")); +} diff --git a/tests/integration/edge_cases.rs b/tests/integration/edge_cases.rs new file mode 100644 index 0000000000000000000000000000000000000000..96f81ac30209347309d37320fa47e984b8b8f15d --- /dev/null +++ b/tests/integration/edge_cases.rs @@ -0,0 +1,131 @@ +use super::*; + +#[test] +fn test_unknown_command() { + let mut fs = VirtualFs::new(); + let err = exec_err("foobar", &mut fs); + assert!(err.contains("unknown command")); +} + +#[test] +fn test_empty_ls_root() { + let fs = VirtualFs::new(); + let entries = fs.ls(None).unwrap(); + assert!(entries.is_empty()); +} + +#[test] +fn test_ls_nonexistent_path() { + let mut fs = VirtualFs::new(); + let err = exec_err("ls nonexistent", &mut fs); + assert!(err.contains("no such file")); +} + +#[test] +fn test_touch_in_nonexistent_parent() { + let mut fs = VirtualFs::new(); + let err = exec_err("touch nonexistent/file.md", &mut fs); + assert!(err.contains("no such file")); +} + +#[test] +fn test_mkdir_in_nonexistent_parent() { + let mut fs = VirtualFs::new(); + let err = exec_err("mkdir nonexistent/child", &mut fs); + assert!(err.contains("no such file")); +} + +#[test] +fn test_mv_nonexistent_src_fails() { + let mut fs = VirtualFs::new(); + let err = exec_err("mv ghost.md dest.md", &mut fs); + assert!(err.contains("no such file")); +} + +#[test] +fn test_many_files_same_directory() { + let mut fs = VirtualFs::new(); + for i in 0..500 { + fs.touch(&format!("file_{i:04}.md"), 0, 0).unwrap(); + } + let entries = fs.ls(None).unwrap(); + assert_eq!(entries.len(), 500); + // BTreeMap should keep them sorted + assert!(entries[0].name < entries[499].name); +} + +#[test] +fn test_large_file_content() { + let mut fs = VirtualFs::new(); + let content: String = (0..10_000) + .map(|i| format!("line {i}: Lorem ipsum dolor sit amet\n")) + .collect(); + exec("touch big.md", &mut fs); + fs.write_file("big.md", content.as_bytes().to_vec()) + .unwrap(); + let read_back = fs.cat("big.md").unwrap(); + assert_eq!(read_back.len(), content.len()); +} + +#[test] +fn test_deeply_nested_path() { + let mut fs = VirtualFs::new(); + let mut path = String::new(); + for i in 0..30 { + if i > 0 { + path.push('/'); + } + path.push_str(&format!("d{i}")); + } + fs.mkdir_p(&path, 0, 0).unwrap(); + let file_path = format!("{path}/deep.md"); + fs.touch(&file_path, 0, 0).unwrap(); + fs.write_file(&file_path, b"deep content".to_vec()) + .unwrap(); + assert_eq!(fs.cat(&file_path).unwrap(), b"deep content"); +} + +#[test] +fn test_stress_create_and_delete_cycle() { + let mut fs = VirtualFs::new(); + for cycle in 0..20 { + for i in 0..50 { + let path = format!("cycle_{cycle}_file_{i}.md"); + fs.touch(&path, 0, 0).unwrap(); + } + for i in 0..50 { + let path = format!("cycle_{cycle}_file_{i}.md"); + fs.rm(&path).unwrap(); + } + } + let entries = fs.ls(None).unwrap(); + assert!(entries.is_empty(), "all files should be deleted"); +} + +#[test] +fn test_stress_rapid_commits() { + let mut fs = VirtualFs::new(); + let mut vcs = markdownfs::vcs::Vcs::new(); + + exec("touch file.md", &mut fs); + for i in 0..50 { + exec(&format!("write file.md version {i}"), &mut fs); + vcs.commit(&fs, &format!("commit {i}"), "root").unwrap(); + } + + assert_eq!(vcs.log().len(), 50); + assert_eq!(exec("cat file.md", &mut fs), "version 49"); +} + +#[test] +fn test_stress_deep_pipe_chain() { + let mut fs = VirtualFs::new(); + exec("touch log.md", &mut fs); + let mut content = String::new(); + for i in 0..1000 { + content.push_str(&format!("ERROR line {i}\n")); + } + fs.write_file("log.md", content.into_bytes()).unwrap(); + let output = exec("cat log.md | grep ERROR | head -5 | wc -l", &mut fs); + assert_eq!(output.trim(), "5"); +} diff --git a/tests/integration/files.rs b/tests/integration/files.rs new file mode 100644 index 0000000000000000000000000000000000000000..396f8f5cff995269af1aef82ce9bb23904502c55 --- /dev/null +++ b/tests/integration/files.rs @@ -0,0 +1,111 @@ +use super::*; + +#[test] +fn test_touch_and_cat() { + let mut fs = VirtualFs::new(); + exec("touch readme.md", &mut fs); + exec("write readme.md Hello, markdownfs!", &mut fs); + let output = exec("cat readme.md", &mut fs); + assert_eq!(output, "Hello, markdownfs!"); +} + +#[test] +fn test_only_markdown_files() { + let mut fs = VirtualFs::new(); + let err = exec_err("touch hello.txt", &mut fs); + assert!(err.contains("only .md files")); +} + +#[test] +fn test_touch_non_md_extensions_rejected() { + let mut fs = VirtualFs::new(); + for ext in ["txt", "rs", "py", "json", "yaml", "toml", "html", "css"] { + let err = exec_err(&format!("touch file.{ext}"), &mut fs); + assert!( + err.contains("only .md files"), + "extension .{ext} should be rejected" + ); + } +} + +#[test] +fn test_touch_updates_timestamp() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + let stat1 = exec("stat file.md", &mut fs); + std::thread::sleep(std::time::Duration::from_millis(10)); + exec("touch file.md", &mut fs); + let stat2 = exec("stat file.md", &mut fs); + // Timestamps should be equal or newer (within test resolution) + assert!(stat1.contains("file") && stat2.contains("file")); +} + +#[test] +fn test_write_to_nonexistent_dir_fails() { + let mut fs = VirtualFs::new(); + let err = exec_err("write nonexistent/ghost.md content", &mut fs); + assert!(err.contains("no such file")); +} + +#[test] +fn test_cat_nonexistent_file() { + let mut fs = VirtualFs::new(); + let err = exec_err("cat ghost.md", &mut fs); + assert!(err.contains("no such file")); +} + +#[test] +fn test_cat_directory_fails() { + let mut fs = VirtualFs::new(); + exec("mkdir dir", &mut fs); + let err = exec_err("cat dir", &mut fs); + assert!(err.contains("is a directory")); +} + +#[test] +fn test_write_overwrite() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + exec("write file.md first", &mut fs); + assert_eq!(exec("cat file.md", &mut fs), "first"); + exec("write file.md second", &mut fs); + assert_eq!(exec("cat file.md", &mut fs), "second"); +} + +#[test] +fn test_write_via_pipe_empty() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + exec("write file.md initial", &mut fs); + // Verify the initial write + assert_eq!(exec("cat file.md", &mut fs), "initial"); + // Overwrite with pipe + exec("echo replaced | write file.md", &mut fs); + let content = exec("cat file.md", &mut fs); + assert!(content.contains("replaced")); +} + +#[test] +fn test_write_multiline() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + exec("write file.md # Title\n\nParagraph 1\n\nParagraph 2", &mut fs); + let content = exec("cat file.md", &mut fs); + assert!(content.contains("# Title")); + assert!(content.contains("Paragraph 1")); + assert!(content.contains("Paragraph 2")); +} + +#[test] +fn test_write_unicode() { + let mut fs = VirtualFs::new(); + exec("touch unicode.md", &mut fs); + exec( + "write unicode.md 你好世界 🌍 café résumé naïve", + &mut fs, + ); + let content = exec("cat unicode.md", &mut fs); + assert!(content.contains("你好世界")); + assert!(content.contains("🌍")); + assert!(content.contains("café")); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..a684ddca8ea6aa627ef95d2527a185eb0f11ab0d --- /dev/null +++ b/tests/integration/main.rs @@ -0,0 +1,38 @@ +use markdownfs::auth::session::Session; +use markdownfs::cmd; +use markdownfs::cmd::parser; +use markdownfs::fs::VirtualFs; + +pub fn exec(line: &str, fs: &mut VirtualFs) -> String { + let pipeline = parser::parse_pipeline(line); + let mut session = Session::root(); + cmd::execute_pipeline(&pipeline, fs, &mut session).unwrap() +} + +pub fn exec_err(line: &str, fs: &mut VirtualFs) -> String { + let pipeline = parser::parse_pipeline(line); + let mut session = Session::root(); + cmd::execute_pipeline(&pipeline, fs, &mut session) + .unwrap_err() + .to_string() +} + +pub fn exec_s(line: &str, fs: &mut VirtualFs, session: &mut Session) -> String { + let pipeline = parser::parse_pipeline(line); + cmd::execute_pipeline(&pipeline, fs, session).unwrap() +} + +mod dirs; +mod files; +mod nav; +mod rm_mv_cp; +mod metadata; +mod search; +mod pipes; +mod symlinks; +mod vcs; +mod persist; +mod permissions; +mod posix; +mod edge_cases; +mod workflows; diff --git a/tests/integration/metadata.rs b/tests/integration/metadata.rs new file mode 100644 index 0000000000000000000000000000000000000000..16d64393d06789dc741f67d2e4f5a853db840d18 --- /dev/null +++ b/tests/integration/metadata.rs @@ -0,0 +1,128 @@ +use super::*; + +#[test] +fn test_stat() { + let mut fs = VirtualFs::new(); + exec("touch info.md", &mut fs); + let output = exec("stat info.md", &mut fs); + assert!(output.contains("file")); + assert!(output.contains("info.md")); +} + +#[test] +fn test_stat_directory() { + let mut fs = VirtualFs::new(); + exec("mkdir mydir", &mut fs); + let output = exec("stat mydir", &mut fs); + assert!(output.contains("directory")); +} + +#[test] +fn test_stat_shows_mode() { + let mut fs = VirtualFs::new(); + exec("touch secret.md", &mut fs); + exec("chmod 600 secret.md", &mut fs); + let output = exec("stat secret.md", &mut fs); + assert!(output.contains("0600")); +} + +#[test] +fn test_stat_shows_uid_gid() { + let mut fs = VirtualFs::new(); + exec("touch owned.md", &mut fs); + let output = exec("stat owned.md", &mut fs); + assert!(output.contains("Uid:")); + assert!(output.contains("Gid:")); +} + +#[test] +fn test_tree() { + let mut fs = VirtualFs::new(); + exec("mkdir docs", &mut fs); + exec("cd docs", &mut fs); + exec("touch readme.md", &mut fs); + exec("cd /", &mut fs); + let output = exec("tree", &mut fs); + assert!(output.contains("docs/")); + assert!(output.contains("readme.md")); +} + +#[test] +fn test_tree_deep_hierarchy() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b/c", &mut fs); + exec("cd a/b/c", &mut fs); + exec("touch leaf.md", &mut fs); + exec("cd /", &mut fs); + let output = exec("tree", &mut fs); + assert!(output.contains("a/")); + assert!(output.contains("b/")); + assert!(output.contains("c/")); + assert!(output.contains("leaf.md")); +} + +#[test] +fn test_tree_empty_root() { + let fs = VirtualFs::new(); + let tree = fs.tree(None, "", None).unwrap(); + assert!(tree.starts_with('.')); +} + +#[test] +fn test_chmod() { + let mut fs = VirtualFs::new(); + exec("touch secure.md", &mut fs); + exec("chmod 600 secure.md", &mut fs); + let output = exec("stat secure.md", &mut fs); + assert!(output.contains("0600")); +} + +#[test] +fn test_chmod_various_modes() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + for mode in ["755", "644", "700", "400", "000", "777"] { + exec(&format!("chmod {mode} file.md"), &mut fs); + let stat = exec("stat file.md", &mut fs); + assert!( + stat.contains(&format!("0{mode}")), + "mode {mode} not found in stat output: {stat}" + ); + } +} + +#[test] +fn test_chmod_directory() { + let mut fs = VirtualFs::new(); + exec("mkdir restricted", &mut fs); + exec("chmod 700 restricted", &mut fs); + let stat = exec("stat restricted", &mut fs); + assert!(stat.contains("0700")); +} + +#[test] +fn test_ls_long_format() { + let mut fs = VirtualFs::new(); + exec("mkdir docs", &mut fs); + exec("touch readme.md", &mut fs); + let output = exec("ls -l", &mut fs); + assert!(output.contains("drwx")); + assert!(output.contains("-rw-")); +} + +#[test] +fn test_ls_long_shows_size() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + exec("write file.md hello world", &mut fs); + let output = exec("ls -l", &mut fs); + assert!(output.contains("11") || output.contains("file.md")); +} + +#[test] +fn test_ls_empty_dir() { + let mut fs = VirtualFs::new(); + exec("mkdir empty", &mut fs); + let output = exec("ls empty", &mut fs); + assert!(output.trim().is_empty()); +} diff --git a/tests/integration/nav.rs b/tests/integration/nav.rs new file mode 100644 index 0000000000000000000000000000000000000000..c81efd6aafa2323935a23ca87f58d4935a599dc6 --- /dev/null +++ b/tests/integration/nav.rs @@ -0,0 +1,78 @@ +use super::*; + +#[test] +fn test_cd_and_pwd() { + let mut fs = VirtualFs::new(); + exec("mkdir project", &mut fs); + exec("cd project", &mut fs); + let pwd = exec("pwd", &mut fs); + assert_eq!(pwd.trim(), "/project"); + exec("cd /", &mut fs); + assert_eq!(exec("pwd", &mut fs).trim(), "/"); +} + +#[test] +fn test_nested_dirs() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b/c", &mut fs); + exec("cd a/b/c", &mut fs); + let pwd = exec("pwd", &mut fs); + assert_eq!(pwd.trim(), "/a/b/c"); +} + +#[test] +fn test_cd_dotdot() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b/c", &mut fs); + exec("cd a/b/c", &mut fs); + exec("cd ..", &mut fs); + assert_eq!(exec("pwd", &mut fs).trim(), "/a/b"); + exec("cd ..", &mut fs); + assert_eq!(exec("pwd", &mut fs).trim(), "/a"); + exec("cd ..", &mut fs); + assert_eq!(exec("pwd", &mut fs).trim(), "/"); +} + +#[test] +fn test_cd_dotdot_at_root() { + let mut fs = VirtualFs::new(); + exec("cd ..", &mut fs); + assert_eq!(exec("pwd", &mut fs).trim(), "/"); +} + +#[test] +fn test_cd_absolute_path() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b/c", &mut fs); + exec("cd a/b/c", &mut fs); + exec("cd /a", &mut fs); + assert_eq!(exec("pwd", &mut fs).trim(), "/a"); +} + +#[test] +fn test_cd_nonexistent_dir_fails() { + let mut fs = VirtualFs::new(); + let err = exec_err("cd nowhere", &mut fs); + assert!(err.contains("no such file")); +} + +#[test] +fn test_cd_to_file_fails() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + let err = exec_err("cd file.md", &mut fs); + assert!(err.contains("not a directory")); +} + +#[test] +fn test_ls_specific_dir() { + let mut fs = VirtualFs::new(); + exec("mkdir docs", &mut fs); + exec("cd docs", &mut fs); + exec("touch a.md", &mut fs); + exec("touch b.md", &mut fs); + exec("cd /", &mut fs); + let output = exec("ls docs", &mut fs); + assert!(output.contains("a.md")); + assert!(output.contains("b.md")); +} diff --git a/tests/integration/permissions.rs b/tests/integration/permissions.rs new file mode 100644 index 0000000000000000000000000000000000000000..d27501040a1a40010b75f3816a34ffbfd6920087 --- /dev/null +++ b/tests/integration/permissions.rs @@ -0,0 +1,89 @@ +use super::*; +use markdownfs::auth::session::Session; +use markdownfs::cmd; +use markdownfs::cmd::parser; + +#[test] +fn test_permission_denied_read() { + let mut fs = VirtualFs::new(); + let mut root_session = Session::root(); + + let pipeline = parser::parse_pipeline("touch secret.md"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + let pipeline = parser::parse_pipeline("write secret.md top secret"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + let pipeline = parser::parse_pipeline("chmod 600 secret.md"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + + fs.registry.add_user("alice", false).unwrap(); + let mut alice_session = Session::new(1, 2, vec![2], "alice".to_string()); + + let pipeline = parser::parse_pipeline("cat secret.md"); + let result = cmd::execute_pipeline(&pipeline, &mut fs, &mut alice_session); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("permission denied")); +} + +#[test] +fn test_permission_visibility() { + let mut fs = VirtualFs::new(); + let mut root_session = Session::root(); + + let pipeline = parser::parse_pipeline("touch public.md"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + let pipeline = parser::parse_pipeline("touch private.md"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + let pipeline = parser::parse_pipeline("chmod 600 private.md"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + + fs.registry.add_user("alice", false).unwrap(); + let mut alice_session = Session::new(1, 2, vec![2], "alice".to_string()); + + let pipeline = parser::parse_pipeline("ls"); + let output = cmd::execute_pipeline(&pipeline, &mut fs, &mut alice_session).unwrap(); + assert!(output.contains("public.md")); + assert!(!output.contains("private.md")); +} + +#[test] +fn test_user_management() { + let mut fs = VirtualFs::new(); + let mut root_session = Session::root(); + + let pipeline = parser::parse_pipeline("adduser bob"); + let output = cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + assert!(output.contains("bob")); + assert!(output.contains("uid=")); + + let pipeline = parser::parse_pipeline("whoami"); + let output = cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + assert_eq!(output.trim(), "root"); + + let pipeline = parser::parse_pipeline("id bob"); + let output = cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + assert!(output.contains("uid=1(bob)")); + + let pipeline = parser::parse_pipeline("groups bob"); + let output = cmd::execute_pipeline(&pipeline, &mut fs, &mut root_session).unwrap(); + assert!(output.contains("bob")); +} + +#[test] +fn test_su_and_ownership() { + let mut fs = VirtualFs::new(); + let mut session = Session::root(); + + // adduser now auto-creates /home/alice owned by alice + let pipeline = parser::parse_pipeline("adduser alice"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut session).unwrap(); + + let pipeline = parser::parse_pipeline("su alice"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut session).unwrap(); + assert_eq!(session.username, "alice"); + + let pipeline = parser::parse_pipeline("touch home/alice/notes.md"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut session).unwrap(); + + let info = fs.stat("home/alice/notes.md").unwrap(); + assert_eq!(info.uid, 1); // alice's uid +} diff --git a/tests/integration/persist.rs b/tests/integration/persist.rs new file mode 100644 index 0000000000000000000000000000000000000000..87dd867caede5203dcb0b4573f7e8410b5d00675 --- /dev/null +++ b/tests/integration/persist.rs @@ -0,0 +1,180 @@ +use super::*; +use markdownfs::persist::PersistManager; +use markdownfs::vcs::Vcs; + +#[test] +fn test_persist_save_and_load() { + let tmp = std::env::temp_dir().join(format!("markdownfs_test_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + + let persist = PersistManager::new(&tmp); + + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("mkdir docs", &mut fs); + exec("touch readme.md", &mut fs); + exec("write readme.md # Hello World", &mut fs); + exec("cd docs", &mut fs); + exec("touch notes.md", &mut fs); + exec("write notes.md Some notes here", &mut fs); + exec("cd /", &mut fs); + + vcs.commit(&fs, "initial", "root").unwrap(); + + exec("touch changelog.md", &mut fs); + exec("write changelog.md ## v0.1.0", &mut fs); + vcs.commit(&fs, "add changelog", "root").unwrap(); + + persist.save(&fs, &vcs).unwrap(); + assert!(persist.state_exists()); + + let (fs2, vcs2) = persist.load().unwrap(); + + assert_eq!( + String::from_utf8_lossy(fs2.cat("readme.md").unwrap()), + "# Hello World" + ); + assert_eq!( + String::from_utf8_lossy(fs2.cat("docs/notes.md").unwrap()), + "Some notes here" + ); + assert_eq!( + String::from_utf8_lossy(fs2.cat("changelog.md").unwrap()), + "## v0.1.0" + ); + + assert_eq!(vcs2.commits.len(), 2); + assert_eq!(vcs2.commits[0].message, "initial"); + assert_eq!(vcs2.commits[1].message, "add changelog"); + assert!(vcs2.head.is_some()); + + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn test_persist_revert_after_reload() { + let tmp = std::env::temp_dir().join(format!("markdownfs_revert_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + + let persist = PersistManager::new(&tmp); + + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("touch data.md", &mut fs); + exec("write data.md version1", &mut fs); + let id1 = vcs.commit(&fs, "v1", "root").unwrap(); + + exec("write data.md version2", &mut fs); + vcs.commit(&fs, "v2", "root").unwrap(); + + persist.save(&fs, &vcs).unwrap(); + let (mut fs2, mut vcs2) = persist.load().unwrap(); + + vcs2.revert(&mut fs2, &id1.short_hex()).unwrap(); + assert_eq!( + String::from_utf8_lossy(fs2.cat("data.md").unwrap()), + "version1" + ); + + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn test_persist_empty_state() { + let tmp = std::env::temp_dir().join(format!("markdownfs_empty_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + + let persist = PersistManager::new(&tmp); + + let fs = VirtualFs::new(); + let vcs = Vcs::new(); + persist.save(&fs, &vcs).unwrap(); + + let (fs2, vcs2) = persist.load().unwrap(); + assert_eq!(fs2.pwd(), "/"); + assert!(vcs2.commits.is_empty()); + assert!(vcs2.head.is_none()); + + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn test_persist_preserves_permissions() { + let tmp = std::env::temp_dir().join(format!("markdownfs_perms_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + + let persist = PersistManager::new(&tmp); + + let mut fs = VirtualFs::new(); + let vcs = Vcs::new(); + + exec("touch secret.md", &mut fs); + exec("chmod 600 secret.md", &mut fs); + exec("mkdir restricted", &mut fs); + exec("chmod 700 restricted", &mut fs); + + persist.save(&fs, &vcs).unwrap(); + let (fs2, _) = persist.load().unwrap(); + + let stat = fs2.stat("secret.md").unwrap(); + assert_eq!(stat.mode, 0o600); + let stat = fs2.stat("restricted").unwrap(); + assert_eq!(stat.mode, 0o700); + + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn test_persist_preserves_user_registry() { + let tmp = std::env::temp_dir().join(format!("markdownfs_reg_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + + let persist = PersistManager::new(&tmp); + + let mut fs = VirtualFs::new(); + let mut root_session = Session::root(); + let vcs = Vcs::new(); + + exec_s("adduser alice", &mut fs, &mut root_session); + exec_s("addgroup devs", &mut fs, &mut root_session); + exec_s("usermod -aG devs alice", &mut fs, &mut root_session); + + persist.save(&fs, &vcs).unwrap(); + let (fs2, _) = persist.load().unwrap(); + + assert!(fs2.registry.lookup_uid("alice").is_some()); + assert!(fs2.registry.lookup_gid("devs").is_some()); + + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn test_persist_multiple_save_load_cycles() { + let tmp = std::env::temp_dir().join(format!("markdownfs_cycle_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let persist = PersistManager::new(&tmp); + + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + for i in 0..5 { + exec(&format!("touch file_{i}.md"), &mut fs); + exec( + &format!("write file_{i}.md content {i}"), + &mut fs, + ); + vcs.commit(&fs, &format!("commit {i}"), "root").unwrap(); + persist.save(&fs, &vcs).unwrap(); + + let (loaded_fs, loaded_vcs) = persist.load().unwrap(); + assert_eq!(loaded_vcs.commits.len(), i + 1); + assert_eq!( + String::from_utf8_lossy(loaded_fs.cat(&format!("file_{i}.md")).unwrap()), + format!("content {i}") + ); + } + + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/tests/integration/pipes.rs b/tests/integration/pipes.rs new file mode 100644 index 0000000000000000000000000000000000000000..354b69cacd6a522b3f7d0ab5a78748d782fc01a9 --- /dev/null +++ b/tests/integration/pipes.rs @@ -0,0 +1,94 @@ +use super::*; + +#[test] +fn test_pipes() { + let mut fs = VirtualFs::new(); + let output = exec("echo hello world | wc -w", &mut fs); + assert_eq!(output.trim(), "2"); +} + +#[test] +fn test_head_tail() { + let mut fs = VirtualFs::new(); + exec("touch data.md", &mut fs); + exec("write data.md 1\n2\n3\n4\n5", &mut fs); + let head = exec("cat data.md | head -2", &mut fs); + assert_eq!(head.trim(), "1\n2"); + let tail = exec("cat data.md | tail -2", &mut fs); + assert_eq!(tail.trim(), "4\n5"); +} + +#[test] +fn test_grep_pipe() { + let mut fs = VirtualFs::new(); + exec("touch log.md", &mut fs); + exec( + "write log.md ERROR: disk full\nINFO: started\nERROR: timeout\nINFO: done", + &mut fs, + ); + let output = exec("cat log.md | grep ERROR | wc -l", &mut fs); + assert_eq!(output.trim(), "2"); +} + +#[test] +fn test_echo_pipe_write() { + let mut fs = VirtualFs::new(); + exec("touch out.md", &mut fs); + exec("echo hello from pipe | write out.md", &mut fs); + let content = exec("cat out.md", &mut fs); + assert_eq!(content.trim(), "hello from pipe"); +} + +#[test] +fn test_pipe_head_tail_combined() { + let mut fs = VirtualFs::new(); + exec("touch nums.md", &mut fs); + let lines: Vec = (1..=20).map(|i| format!("{i}")).collect(); + exec( + &format!("write nums.md {}", lines.join("\n")), + &mut fs, + ); + let output = exec("cat nums.md | head -10 | tail -3", &mut fs); + assert!(output.contains("8")); + assert!(output.contains("9")); + assert!(output.contains("10")); +} + +#[test] +fn test_wc_lines() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + exec("write file.md a\nb\nc\nd\ne", &mut fs); + let output = exec("cat file.md | wc -l", &mut fs); + assert_eq!(output.trim(), "5"); +} + +#[test] +fn test_wc_words() { + let mut fs = VirtualFs::new(); + let output = exec("echo one two three four five | wc -w", &mut fs); + assert_eq!(output.trim(), "5"); +} + +#[test] +fn test_echo() { + let mut fs = VirtualFs::new(); + let output = exec("echo hello world", &mut fs); + assert_eq!(output.trim(), "hello world"); +} + +#[test] +fn test_echo_empty() { + let mut fs = VirtualFs::new(); + let output = exec("echo", &mut fs); + assert!(output.trim().is_empty()); +} + +#[test] +fn test_help() { + let mut fs = VirtualFs::new(); + let output = exec("help", &mut fs); + assert!(output.contains("ls")); + assert!(output.contains("mkdir")); + assert!(output.contains("commit")); +} diff --git a/tests/integration/posix.rs b/tests/integration/posix.rs new file mode 100644 index 0000000000000000000000000000000000000000..a0b7c0bd32424fe9c0f6db2f2fc27d7d33a149cb --- /dev/null +++ b/tests/integration/posix.rs @@ -0,0 +1,99 @@ +use markdownfs::auth::session::Session; +use markdownfs::posix::{PosixFs, PosixSetAttr}; + +use super::*; + +#[test] +fn test_posix_mode_allows_non_markdown_regular_files() { + let mut fs = VirtualFs::new_posix(); + let root = Session::root(); + let mut posix = PosixFs::new(&mut fs, &root); + + let _fh = posix.create("/notes.txt", 0o644).unwrap(); + posix.write("/notes.txt", 0, b"plain text").unwrap(); + + let stat = posix.getattr("/notes.txt").unwrap(); + assert_eq!(stat.kind, "file"); + assert_eq!(stat.size, 10); +} + +#[test] +fn test_posix_offset_io_and_truncate() { + let mut fs = VirtualFs::new_posix(); + let root = Session::root(); + let mut posix = PosixFs::new(&mut fs, &root); + + let _fh = posix.create("/blob.bin", 0o644).unwrap(); + posix.write("/blob.bin", 0, b"hello world").unwrap(); + posix.write("/blob.bin", 6, b"POSIX").unwrap(); + + assert_eq!(posix.read("/blob.bin", 0, 11).unwrap(), b"hello POSIX"); + + posix.truncate("/blob.bin", 5).unwrap(); + assert_eq!(posix.read("/blob.bin", 0, 16).unwrap(), b"hello"); + + let stat = posix.getattr("/blob.bin").unwrap(); + assert_eq!(stat.size, 5); +} + +#[test] +fn test_posix_hard_links_update_nlink() { + let mut fs = VirtualFs::new_posix(); + let root = Session::root(); + let mut posix = PosixFs::new(&mut fs, &root); + + let _fh = posix.create("/alpha.txt", 0o644).unwrap(); + posix.write("/alpha.txt", 0, b"same inode").unwrap(); + posix.link("/alpha.txt", "/beta.txt").unwrap(); + + let alpha = posix.getattr("/alpha.txt").unwrap(); + let beta = posix.getattr("/beta.txt").unwrap(); + assert_eq!(alpha.inode_id, beta.inode_id); + assert_eq!(alpha.nlink, 2); + + posix.unlink("/alpha.txt").unwrap(); + assert_eq!(posix.read("/beta.txt", 0, 32).unwrap(), b"same inode"); + assert_eq!(posix.getattr("/beta.txt").unwrap().nlink, 1); +} + +#[test] +fn test_posix_open_handle_survives_unlink_until_release() { + let mut fs = VirtualFs::new_posix(); + let root = Session::root(); + + { + let mut posix = PosixFs::new(&mut fs, &root); + let fh = posix.create("/temp.log", 0o644).unwrap(); + posix.write("/temp.log", 0, b"orphaned").unwrap(); + posix.unlink("/temp.log").unwrap(); + assert_eq!(posix.read_handle(fh, 32).unwrap(), b"orphaned"); + posix.release(fh).unwrap(); + } + + assert!(fs.resolve_path("/temp.log").is_err()); +} + +#[test] +fn test_posix_setattr_updates_mode_and_size() { + let mut fs = VirtualFs::new_posix(); + let root = Session::root(); + let mut posix = PosixFs::new(&mut fs, &root); + + let _fh = posix.create("/state.json", 0o644).unwrap(); + posix.write("/state.json", 0, br#"{"ok":true}"#).unwrap(); + + let stat = posix + .setattr( + "/state.json", + PosixSetAttr { + mode: Some(0o600), + size: Some(5), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(stat.mode, 0o600); + assert_eq!(stat.size, 5); + assert_eq!(posix.read("/state.json", 0, 16).unwrap(), br#"{"ok""#); +} diff --git a/tests/integration/rm_mv_cp.rs b/tests/integration/rm_mv_cp.rs new file mode 100644 index 0000000000000000000000000000000000000000..5907b10bc101acf8c915f2a41d3e66acb5dc266f --- /dev/null +++ b/tests/integration/rm_mv_cp.rs @@ -0,0 +1,131 @@ +use super::*; + +#[test] +fn test_rm_file() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + assert!(exec("ls", &mut fs).contains("file.md")); + exec("rm file.md", &mut fs); + assert!(!exec("ls", &mut fs).contains("file.md")); +} + +#[test] +fn test_rm_nonexistent_file_fails() { + let mut fs = VirtualFs::new(); + let err = exec_err("rm ghost.md", &mut fs); + assert!(err.contains("no such file")); +} + +#[test] +fn test_rm_directory_fails_without_r() { + let mut fs = VirtualFs::new(); + exec("mkdir dir", &mut fs); + let err = exec_err("rm dir", &mut fs); + assert!(err.contains("is a directory")); +} + +#[test] +fn test_rm_rf() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b", &mut fs); + exec("cd a/b", &mut fs); + exec("touch x.md", &mut fs); + exec("cd /", &mut fs); + exec("rm -r a", &mut fs); + assert!(!exec("ls", &mut fs).contains("a/")); +} + +#[test] +fn test_rm_rf_deep_tree() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b/c/d", &mut fs); + exec("cd a/b/c/d", &mut fs); + exec("touch deep.md", &mut fs); + exec("cd /a/b/c", &mut fs); + exec("touch mid.md", &mut fs); + exec("cd /a/b", &mut fs); + exec("touch upper.md", &mut fs); + exec("cd /", &mut fs); + exec("rm -r a", &mut fs); + assert!(!exec("ls", &mut fs).contains("a")); +} + +#[test] +fn test_mv() { + let mut fs = VirtualFs::new(); + exec("touch old.md", &mut fs); + exec("mv old.md new.md", &mut fs); + let listing = exec("ls", &mut fs); + assert!(!listing.contains("old.md")); + assert!(listing.contains("new.md")); +} + +#[test] +fn test_mv_preserves_content() { + let mut fs = VirtualFs::new(); + exec("touch src.md", &mut fs); + exec("write src.md important data", &mut fs); + exec("mv src.md dst.md", &mut fs); + assert_eq!(exec("cat dst.md", &mut fs), "important data"); +} + +#[test] +fn test_mv_into_directory() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + exec("write file.md content", &mut fs); + exec("mkdir dir", &mut fs); + exec("mv file.md dir/file.md", &mut fs); + assert!(!exec("ls", &mut fs).contains("file.md")); + assert_eq!(exec("cat dir/file.md", &mut fs), "content"); +} + +#[test] +fn test_mv_directory() { + let mut fs = VirtualFs::new(); + exec("mkdir src", &mut fs); + exec("mkdir dst", &mut fs); + exec("mv src dst", &mut fs); + let ls = exec("ls dst", &mut fs); + assert!(ls.contains("src/")); +} + +#[test] +fn test_cp() { + let mut fs = VirtualFs::new(); + exec("touch a.md", &mut fs); + exec("write a.md content", &mut fs); + exec("cp a.md b.md", &mut fs); + let output = exec("cat b.md", &mut fs); + assert_eq!(output, "content"); +} + +#[test] +fn test_cp_independent_copy() { + let mut fs = VirtualFs::new(); + exec("touch a.md", &mut fs); + exec("write a.md original", &mut fs); + exec("cp a.md b.md", &mut fs); + exec("write a.md modified", &mut fs); + assert_eq!(exec("cat a.md", &mut fs), "modified"); + assert_eq!(exec("cat b.md", &mut fs), "original"); +} + +#[test] +fn test_cp_into_directory() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + exec("write file.md content", &mut fs); + exec("mkdir dir", &mut fs); + exec("cp file.md dir/file.md", &mut fs); + assert_eq!(exec("cat dir/file.md", &mut fs), "content"); + // Original still exists + assert_eq!(exec("cat file.md", &mut fs), "content"); +} + +#[test] +fn test_cp_nonexistent_src_fails() { + let mut fs = VirtualFs::new(); + let err = exec_err("cp ghost.md dest.md", &mut fs); + assert!(err.contains("no such file")); +} diff --git a/tests/integration/search.rs b/tests/integration/search.rs new file mode 100644 index 0000000000000000000000000000000000000000..b8527f48be38abef6c510d9bddd5ac5ef6c421df --- /dev/null +++ b/tests/integration/search.rs @@ -0,0 +1,91 @@ +use super::*; + +#[test] +fn test_find() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b", &mut fs); + exec("cd a", &mut fs); + exec("touch x.md", &mut fs); + exec("cd b", &mut fs); + exec("touch y.md", &mut fs); + exec("cd /", &mut fs); + let output = exec("find . -name *.md", &mut fs); + assert!(output.contains("x.md")); + assert!(output.contains("y.md")); +} + +#[test] +fn test_find_no_pattern() { + let mut fs = VirtualFs::new(); + exec("mkdir dir", &mut fs); + exec("cd dir", &mut fs); + exec("touch a.md", &mut fs); + exec("cd /", &mut fs); + let output = exec("find .", &mut fs); + assert!(output.contains("dir")); + assert!(output.contains("a.md")); +} + +#[test] +fn test_find_specific_name() { + let mut fs = VirtualFs::new(); + exec("mkdir -p src/utils", &mut fs); + exec("cd src", &mut fs); + exec("touch main.md", &mut fs); + exec("cd utils", &mut fs); + exec("touch helpers.md", &mut fs); + exec("cd /", &mut fs); + let output = exec("find . -name helpers.md", &mut fs); + assert!(output.contains("helpers.md")); + assert!(!output.contains("main.md")); +} + +#[test] +fn test_grep() { + let mut fs = VirtualFs::new(); + exec("touch notes.md", &mut fs); + exec("write notes.md TODO: fix this\nDONE: that\nTODO: another", &mut fs); + let output = exec("grep TODO notes.md", &mut fs); + assert!(output.contains("TODO: fix this")); + assert!(output.contains("TODO: another")); + assert!(!output.contains("DONE")); +} + +#[test] +fn test_grep_recursive() { + let mut fs = VirtualFs::new(); + exec("mkdir -p a/b", &mut fs); + exec("touch a.md", &mut fs); + exec("write a.md ERROR: top level", &mut fs); + exec("cd a", &mut fs); + exec("touch mid.md", &mut fs); + exec("write mid.md INFO: middle level", &mut fs); + exec("cd b", &mut fs); + exec("touch deep.md", &mut fs); + exec("write deep.md ERROR: deep level", &mut fs); + exec("cd /", &mut fs); + let output = exec("grep -r ERROR .", &mut fs); + assert!(output.contains("a.md")); + assert!(output.contains("deep.md")); + assert!(!output.contains("mid.md")); +} + +#[test] +fn test_grep_no_match() { + let mut fs = VirtualFs::new(); + exec("touch file.md", &mut fs); + exec("write file.md hello world", &mut fs); + let output = exec("grep NONEXISTENT file.md", &mut fs); + assert!(output.trim().is_empty()); +} + +#[test] +fn test_grep_regex() { + let mut fs = VirtualFs::new(); + exec("touch data.md", &mut fs); + exec("write data.md alpha 123\nbeta abc\ngamma 456", &mut fs); + let output = exec("grep [0-9]{3} data.md", &mut fs); + assert!(output.contains("alpha 123")); + assert!(output.contains("gamma 456")); + assert!(!output.contains("beta abc")); +} diff --git a/tests/integration/symlinks.rs b/tests/integration/symlinks.rs new file mode 100644 index 0000000000000000000000000000000000000000..58db790a32bdd607ce404017fcb1efeb738c8ae9 --- /dev/null +++ b/tests/integration/symlinks.rs @@ -0,0 +1,40 @@ +use super::*; + +#[test] +fn test_symlink() { + let mut fs = VirtualFs::new(); + exec("touch target.md", &mut fs); + exec("write target.md linked content", &mut fs); + exec("ln -s target.md link.md", &mut fs); + let output = exec("cat link.md", &mut fs); + assert_eq!(output, "linked content"); +} + +#[test] +fn test_symlink_stat() { + let mut fs = VirtualFs::new(); + exec("touch target.md", &mut fs); + exec("ln -s target.md link.md", &mut fs); + let stat = exec("stat link.md", &mut fs); + assert!(stat.contains("symlink")); +} + +#[test] +fn test_symlink_write_through() { + let mut fs = VirtualFs::new(); + exec("touch target.md", &mut fs); + exec("write target.md original", &mut fs); + exec("ln -s target.md link.md", &mut fs); + exec("write link.md updated", &mut fs); + // Reading via original path should reflect the update + assert_eq!(exec("cat target.md", &mut fs), "updated"); +} + +#[test] +fn test_symlink_duplicate_fails() { + let mut fs = VirtualFs::new(); + exec("touch target.md", &mut fs); + exec("ln -s target.md link.md", &mut fs); + let err = exec_err("ln -s target.md link.md", &mut fs); + assert!(err.contains("already exists")); +} diff --git a/tests/integration/vcs.rs b/tests/integration/vcs.rs new file mode 100644 index 0000000000000000000000000000000000000000..72005d4335e5b157b22419bcb18a38775facd02d --- /dev/null +++ b/tests/integration/vcs.rs @@ -0,0 +1,174 @@ +use super::*; +use markdownfs::vcs::Vcs; + +#[test] +fn test_commit_and_log() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("touch readme.md", &mut fs); + exec("write readme.md # Hello", &mut fs); + + let id = vcs.commit(&fs, "initial commit", "root").unwrap(); + assert!(!id.to_hex().is_empty()); + + let log = vcs.log(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].message, "initial commit"); + assert_eq!(log[0].author, "root"); +} + +#[test] +fn test_commit_and_revert() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("touch a.md", &mut fs); + exec("write a.md version1", &mut fs); + let id1 = vcs.commit(&fs, "v1", "root").unwrap(); + + exec("write a.md version2", &mut fs); + exec("touch b.md", &mut fs); + exec("write b.md extra", &mut fs); + let _id2 = vcs.commit(&fs, "v2", "root").unwrap(); + + assert_eq!(exec("cat a.md", &mut fs), "version2"); + assert!(exec("ls", &mut fs).contains("b.md")); + + vcs.revert(&mut fs, &id1.short_hex()).unwrap(); + + assert_eq!(exec("cat a.md", &mut fs), "version1"); + assert!(!exec("ls", &mut fs).contains("b.md")); +} + +#[test] +fn test_multiple_commits_and_log() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("touch a.md", &mut fs); + vcs.commit(&fs, "first", "root").unwrap(); + + exec("touch b.md", &mut fs); + vcs.commit(&fs, "second", "root").unwrap(); + + exec("touch c.md", &mut fs); + vcs.commit(&fs, "third", "root").unwrap(); + + let log = vcs.log(); + assert_eq!(log.len(), 3); + assert_eq!(log[0].message, "third"); + assert_eq!(log[1].message, "second"); + assert_eq!(log[2].message, "first"); +} + +#[test] +fn test_deduplication() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("touch a.md", &mut fs); + exec("write a.md same content", &mut fs); + exec("touch b.md", &mut fs); + exec("write b.md same content", &mut fs); + + vcs.commit(&fs, "dedup test", "root").unwrap(); + + let count = vcs.store.object_count(); + assert!(count > 0); +} + +#[test] +fn test_revert_to_first_of_many_commits() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("touch file.md", &mut fs); + exec("write file.md v1", &mut fs); + let id1 = vcs.commit(&fs, "c1", "root").unwrap(); + + for i in 2..=10 { + exec(&format!("write file.md v{i}"), &mut fs); + vcs.commit(&fs, &format!("c{i}"), "root").unwrap(); + } + + assert_eq!(exec("cat file.md", &mut fs), "v10"); + vcs.revert(&mut fs, &id1.short_hex()).unwrap(); + assert_eq!(exec("cat file.md", &mut fs), "v1"); +} + +#[test] +fn test_commit_with_directories_and_revert() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("mkdir -p src/lib", &mut fs); + exec("cd src/lib", &mut fs); + exec("touch module.md", &mut fs); + exec("write module.md # Module", &mut fs); + exec("cd /", &mut fs); + exec("touch readme.md", &mut fs); + exec("write readme.md # Project", &mut fs); + + let id1 = vcs.commit(&fs, "project structure", "root").unwrap(); + + exec("rm -r src", &mut fs); + exec("write readme.md # Changed", &mut fs); + vcs.commit(&fs, "destructive change", "root").unwrap(); + + vcs.revert(&mut fs, &id1.short_hex()).unwrap(); + + assert_eq!(exec("cat readme.md", &mut fs), "# Project"); + assert_eq!(exec("cat src/lib/module.md", &mut fs), "# Module"); +} + +#[test] +fn test_commit_preserves_author() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("touch file.md", &mut fs); + vcs.commit(&fs, "by alice", "alice").unwrap(); + vcs.commit(&fs, "by bob", "bob").unwrap(); + + let log = vcs.log(); + assert_eq!(log[0].author, "bob"); + assert_eq!(log[1].author, "alice"); +} + +#[test] +fn test_dedup_identical_files() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + let content = "# Same template\n\nIdentical everywhere.\n"; + for i in 0..100 { + let path = format!("dup_{i:03}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + + vcs.commit(&fs, "dedup test", "root").unwrap(); + let count = vcs.store.object_count(); + // 1 blob + 1 tree + 1 commit = 3 objects (not 100+) + assert!(count < 10, "expected dedup, got {count} objects for 100 identical files"); +} + +#[test] +fn test_dedup_after_modification() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + exec("touch a.md", &mut fs); + exec("write a.md shared", &mut fs); + exec("touch b.md", &mut fs); + exec("write b.md shared", &mut fs); + vcs.commit(&fs, "c1", "root").unwrap(); + + exec("write a.md different", &mut fs); + vcs.commit(&fs, "c2", "root").unwrap(); + + // a.md has a new blob, b.md still references the old one + let count = vcs.store.object_count(); + assert!(count > 3, "should have new blob for 'different'"); +} diff --git a/tests/integration/workflows.rs b/tests/integration/workflows.rs new file mode 100644 index 0000000000000000000000000000000000000000..dc9ceae616053478d32c3d9af9e910a9a169f395 --- /dev/null +++ b/tests/integration/workflows.rs @@ -0,0 +1,90 @@ +use super::*; +use markdownfs::auth::session::Session; +use markdownfs::vcs::Vcs; + +#[test] +fn test_full_project_workflow() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + // Setup project + exec("mkdir -p src/lib", &mut fs); + exec("mkdir -p docs/api", &mut fs); + exec("mkdir tests", &mut fs); + + exec("cd src", &mut fs); + exec("touch main.md", &mut fs); + exec("write main.md # Main entry point", &mut fs); + exec("cd lib", &mut fs); + exec("touch utils.md", &mut fs); + exec("write utils.md # Utility functions\n\n## Helpers\n\n- parse()\n- format()", &mut fs); + exec("cd /docs/api", &mut fs); + exec("touch endpoints.md", &mut fs); + exec("write endpoints.md # API Endpoints\n\n## GET /users\n## POST /users", &mut fs); + exec("cd /tests", &mut fs); + exec("touch test_api.md", &mut fs); + exec("write test_api.md # API Tests\n\n- [ ] Test GET\n- [ ] Test POST", &mut fs); + exec("cd /", &mut fs); + exec("touch readme.md", &mut fs); + exec("write readme.md # My Project\n\nA cool project.", &mut fs); + + let id1 = vcs.commit(&fs, "initial project setup", "root").unwrap(); + + // Verify tree + let tree = exec("tree", &mut fs); + assert!(tree.contains("src/")); + assert!(tree.contains("docs/")); + assert!(tree.contains("tests/")); + + // Verify find + let found = exec("find . -name *.md", &mut fs); + assert_eq!(found.lines().count(), 5); // main, utils, endpoints, test_api, readme + + // Verify grep + let grep = exec("grep -r API .", &mut fs); + assert!(grep.contains("endpoints.md")); + assert!(grep.contains("test_api.md")); + + // Make changes + exec("write readme.md # My Project v2\n\nImproved.", &mut fs); + vcs.commit(&fs, "update readme", "root").unwrap(); + + // Revert + vcs.revert(&mut fs, &id1.short_hex()).unwrap(); + assert!(exec("cat readme.md", &mut fs).contains("A cool project.")); +} + +#[test] +fn test_multiuser_workflow() { + let mut fs = VirtualFs::new(); + let mut session = Session::root(); + + // Admin creates users + exec_s("adduser alice", &mut fs, &mut session); + exec_s("adduser bob", &mut fs, &mut session); + exec_s("addgroup engineering", &mut fs, &mut session); + exec_s("usermod -aG engineering alice", &mut fs, &mut session); + exec_s("usermod -aG engineering bob", &mut fs, &mut session); + + // Setup shared space + exec_s("mkdir shared", &mut fs, &mut session); + exec_s("chown root:engineering shared", &mut fs, &mut session); + exec_s("chmod 2775 shared", &mut fs, &mut session); + + // Switch to alice + exec_s("su alice", &mut fs, &mut session); + assert_eq!(session.username, "alice"); + exec_s("touch shared/alice-work.md", &mut fs, &mut session); + exec_s("write shared/alice-work.md Alice design notes", &mut fs, &mut session); + exec_s("chmod 664 shared/alice-work.md", &mut fs, &mut session); + + // Switch back to root, then to bob + session = Session::root(); + exec_s("su bob", &mut fs, &mut session); + assert_eq!(session.username, "bob"); + exec_s("touch shared/bob-work.md", &mut fs, &mut session); + + // Bob can read alice's work (group permission via setgid inheritance) + let content = exec_s("cat shared/alice-work.md", &mut fs, &mut session); + assert!(content.contains("Alice design notes")); +} diff --git a/tests/perf/creation.rs b/tests/perf/creation.rs new file mode 100644 index 0000000000000000000000000000000000000000..41bc256a5d8d9820650cf442afc6893854e39b8f --- /dev/null +++ b/tests/perf/creation.rs @@ -0,0 +1,73 @@ +use super::*; + +#[test] +fn perf_file_creation_10k() { + let mut fs = VirtualFs::new(); + let count = 10_000; + + let start = Instant::now(); + for i in 0..count { + let path = format!("file_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + } + let elapsed = start.elapsed(); + + print_result("touch (10K files, flat dir)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_file_creation_nested_10k() { + let mut fs = VirtualFs::new(); + let count = 10_000; + + for i in 0..100 { + fs.mkdir(&format!("dir_{i:03}"), 0, 0).unwrap(); + } + + let start = Instant::now(); + for i in 0..count { + let dir = i % 100; + let path = format!("dir_{dir:03}/file_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + } + let elapsed = start.elapsed(); + + print_result("touch (10K files, 100 dirs)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_file_creation_50k_flat() { + let mut fs = VirtualFs::new(); + let count = 50_000; + + let start = Instant::now(); + for i in 0..count { + fs.touch(&format!("f_{i:06}.md"), 0, 0).unwrap(); + } + let elapsed = start.elapsed(); + + print_result("touch (50K files, flat dir)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(30), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_mkdir_p_deep() { + let mut fs = VirtualFs::new(); + let count = 100; + + let start = Instant::now(); + for i in 0..count { + let path = format!("root_{i}/a/b/c/d/e/f/g/h/i/j"); + fs.mkdir_p(&path, 0, 0).unwrap(); + } + let elapsed = start.elapsed(); + + print_result( + &format!("mkdir -p (depth=10, {count} trees)"), + count, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} diff --git a/tests/perf/delete_mv_cp.rs b/tests/perf/delete_mv_cp.rs new file mode 100644 index 0000000000000000000000000000000000000000..695a368522de555886193e210246455311a21875 --- /dev/null +++ b/tests/perf/delete_mv_cp.rs @@ -0,0 +1,94 @@ +use super::*; + +#[test] +fn perf_rm_10k() { + let mut fs = VirtualFs::new(); + let count = 10_000; + + for i in 0..count { + fs.touch(&format!("f_{i:05}.md"), 0, 0).unwrap(); + } + + let start = Instant::now(); + for i in 0..count { + fs.rm(&format!("f_{i:05}.md")).unwrap(); + } + let elapsed = start.elapsed(); + + print_result("rm (10K file deletions)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); + + let entries = fs.ls(None).unwrap(); + assert!(entries.is_empty()); +} + +#[test] +fn perf_rm_rf_tree() { + let mut fs = VirtualFs::new(); + + // Create 100 dirs x 10 files each + for d in 0..100 { + fs.mkdir(&format!("d_{d:03}"), 0, 0).unwrap(); + for f in 0..10 { + fs.touch(&format!("d_{d:03}/f_{f}.md"), 0, 0).unwrap(); + } + } + + let start = Instant::now(); + for d in 0..100 { + fs.rm_rf(&format!("d_{d:03}")).unwrap(); + } + let elapsed = start.elapsed(); + + print_result("rm -r (100 dirs x 10 files each)", 100, elapsed); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_mv_1k() { + let mut fs = VirtualFs::new(); + let count = 1_000; + + fs.mkdir("src", 0, 0).unwrap(); + fs.mkdir("dst", 0, 0).unwrap(); + + for i in 0..count { + fs.touch(&format!("src/f_{i:04}.md"), 0, 0).unwrap(); + } + + let start = Instant::now(); + for i in 0..count { + fs.mv(&format!("src/f_{i:04}.md"), &format!("dst/f_{i:04}.md")).unwrap(); + } + let elapsed = start.elapsed(); + + print_result("mv (1K file renames)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); + + assert!(fs.ls(Some("src")).unwrap().is_empty()); + assert_eq!(fs.ls(Some("dst")).unwrap().len(), count); +} + +#[test] +fn perf_cp_1k() { + let mut fs = VirtualFs::new(); + let count = 1_000; + + for i in 0..count { + let path = format!("orig_{i:04}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, format!("Content {i}").into_bytes()).unwrap(); + } + + fs.mkdir("copies", 0, 0).unwrap(); + + let start = Instant::now(); + for i in 0..count { + fs.cp(&format!("orig_{i:04}.md"), &format!("copies/copy_{i:04}.md"), 0, 0).unwrap(); + } + let elapsed = start.elapsed(); + + print_result("cp (1K file copies)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); + assert_eq!(fs.ls(Some("copies")).unwrap().len(), count); +} diff --git a/tests/perf/io.rs b/tests/perf/io.rs new file mode 100644 index 0000000000000000000000000000000000000000..73e439cdb223402b38c3a2802e4cfcbe76f5ee58 --- /dev/null +++ b/tests/perf/io.rs @@ -0,0 +1,139 @@ +use super::*; + +#[test] +fn perf_write_small_files_10k() { + let mut fs = VirtualFs::new(); + let count = 10_000; + + for i in 0..count { + fs.touch(&format!("f_{i:05}.md"), 0, 0).unwrap(); + } + + let content = "# Title\n\nSome markdown content.\n\n- Item 1\n- Item 2\n- Item 3\n"; + + let start = Instant::now(); + for i in 0..count { + let path = format!("f_{i:05}.md"); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + let elapsed = start.elapsed(); + + print_result("write (10K files, 60B each)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_write_large_files_1k() { + let mut fs = VirtualFs::new(); + let count = 1_000; + + let mut content = String::with_capacity(10_240); + for i in 0..200 { + content.push_str(&format!("## Section {i}\n\nLorem ipsum dolor sit amet.\n\n")); + } + + for i in 0..count { + fs.touch(&format!("big_{i:04}.md"), 0, 0).unwrap(); + } + + let start = Instant::now(); + for i in 0..count { + let path = format!("big_{i:04}.md"); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + let elapsed = start.elapsed(); + + let total_mb = (count * content.len()) as f64 / (1024.0 * 1024.0); + print_result( + &format!("write (1K files, 10KB each, {total_mb:.1}MB total)"), + count, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_write_100kb_files() { + let mut fs = VirtualFs::new(); + let count = 100; + + let mut content = String::with_capacity(100_000); + for i in 0..2000 { + content.push_str(&format!("## Section {i}\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.\n\n")); + } + + for i in 0..count { + fs.touch(&format!("huge_{i:03}.md"), 0, 0).unwrap(); + } + + let start = Instant::now(); + for i in 0..count { + fs.write_file(&format!("huge_{i:03}.md"), content.as_bytes().to_vec()).unwrap(); + } + let elapsed = start.elapsed(); + + let total_mb = (count * content.len()) as f64 / (1024.0 * 1024.0); + print_result( + &format!("write (100 files, ~100KB each, {total_mb:.1}MB total)"), + count, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_overwrite_same_file_10k() { + let mut fs = VirtualFs::new(); + fs.touch("target.md", 0, 0).unwrap(); + let count = 10_000; + + let start = Instant::now(); + for i in 0..count { + fs.write_file("target.md", format!("version {i}\n").into_bytes()).unwrap(); + } + let elapsed = start.elapsed(); + + print_result("overwrite same file (10K times)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_cat_10k() { + let mut fs = VirtualFs::new(); + let count = 10_000; + let content = "# Hello\n\nWorld\n"; + + for i in 0..count { + let path = format!("r_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + + let start = Instant::now(); + for i in 0..count { + let path = format!("r_{i:05}.md"); + let data = fs.cat(&path).unwrap(); + assert_eq!(data.len(), content.len()); + } + let elapsed = start.elapsed(); + + print_result("cat (10K file reads)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_cat_same_file_100k() { + let mut fs = VirtualFs::new(); + fs.touch("hot.md", 0, 0).unwrap(); + fs.write_file("hot.md", b"# Hot path content\n\nFrequently read.\n".to_vec()).unwrap(); + + let count = 100_000; + let start = Instant::now(); + for _ in 0..count { + let _ = fs.cat("hot.md").unwrap(); + } + let elapsed = start.elapsed(); + + print_result("cat same file (100K reads, hot path)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} diff --git a/tests/perf/listing.rs b/tests/perf/listing.rs new file mode 100644 index 0000000000000000000000000000000000000000..42de157709ef1dd2110099488bce08828d1039c9 --- /dev/null +++ b/tests/perf/listing.rs @@ -0,0 +1,108 @@ +use super::*; + +#[test] +fn perf_ls_large_dir() { + let mut fs = VirtualFs::new(); + let file_count = 10_000; + + for i in 0..file_count { + fs.touch(&format!("f_{i:05}.md"), 0, 0).unwrap(); + } + + let iterations = 100; + let start = Instant::now(); + for _ in 0..iterations { + let entries = fs.ls(None).unwrap(); + assert_eq!(entries.len(), file_count); + } + let elapsed = start.elapsed(); + + print_result( + &format!("ls (dir with {file_count} entries, {iterations}x)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_ls_many_dirs() { + let mut fs = VirtualFs::new(); + let dir_count = 100; + let files_per_dir = 50; + + for d in 0..dir_count { + fs.mkdir(&format!("dir_{d:03}"), 0, 0).unwrap(); + for f in 0..files_per_dir { + fs.touch(&format!("dir_{d:03}/f_{f:03}.md"), 0, 0).unwrap(); + } + } + + let iterations = 50; + let start = Instant::now(); + for _ in 0..iterations { + for d in 0..dir_count { + let entries = fs.ls(Some(&format!("dir_{d:03}"))).unwrap(); + assert_eq!(entries.len(), files_per_dir); + } + } + let elapsed = start.elapsed(); + + print_result( + &format!("ls ({dir_count} dirs x {files_per_dir} files, {iterations}x)"), + iterations * dir_count, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(30), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_deep_path_resolution() { + let mut fs = VirtualFs::new(); + let depth = 50; + + let mut path = String::new(); + for i in 0..depth { + if i > 0 { + path.push('/'); + } + path.push_str(&format!("d{i}")); + } + fs.mkdir_p(&path, 0, 0).unwrap(); + let file_path = format!("{path}/deep.md"); + fs.touch(&file_path, 0, 0).unwrap(); + fs.write_file(&file_path, b"deep content".to_vec()).unwrap(); + + let iterations = 10_000; + let start = Instant::now(); + for _ in 0..iterations { + let data = fs.cat(&file_path).unwrap(); + assert_eq!(data, b"deep content"); + } + let elapsed = start.elapsed(); + + print_result( + &format!("resolve path (depth={depth}, {iterations}x)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_shallow_path_resolution_100k() { + let mut fs = VirtualFs::new(); + fs.mkdir("src", 0, 0).unwrap(); + fs.touch("src/main.md", 0, 0).unwrap(); + fs.write_file("src/main.md", b"# Main".to_vec()).unwrap(); + + let count = 100_000; + let start = Instant::now(); + for _ in 0..count { + let _ = fs.cat("src/main.md").unwrap(); + } + let elapsed = start.elapsed(); + + print_result("resolve path (depth=1, 100K)", count, elapsed); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} diff --git a/tests/perf/main.rs b/tests/perf/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..817ba736964be0857093e02f66313004664d6653 --- /dev/null +++ b/tests/perf/main.rs @@ -0,0 +1,49 @@ +use markdownfs::auth::session::Session; +use markdownfs::cmd; +use markdownfs::cmd::parser; +use markdownfs::fs::VirtualFs; +use std::time::Instant; + +pub fn exec(line: &str, fs: &mut VirtualFs) -> String { + let pipeline = parser::parse_pipeline(line); + let mut session = Session::root(); + cmd::execute_pipeline(&pipeline, fs, &mut session).unwrap() +} + +pub fn format_rate(count: usize, elapsed: std::time::Duration) -> String { + let per_sec = count as f64 / elapsed.as_secs_f64(); + if per_sec > 1_000_000.0 { + format!("{:.2}M ops/sec", per_sec / 1_000_000.0) + } else if per_sec > 1_000.0 { + format!("{:.2}K ops/sec", per_sec / 1_000.0) + } else { + format!("{:.2} ops/sec", per_sec) + } +} + +pub fn print_result(name: &str, count: usize, elapsed: std::time::Duration) { + let per_op = elapsed / count as u32; + println!( + " {:<50} {:>8} ops in {:>8.2?} ({}, {:.2?}/op)", + name, + count, + elapsed, + format_rate(count, elapsed), + per_op, + ); +} + +pub fn debug_limit(secs: u64) -> u64 { + if cfg!(debug_assertions) { secs * 6 } else { secs } +} + +mod creation; +mod io; +mod listing; +mod search; +mod vcs; +mod persistence; +mod pipes; +mod workloads; +mod delete_mv_cp; +mod perms; diff --git a/tests/perf/perms.rs b/tests/perf/perms.rs new file mode 100644 index 0000000000000000000000000000000000000000..ee0e32460fb3fbc4df6253a86fab804d4c6c088a --- /dev/null +++ b/tests/perf/perms.rs @@ -0,0 +1,75 @@ +use super::*; + +#[test] +fn perf_permission_checked_ls() { + let mut fs = VirtualFs::new(); + let mut root = Session::root(); + + let pipeline = parser::parse_pipeline("adduser alice"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root).unwrap(); + + let alice_user = fs.registry.get_user(1).unwrap(); + let mut alice = Session::new( + alice_user.uid, + alice_user.groups[0], + alice_user.groups.clone(), + alice_user.name.clone(), + ); + + for i in 0..1000 { + fs.touch(&format!("f_{i:04}.md"), 0, 0).unwrap(); + } + + let iterations = 100; + let start = Instant::now(); + for _ in 0..iterations { + let pipeline = parser::parse_pipeline("ls"); + let _ = cmd::execute_pipeline(&pipeline, &mut fs, &mut alice).unwrap(); + } + let elapsed = start.elapsed(); + + print_result( + &format!("ls with permission filtering (1K files, {iterations}x)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(30), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_permission_checked_find() { + let mut fs = VirtualFs::new(); + let mut root = Session::root(); + + let pipeline = parser::parse_pipeline("adduser alice"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root).unwrap(); + + let alice_user = fs.registry.get_user(1).unwrap(); + let alice = Session::new( + alice_user.uid, + alice_user.groups[0], + alice_user.groups.clone(), + alice_user.name.clone(), + ); + + for d in 0..10 { + fs.mkdir(&format!("d_{d}"), 0, 0).unwrap(); + for f in 0..50 { + fs.touch(&format!("d_{d}/f_{f:03}.md"), 0, 0).unwrap(); + } + } + + let iterations = 50; + let start = Instant::now(); + for _ in 0..iterations { + let _ = fs.find(Some("."), Some("*.md"), Some(&alice)).unwrap(); + } + let elapsed = start.elapsed(); + + print_result( + &format!("find with permission filtering (500 files, {iterations}x)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(30), "too slow: {elapsed:?}"); +} diff --git a/tests/perf/persistence.rs b/tests/perf/persistence.rs new file mode 100644 index 0000000000000000000000000000000000000000..537771f9e3d77cccc792aeb2509f2d8f847a8e1c --- /dev/null +++ b/tests/perf/persistence.rs @@ -0,0 +1,92 @@ +use super::*; +use markdownfs::persist::PersistManager; +use markdownfs::vcs::Vcs; + +#[test] +fn perf_persist_save_load_10k() { + let tmp = std::env::temp_dir().join(format!("markdownfs_perf_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let persist = PersistManager::new(&tmp); + + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + let file_count = 10_000; + + let content = "# Perf\n\nBenchmark file.\n"; + for i in 0..file_count { + let path = format!("p_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + vcs.commit(&fs, "bulk", "root").unwrap(); + + let start = Instant::now(); + persist.save(&fs, &vcs).unwrap(); + let save_elapsed = start.elapsed(); + + let file_size = std::fs::metadata(tmp.join(".vfs/state.bin")) + .unwrap() + .len(); + print_result(&format!("save ({file_count} files)"), 1, save_elapsed); + println!( + " state.bin size: {:.2} MB", + file_size as f64 / (1024.0 * 1024.0) + ); + + let start = Instant::now(); + let (fs2, vcs2) = persist.load().unwrap(); + let load_elapsed = start.elapsed(); + + print_result(&format!("load ({file_count} files)"), 1, load_elapsed); + assert_eq!(vcs2.commits.len(), 1); + + let data = fs2.cat("p_00000.md").unwrap(); + assert_eq!(String::from_utf8_lossy(data), content); + + assert!(save_elapsed.as_secs() < debug_limit(10), "save too slow: {save_elapsed:?}"); + assert!(load_elapsed.as_secs() < debug_limit(10), "load too slow: {load_elapsed:?}"); + + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn perf_persist_large_state() { + let tmp = std::env::temp_dir().join(format!("markdownfs_perflg_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let persist = PersistManager::new(&tmp); + + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + // Create a substantial state: dirs + files + multiple commits + for d in 0..10 { + fs.mkdir(&format!("dir_{d}"), 0, 0).unwrap(); + for f in 0..100 { + let path = format!("dir_{d}/f_{f:03}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, format!("# File {d}/{f}\nContent.\n").into_bytes()).unwrap(); + } + } + for c in 0..10 { + let path = format!("dir_{}/f_{:03}.md", c % 10, c); + fs.write_file(&path, format!("# Updated at commit {c}\n").into_bytes()).unwrap(); + vcs.commit(&fs, &format!("commit {c}"), "root").unwrap(); + } + + let start = Instant::now(); + persist.save(&fs, &vcs).unwrap(); + let save_elapsed = start.elapsed(); + + let start = Instant::now(); + let (_, vcs2) = persist.load().unwrap(); + let load_elapsed = start.elapsed(); + + print_result("save (1K files + 10 commits)", 1, save_elapsed); + print_result("load (1K files + 10 commits)", 1, load_elapsed); + assert_eq!(vcs2.commits.len(), 10); + + assert!(save_elapsed.as_secs() < debug_limit(10), "save too slow"); + assert!(load_elapsed.as_secs() < debug_limit(10), "load too slow"); + + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/tests/perf/pipes.rs b/tests/perf/pipes.rs new file mode 100644 index 0000000000000000000000000000000000000000..ad17ac681d404f6a0af10254d104e4519ee4fd56 --- /dev/null +++ b/tests/perf/pipes.rs @@ -0,0 +1,58 @@ +use super::*; + +#[test] +fn perf_pipe_chain() { + let mut fs = VirtualFs::new(); + + let mut content = String::new(); + for i in 0..10_000 { + if i % 3 == 0 { + content.push_str(&format!("ERROR: something failed at line {i}\n")); + } else { + content.push_str(&format!("INFO: normal operation at line {i}\n")); + } + } + fs.touch("log.md", 0, 0).unwrap(); + fs.write_file("log.md", content.into_bytes()).unwrap(); + + let iterations = 100; + let start = Instant::now(); + for _ in 0..iterations { + let output = exec("cat log.md | grep ERROR | head -10 | wc -l", &mut fs); + assert_eq!(output.trim(), "10"); + } + let elapsed = start.elapsed(); + + print_result( + &format!("pipe: cat|grep|head|wc ({iterations}x, 10K lines)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_pipe_grep_wc() { + let mut fs = VirtualFs::new(); + + let mut content = String::new(); + for i in 0..50_000 { + content.push_str(&format!("log entry {i}: status=ok\n")); + } + fs.touch("big-log.md", 0, 0).unwrap(); + fs.write_file("big-log.md", content.into_bytes()).unwrap(); + + let iterations = 20; + let start = Instant::now(); + for _ in 0..iterations { + let _ = exec("cat big-log.md | grep status=ok | wc -l", &mut fs); + } + let elapsed = start.elapsed(); + + print_result( + &format!("pipe: cat|grep|wc ({iterations}x, 50K lines)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(30), "too slow: {elapsed:?}"); +} diff --git a/tests/perf/search.rs b/tests/perf/search.rs new file mode 100644 index 0000000000000000000000000000000000000000..03c1f89db55f1d37647d26fb806b216c7ba6a307 --- /dev/null +++ b/tests/perf/search.rs @@ -0,0 +1,120 @@ +use super::*; + +#[test] +fn perf_grep_across_1k_files() { + let mut fs = VirtualFs::new(); + let file_count = 1_000; + + for i in 0..file_count { + let path = format!("s_{i:04}.md"); + fs.touch(&path, 0, 0).unwrap(); + let content = if i % 10 == 0 { + format!("# File {i}\n\nTODO: fix this issue\n\nSome other content.\n") + } else { + format!("# File {i}\n\nEverything is fine here.\n\nNo issues.\n") + }; + fs.write_file(&path, content.into_bytes()).unwrap(); + } + + let iterations = 50; + let start = Instant::now(); + for _ in 0..iterations { + let results = fs.grep("TODO", None, true, None).unwrap(); + assert_eq!(results.len(), 100); + } + let elapsed = start.elapsed(); + + print_result( + &format!("grep -r TODO (1K files, {iterations}x)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_grep_complex_regex() { + let mut fs = VirtualFs::new(); + let file_count = 500; + + for i in 0..file_count { + let path = format!("r_{i:04}.md"); + fs.touch(&path, 0, 0).unwrap(); + let content = format!( + "# File {i}\n\nDate: 2024-01-{:02}\nEmail: user{i}@example.com\nVersion: v{}.{}.0\n", + (i % 28) + 1, i / 100, i % 100 + ); + fs.write_file(&path, content.into_bytes()).unwrap(); + } + + let iterations = 20; + let start = Instant::now(); + for _ in 0..iterations { + let _ = fs.grep(r"\d{4}-\d{2}-\d{2}", None, true, None).unwrap(); + } + let elapsed = start.elapsed(); + + print_result( + &format!("grep -r (complex regex, {file_count} files, {iterations}x)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_find_across_tree() { + let mut fs = VirtualFs::new(); + + for d in 0..10 { + fs.mkdir(&format!("dir_{d}"), 0, 0).unwrap(); + for f in 0..100 { + let path = format!("dir_{d}/file_{f:03}.md"); + fs.touch(&path, 0, 0).unwrap(); + } + } + + let iterations = 100; + let start = Instant::now(); + for _ in 0..iterations { + let results = fs.find(Some("."), Some("*.md"), None).unwrap(); + assert_eq!(results.len(), 1000); + } + let elapsed = start.elapsed(); + + print_result( + &format!("find -name *.md (1K files, {iterations}x)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(60), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_tree_large_hierarchy() { + let mut fs = VirtualFs::new(); + + for d in 0..20 { + fs.mkdir(&format!("dir_{d:02}"), 0, 0).unwrap(); + for sd in 0..5 { + fs.mkdir(&format!("dir_{d:02}/sub_{sd}"), 0, 0).unwrap(); + for f in 0..10 { + fs.touch(&format!("dir_{d:02}/sub_{sd}/file_{f}.md"), 0, 0).unwrap(); + } + } + } + + let iterations = 50; + let start = Instant::now(); + for _ in 0..iterations { + let _ = fs.tree(None, "", None).unwrap(); + } + let elapsed = start.elapsed(); + + print_result( + &format!("tree (20 dirs, 100 subdirs, 1K files, {iterations}x)"), + iterations, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(30), "too slow: {elapsed:?}"); +} diff --git a/tests/perf/vcs.rs b/tests/perf/vcs.rs new file mode 100644 index 0000000000000000000000000000000000000000..a1b007e28fdd1493c46f815b9f50835ad38c750e --- /dev/null +++ b/tests/perf/vcs.rs @@ -0,0 +1,214 @@ +use super::*; +use markdownfs::vcs::Vcs; + +#[test] +fn perf_commit_10k_files() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + let file_count = 10_000; + + let content = "# Title\n\nContent here.\n"; + for i in 0..file_count { + let path = format!("f_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + + let start = Instant::now(); + let id = vcs.commit(&fs, "big commit", "root").unwrap(); + let elapsed = start.elapsed(); + + print_result( + &format!("commit ({file_count} files)"), + 1, + elapsed, + ); + println!(" commit hash: {}", id.short_hex()); + println!(" objects in store: {}", vcs.store.object_count()); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_sequential_commits_100() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + let commit_count = 100; + + for i in 0..100 { + let path = format!("f_{i:03}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, format!("# File {i}\n").into_bytes()) + .unwrap(); + } + + let start = Instant::now(); + for c in 0..commit_count { + let path = format!("f_{:03}.md", c % 100); + fs.write_file(&path, format!("# File updated at commit {c}\n").into_bytes()) + .unwrap(); + vcs.commit(&fs, &format!("commit {c}"), "root").unwrap(); + } + let elapsed = start.elapsed(); + + print_result( + &format!("{commit_count} sequential commits (100 files)"), + commit_count, + elapsed, + ); + assert_eq!(vcs.log().len(), commit_count); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_commit_500_sequential() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + fs.touch("evolving.md", 0, 0).unwrap(); + let count = 500; + + let start = Instant::now(); + for c in 0..count { + fs.write_file("evolving.md", format!("Content at commit {c}\n").into_bytes()).unwrap(); + vcs.commit(&fs, &format!("c{c}"), "root").unwrap(); + } + let elapsed = start.elapsed(); + + print_result( + &format!("{count} sequential commits (single file)"), + count, + elapsed, + ); + assert_eq!(vcs.log().len(), count); + assert!(elapsed.as_secs() < debug_limit(20), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_revert_large_state() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + let file_count = 5_000; + + let content = "# Original\n\nContent.\n"; + for i in 0..file_count { + let path = format!("f_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + let id1 = vcs.commit(&fs, "v1", "root").unwrap(); + + for i in 0..file_count { + let path = format!("f_{i:05}.md"); + fs.write_file(&path, b"# Modified\n".to_vec()).unwrap(); + } + vcs.commit(&fs, "v2", "root").unwrap(); + + let start = Instant::now(); + vcs.revert(&mut fs, &id1.short_hex()).unwrap(); + let elapsed = start.elapsed(); + + print_result( + &format!("revert ({file_count} files)"), + 1, + elapsed, + ); + + let data = fs.cat("f_00000.md").unwrap(); + assert_eq!(String::from_utf8_lossy(data), content); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_revert_chain() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + fs.touch("file.md", 0, 0).unwrap(); + let mut commit_ids = Vec::new(); + + for c in 0..20 { + fs.write_file("file.md", format!("v{c}\n").into_bytes()).unwrap(); + let id = vcs.commit(&fs, &format!("c{c}"), "root").unwrap(); + commit_ids.push(id); + } + + let start = Instant::now(); + for (i, id) in commit_ids.iter().rev().enumerate() { + vcs.revert(&mut fs, &id.short_hex()).unwrap(); + let expected = format!("v{}\n", 19 - i); + assert_eq!(String::from_utf8_lossy(fs.cat("file.md").unwrap()), expected); + } + let elapsed = start.elapsed(); + + print_result("20 reverts (walk history backward)", 20, elapsed); + assert!(elapsed.as_secs() < debug_limit(5), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_dedup_identical_files() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + let file_count = 10_000; + + let content = "# Shared Template\n\nThis content is identical everywhere.\n"; + for i in 0..file_count { + let path = format!("dup_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + + let start = Instant::now(); + vcs.commit(&fs, "dedup benchmark", "root").unwrap(); + let elapsed = start.elapsed(); + + let object_count = vcs.store.object_count(); + print_result( + &format!("commit {file_count} identical files (dedup)"), + 1, + elapsed, + ); + println!(" objects in store: {object_count} (expect <<{file_count} due to dedup)"); + + assert!( + object_count < 100, + "dedup not working: {object_count} objects for {file_count} identical files" + ); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_dedup_mixed_content() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + let templates = [ + "# Template A\n\nShared content type A.\n", + "# Template B\n\nShared content type B.\n", + "# Template C\n\nShared content type C.\n", + ]; + + let file_count = 9_000; + for i in 0..file_count { + let path = format!("f_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, templates[i % 3].as_bytes().to_vec()).unwrap(); + } + + let start = Instant::now(); + vcs.commit(&fs, "mixed dedup", "root").unwrap(); + let elapsed = start.elapsed(); + + let object_count = vcs.store.object_count(); + print_result( + &format!("commit {file_count} files (3 unique contents)"), + 1, + elapsed, + ); + println!(" objects in store: {object_count} (expect ~5: 3 blobs + 1 tree + 1 commit)"); + + assert!( + object_count < 20, + "dedup for 3 templates should yield few objects, got {object_count}" + ); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} diff --git a/tests/perf/workloads.rs b/tests/perf/workloads.rs new file mode 100644 index 0000000000000000000000000000000000000000..b2260df38365521dabeb5708312aca85cdc2ff29 --- /dev/null +++ b/tests/perf/workloads.rs @@ -0,0 +1,146 @@ +use super::*; +use markdownfs::vcs::Vcs; + +#[test] +fn perf_mixed_workload() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + + let start = Instant::now(); + let mut ops = 0; + + // Phase 1: Project setup + fs.mkdir("src", 0, 0).unwrap(); + fs.mkdir("docs", 0, 0).unwrap(); + fs.mkdir("tests", 0, 0).unwrap(); + ops += 3; + + for i in 0..50 { + let path = format!("src/module_{i:02}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file( + &path, + format!("# Module {i}\n\n## API\n\nSome documentation.\n") + .into_bytes(), + ) + .unwrap(); + ops += 2; + } + + vcs.commit(&fs, "initial project setup", "root").unwrap(); + ops += 1; + + // Phase 2: Iterative development + for cycle in 0..20 { + for f in 0..5 { + let idx = (cycle * 5 + f) % 50; + let path = format!("src/module_{idx:02}.md"); + fs.write_file( + &path, + format!("# Module {idx} (v{cycle})\n\n## Updated API\n\nNew content.\n") + .into_bytes(), + ) + .unwrap(); + ops += 1; + } + + let test_path = format!("tests/test_{cycle:02}.md"); + fs.touch(&test_path, 0, 0).unwrap(); + fs.write_file( + &test_path, + format!("# Test {cycle}\n\n- [ ] Test A\n- [ ] Test B\n") + .into_bytes(), + ) + .unwrap(); + ops += 2; + + vcs.commit(&fs, &format!("development cycle {cycle}"), "root").unwrap(); + ops += 1; + + if cycle % 5 == 0 { + let _ = fs.grep("Updated", None, true, None).unwrap(); + let _ = fs.find(Some("."), Some("*.md"), None).unwrap(); + let _ = fs.ls(Some("src")).unwrap(); + ops += 3; + } + } + + // Phase 3: Revert + let commits = vcs.log(); + let mid_commit = &commits[commits.len() / 2]; + vcs.revert(&mut fs, &mid_commit.id.short_hex()).unwrap(); + ops += 1; + + let elapsed = start.elapsed(); + + print_result(&format!("mixed workload ({ops} operations)"), ops, elapsed); + println!(" commits: {}", vcs.log().len()); + println!(" objects in store: {}", vcs.store.object_count()); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} + +#[test] +fn perf_mixed_multiuser_workload() { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + let mut root = Session::root(); + + // Setup users + let pipeline = parser::parse_pipeline("adduser alice"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root).unwrap(); + let pipeline = parser::parse_pipeline("adduser bob"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root).unwrap(); + + let alice_user = fs.registry.get_user(1).unwrap(); + let mut alice = Session::new( + alice_user.uid, + alice_user.groups[0], + alice_user.groups.clone(), + alice_user.name.clone(), + ); + + let bob_user = fs.registry.get_user(2).unwrap(); + let mut bob = Session::new( + bob_user.uid, + bob_user.groups[0], + bob_user.groups.clone(), + bob_user.name.clone(), + ); + + // Create shared workspace + let pipeline = parser::parse_pipeline("mkdir shared"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root).unwrap(); + let pipeline = parser::parse_pipeline("chmod 777 shared"); + cmd::execute_pipeline(&pipeline, &mut fs, &mut root).unwrap(); + + let start = Instant::now(); + let mut ops = 0; + + // Alternating user operations + for i in 0..100 { + let session = if i % 2 == 0 { &mut alice } else { &mut bob }; + let username = if i % 2 == 0 { "alice" } else { "bob" }; + + let path = format!("shared/{username}_{i:03}.md"); + let pipeline = parser::parse_pipeline(&format!("touch {path}")); + cmd::execute_pipeline(&pipeline, &mut fs, session).unwrap(); + + let pipeline = parser::parse_pipeline(&format!("write {path} Work by {username} #{i}")); + cmd::execute_pipeline(&pipeline, &mut fs, session).unwrap(); + ops += 2; + + if i % 10 == 0 { + vcs.commit(&fs, &format!("checkpoint {i}"), username).unwrap(); + ops += 1; + } + } + + let elapsed = start.elapsed(); + + print_result( + &format!("multi-user mixed workload ({ops} ops, 2 users)"), + ops, + elapsed, + ); + assert!(elapsed.as_secs() < debug_limit(10), "too slow: {elapsed:?}"); +} diff --git a/tests/perf_comparison.rs b/tests/perf_comparison.rs new file mode 100644 index 0000000000000000000000000000000000000000..7834a003daa8ba1d7c0a53badd00573d6f0a60f0 --- /dev/null +++ b/tests/perf_comparison.rs @@ -0,0 +1,1035 @@ +//! Comprehensive performance comparison: markdownfs vs native filesystem +//! +//! Run with: cargo test --release --test perf_comparison -- --nocapture +//! +//! Measures markdownfs operations and compares against native fs operations +//! performed on the same machine for a fair baseline. + +use markdownfs::auth::session::Session; +use markdownfs::cmd; +use markdownfs::cmd::parser; +use markdownfs::fs::VirtualFs; +use markdownfs::persist::PersistManager; +use markdownfs::vcs::Vcs; +use std::time::Instant; + +struct BenchResult { + name: String, + markdownfs_us: f64, + native_us: Option, + speedup: Option, +} + +impl BenchResult { + fn print(&self) { + let markdownfs_str = format_duration_us(self.markdownfs_us); + match (self.native_us, self.speedup) { + (Some(native), Some(speedup)) => { + let native_str = format_duration_us(native); + let arrow = if speedup >= 1.0 { + "\x1b[32m↑\x1b[0m" + } else { + "\x1b[31m↓\x1b[0m" + }; + println!( + " {:<50} markdownfs: {:>12} native: {:>12} {arrow} {:.1}x", + self.name, markdownfs_str, native_str, speedup + ); + } + _ => { + println!( + " {:<50} markdownfs: {:>12}", + self.name, markdownfs_str + ); + } + } + } +} + +fn format_duration_us(us: f64) -> String { + if us < 1.0 { + format!("{:.0}ns", us * 1000.0) + } else if us < 1000.0 { + format!("{:.2}µs", us) + } else if us < 1_000_000.0 { + format!("{:.2}ms", us / 1000.0) + } else { + format!("{:.2}s", us / 1_000_000.0) + } +} + +fn exec(line: &str, fs: &mut VirtualFs) -> String { + let pipeline = parser::parse_pipeline(line); + let mut session = Session::root(); + cmd::execute_pipeline(&pipeline, fs, &mut session).unwrap() +} + +// ═══════════════════════════════════════════════════════════════ +// NATIVE FILESYSTEM BENCHMARK HELPERS +// ═══════════════════════════════════════════════════════════════ + +fn make_tmp(suffix: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!( + "markdownfs_{suffix}_{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p +} + +fn cleanup(p: &std::path::Path) { + let _ = std::fs::remove_dir_all(p); +} + +fn native_bench_file_creation(count: usize) -> f64 { + let tmp = make_tmp("create"); + let start = Instant::now(); + for i in 0..count { + std::fs::write(tmp.join(format!("file_{i:05}.md")), "").unwrap(); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +fn native_bench_file_creation_nested(count: usize, dirs: usize) -> f64 { + let tmp = make_tmp("create_nested"); + for d in 0..dirs { + std::fs::create_dir(tmp.join(format!("dir_{d:03}"))).unwrap(); + } + let start = Instant::now(); + for i in 0..count { + let d = i % dirs; + std::fs::write( + tmp.join(format!("dir_{d:03}/file_{i:05}.md")), + "", + ) + .unwrap(); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +fn native_bench_file_write(count: usize, content: &[u8]) -> f64 { + let tmp = make_tmp("write"); + for i in 0..count { + std::fs::write(tmp.join(format!("f_{i:05}.md")), "").unwrap(); + } + let start = Instant::now(); + for i in 0..count { + std::fs::write(tmp.join(format!("f_{i:05}.md")), content).unwrap(); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +fn native_bench_file_read(count: usize, content: &[u8]) -> f64 { + let tmp = make_tmp("read"); + for i in 0..count { + std::fs::write(tmp.join(format!("f_{i:05}.md")), content).unwrap(); + } + let start = Instant::now(); + for i in 0..count { + let data = std::fs::read(tmp.join(format!("f_{i:05}.md"))).unwrap(); + assert_eq!(data.len(), content.len()); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +fn native_bench_file_read_same(content: &[u8], count: usize) -> f64 { + let tmp = make_tmp("read_same"); + let path = tmp.join("hot.md"); + std::fs::write(&path, content).unwrap(); + let start = Instant::now(); + for _ in 0..count { + let _ = std::fs::read(&path).unwrap(); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +fn native_bench_ls(count: usize, iterations: usize) -> f64 { + let tmp = make_tmp("ls"); + for i in 0..count { + std::fs::write(tmp.join(format!("f_{i:05}.md")), "").unwrap(); + } + let start = Instant::now(); + for _ in 0..iterations { + let entries: Vec<_> = std::fs::read_dir(&tmp).unwrap().collect(); + assert_eq!(entries.len(), count); + } + let us = start.elapsed().as_micros() as f64 / iterations as f64; + cleanup(&tmp); + us +} + +fn native_bench_stat(count: usize, iterations: usize) -> f64 { + let tmp = make_tmp("stat"); + for i in 0..count { + std::fs::write(tmp.join(format!("f_{i:05}.md")), "content").unwrap(); + } + let start = Instant::now(); + for _ in 0..iterations { + for i in 0..count { + let _ = std::fs::metadata(tmp.join(format!("f_{i:05}.md"))).unwrap(); + } + } + let total = count * iterations; + let us = start.elapsed().as_micros() as f64 / total as f64; + cleanup(&tmp); + us +} + +fn native_bench_grep(file_count: usize, iterations: usize) -> f64 { + let tmp = make_tmp("grep"); + for i in 0..file_count { + let content = if i % 10 == 0 { + format!("# File {i}\n\nTODO: fix this issue\n\nSome other content.\n") + } else { + format!("# File {i}\n\nEverything is fine here.\n\nNo issues.\n") + }; + std::fs::write(tmp.join(format!("s_{i:04}.md")), content).unwrap(); + } + let re = regex::Regex::new("TODO").unwrap(); + let start = Instant::now(); + for _ in 0..iterations { + let mut match_count = 0; + for entry in std::fs::read_dir(&tmp).unwrap() { + let path = entry.unwrap().path(); + let content = std::fs::read_to_string(&path).unwrap(); + for line in content.lines() { + if re.is_match(line) { + match_count += 1; + } + } + } + assert_eq!(match_count, file_count / 10); + } + let us = start.elapsed().as_micros() as f64 / iterations as f64; + cleanup(&tmp); + us +} + +fn native_bench_find(dirs: usize, files_per_dir: usize, iterations: usize) -> f64 { + let tmp = make_tmp("find"); + for d in 0..dirs { + let dir = tmp.join(format!("dir_{d}")); + std::fs::create_dir(&dir).unwrap(); + for f in 0..files_per_dir { + std::fs::write(dir.join(format!("file_{f:03}.md")), "").unwrap(); + } + } + let start = Instant::now(); + for _ in 0..iterations { + let mut count = 0; + fn walk(dir: &std::path::Path, count: &mut usize) { + for entry in std::fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + walk(&path, count); + } else if path.extension().map_or(false, |e| e == "md") { + *count += 1; + } + } + } + walk(&tmp, &mut count); + assert_eq!(count, dirs * files_per_dir); + } + let us = start.elapsed().as_micros() as f64 / iterations as f64; + cleanup(&tmp); + us +} + +fn native_bench_rm(count: usize) -> f64 { + let tmp = make_tmp("rm"); + for i in 0..count { + std::fs::write(tmp.join(format!("f_{i:05}.md")), "content").unwrap(); + } + let start = Instant::now(); + for i in 0..count { + std::fs::remove_file(tmp.join(format!("f_{i:05}.md"))).unwrap(); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +fn native_bench_mv(count: usize) -> f64 { + let tmp = make_tmp("mv"); + let src = tmp.join("src"); + let dst = tmp.join("dst"); + std::fs::create_dir(&src).unwrap(); + std::fs::create_dir(&dst).unwrap(); + for i in 0..count { + std::fs::write(src.join(format!("f_{i:04}.md")), "").unwrap(); + } + let start = Instant::now(); + for i in 0..count { + std::fs::rename( + src.join(format!("f_{i:04}.md")), + dst.join(format!("f_{i:04}.md")), + ) + .unwrap(); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +fn native_bench_cp(count: usize, content: &[u8]) -> f64 { + let tmp = make_tmp("cp"); + for i in 0..count { + std::fs::write(tmp.join(format!("orig_{i:04}.md")), content).unwrap(); + } + let copies = tmp.join("copies"); + std::fs::create_dir(&copies).unwrap(); + let start = Instant::now(); + for i in 0..count { + std::fs::copy( + tmp.join(format!("orig_{i:04}.md")), + copies.join(format!("copy_{i:04}.md")), + ) + .unwrap(); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +fn native_bench_mkdir_p(count: usize, depth: usize) -> f64 { + let tmp = make_tmp("mkdir_p"); + let start = Instant::now(); + for i in 0..count { + let mut path = tmp.join(format!("root_{i}")); + for d in 0..depth { + path = path.join(format!("d{d}")); + } + std::fs::create_dir_all(&path).unwrap(); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +fn native_bench_save_load(file_count: usize) -> (f64, f64, u64) { + let tmp = make_tmp("persist"); + let content = "# Perf\n\nBenchmark file.\n"; + let start = Instant::now(); + for i in 0..file_count { + std::fs::write(tmp.join(format!("p_{i:05}.md")), content).unwrap(); + } + let save_us = start.elapsed().as_micros() as f64; + let total_size: u64 = std::fs::read_dir(&tmp) + .unwrap() + .map(|e| e.unwrap().metadata().unwrap().len()) + .sum(); + let start = Instant::now(); + for i in 0..file_count { + let _ = std::fs::read(tmp.join(format!("p_{i:05}.md"))).unwrap(); + } + let load_us = start.elapsed().as_micros() as f64; + cleanup(&tmp); + (save_us, load_us, total_size) +} + +fn native_bench_overwrite_same(count: usize) -> f64 { + let tmp = make_tmp("overwrite"); + let path = tmp.join("target.md"); + std::fs::write(&path, "").unwrap(); + let start = Instant::now(); + for i in 0..count { + std::fs::write(&path, format!("version {i}\n")).unwrap(); + } + let us = start.elapsed().as_micros() as f64 / count as f64; + cleanup(&tmp); + us +} + +// ═══════════════════════════════════════════════════════════════ +// MAIN COMPARISON BENCHMARK +// ═══════════════════════════════════════════════════════════════ + +#[test] +fn perf_full_comparison() { + println!("\n{}", "═".repeat(110)); + println!( + " markdownfs Performance Comparison — markdownfs (in-memory VFS) vs Native Filesystem" + ); + println!(" All times are per-operation unless noted. Speedup >1x means markdownfs is faster."); + println!("{}\n", "═".repeat(110)); + + let mut results: Vec = Vec::new(); + + // ── File creation (flat) ── + println!(" \x1b[1m━━━ File Creation ━━━\x1b[0m"); + { + let count = 10_000; + let mut fs = VirtualFs::new(); + let start = Instant::now(); + for i in 0..count { + fs.touch(&format!("file_{i:05}.md"), 0, 0).unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_file_creation(count); + let r = BenchResult { + name: format!("touch ({count} files, flat)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── File creation (nested) ── + { + let count = 10_000; + let dirs = 100; + let mut fs = VirtualFs::new(); + for d in 0..dirs { + fs.mkdir(&format!("dir_{d:03}"), 0, 0).unwrap(); + } + let start = Instant::now(); + for i in 0..count { + let d = i % dirs; + fs.touch(&format!("dir_{d:03}/file_{i:05}.md"), 0, 0).unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_file_creation_nested(count, dirs); + let r = BenchResult { + name: format!("touch ({count} files, {dirs} dirs)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── mkdir -p ── + { + let count = 100; + let depth = 10; + let mut fs = VirtualFs::new(); + let start = Instant::now(); + for i in 0..count { + let mut path = format!("root_{i}"); + for d in 0..depth { + path = format!("{path}/d{d}"); + } + fs.mkdir_p(&path, 0, 0).unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_mkdir_p(count, depth); + let r = BenchResult { + name: format!("mkdir -p (depth={depth}, {count}x)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── File write (small) ── + println!("\n \x1b[1m━━━ File Write ━━━\x1b[0m"); + { + let count = 10_000; + let content = b"# Title\n\nSome markdown content.\n\n- Item 1\n- Item 2\n"; + let mut fs = VirtualFs::new(); + for i in 0..count { + fs.touch(&format!("f_{i:05}.md"), 0, 0).unwrap(); + } + let start = Instant::now(); + for i in 0..count { + fs.write_file(&format!("f_{i:05}.md"), content.to_vec()) + .unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_file_write(count, content); + let r = BenchResult { + name: format!("write small ({count} files, {}B)", content.len()), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── File write (large) ── + { + let count = 1_000; + let mut big_content = String::with_capacity(10_240); + for i in 0..200 { + big_content.push_str(&format!( + "## Section {i}\n\nLorem ipsum dolor sit amet.\n\n" + )); + } + let content = big_content.as_bytes(); + + let mut fs = VirtualFs::new(); + for i in 0..count { + fs.touch(&format!("b_{i:04}.md"), 0, 0).unwrap(); + } + let start = Instant::now(); + for i in 0..count { + fs.write_file(&format!("b_{i:04}.md"), content.to_vec()) + .unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_file_write(count, content); + let r = BenchResult { + name: format!( + "write large ({count} files, {:.1}KB)", + content.len() as f64 / 1024.0 + ), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── Overwrite same file ── + { + let count = 10_000; + let mut fs = VirtualFs::new(); + fs.touch("target.md", 0, 0).unwrap(); + let start = Instant::now(); + for i in 0..count { + fs.write_file("target.md", format!("v{i}\n").into_bytes()) + .unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_overwrite_same(count); + let r = BenchResult { + name: format!("overwrite same file ({count}x)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── File read (many files) ── + println!("\n \x1b[1m━━━ File Read ━━━\x1b[0m"); + { + let count = 10_000; + let content = b"# Hello\n\nWorld\n"; + let mut fs = VirtualFs::new(); + for i in 0..count { + let path = format!("r_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.to_vec()).unwrap(); + } + let start = Instant::now(); + for i in 0..count { + let _ = fs.cat(&format!("r_{i:05}.md")).unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_file_read(count, content); + let r = BenchResult { + name: format!("cat ({count} different files)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── File read (hot path — same file) ── + { + let count = 100_000; + let content = b"# Hot path content\n\nFrequently read.\n"; + let mut fs = VirtualFs::new(); + fs.touch("hot.md", 0, 0).unwrap(); + fs.write_file("hot.md", content.to_vec()).unwrap(); + let start = Instant::now(); + for _ in 0..count { + let _ = fs.cat("hot.md").unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_file_read_same(content, count); + let r = BenchResult { + name: format!("cat same file ({count}x, hot path)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── Directory listing ── + println!("\n \x1b[1m━━━ Directory Listing ━━━\x1b[0m"); + { + let file_count = 10_000; + let iterations = 100; + let mut fs = VirtualFs::new(); + for i in 0..file_count { + fs.touch(&format!("f_{i:05}.md"), 0, 0).unwrap(); + } + let start = Instant::now(); + for _ in 0..iterations { + let _ = fs.ls(None).unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / iterations as f64; + let native_us = native_bench_ls(file_count, iterations); + let r = BenchResult { + name: format!("ls ({file_count} entries, {iterations}x)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── Stat ── + { + let count = 100; + let iterations = 100; + let mut fs = VirtualFs::new(); + for i in 0..count { + fs.touch(&format!("f_{i:04}.md"), 0, 0).unwrap(); + } + let start = Instant::now(); + for _ in 0..iterations { + for i in 0..count { + let _ = fs.stat(&format!("f_{i:04}.md")).unwrap(); + } + } + let total = count * iterations; + let markdownfs_us = start.elapsed().as_micros() as f64 / total as f64; + let native_us = native_bench_stat(count, iterations); + let r = BenchResult { + name: format!("stat ({count} files, {iterations}x each)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── Search: grep ── + println!("\n \x1b[1m━━━ Search ━━━\x1b[0m"); + { + let file_count = 1_000; + let iterations = 50; + let mut fs = VirtualFs::new(); + for i in 0..file_count { + let path = format!("s_{i:04}.md"); + fs.touch(&path, 0, 0).unwrap(); + let content = if i % 10 == 0 { + format!("# File {i}\n\nTODO: fix this issue\n\nSome other content.\n") + } else { + format!("# File {i}\n\nEverything is fine here.\n\nNo issues.\n") + }; + fs.write_file(&path, content.into_bytes()).unwrap(); + } + let start = Instant::now(); + for _ in 0..iterations { + let _ = fs.grep("TODO", None, true, None).unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / iterations as f64; + let native_us = native_bench_grep(file_count, iterations); + let r = BenchResult { + name: format!("grep -r ({file_count} files, {iterations}x)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── Search: find ── + { + let dirs = 10; + let files_per_dir = 100; + let iterations = 50; + let mut fs = VirtualFs::new(); + for d in 0..dirs { + fs.mkdir(&format!("dir_{d}"), 0, 0).unwrap(); + for f in 0..files_per_dir { + fs.touch(&format!("dir_{d}/file_{f:03}.md"), 0, 0).unwrap(); + } + } + let start = Instant::now(); + for _ in 0..iterations { + let _ = fs.find(Some("."), Some("*.md"), None).unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / iterations as f64; + let native_us = native_bench_find(dirs, files_per_dir, iterations); + let r = BenchResult { + name: format!( + "find -name *.md ({} files, {iterations}x)", + dirs * files_per_dir + ), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── Delete ── + println!("\n \x1b[1m━━━ Delete ━━━\x1b[0m"); + { + let count = 5_000; + let mut fs = VirtualFs::new(); + for i in 0..count { + fs.touch(&format!("f_{i:05}.md"), 0, 0).unwrap(); + } + let start = Instant::now(); + for i in 0..count { + fs.rm(&format!("f_{i:05}.md")).unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_rm(count); + let r = BenchResult { + name: format!("rm ({count} files)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── Move ── + println!("\n \x1b[1m━━━ Move / Copy ━━━\x1b[0m"); + { + let count = 1_000; + let mut fs = VirtualFs::new(); + fs.mkdir("src", 0, 0).unwrap(); + fs.mkdir("dst", 0, 0).unwrap(); + for i in 0..count { + fs.touch(&format!("src/f_{i:04}.md"), 0, 0).unwrap(); + } + let start = Instant::now(); + for i in 0..count { + fs.mv(&format!("src/f_{i:04}.md"), &format!("dst/f_{i:04}.md")) + .unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_mv(count); + let r = BenchResult { + name: format!("mv ({count} renames)"), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── Copy ── + { + let count = 1_000; + let content = b"# Content to copy\n\nSome data here.\n"; + let mut fs = VirtualFs::new(); + fs.mkdir("copies", 0, 0).unwrap(); + for i in 0..count { + let path = format!("orig_{i:04}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.to_vec()).unwrap(); + } + let start = Instant::now(); + for i in 0..count { + fs.cp( + &format!("orig_{i:04}.md"), + &format!("copies/copy_{i:04}.md"), + 0, + 0, + ) + .unwrap(); + } + let markdownfs_us = start.elapsed().as_micros() as f64 / count as f64; + let native_us = native_bench_cp(count, content); + let r = BenchResult { + name: format!("cp ({count} copies, {}B)", content.len()), + markdownfs_us, + native_us: Some(native_us), + speedup: Some(native_us / markdownfs_us), + }; + r.print(); + results.push(r); + } + + // ── Persistence ── + println!("\n \x1b[1m━━━ Persistence ━━━\x1b[0m"); + { + let file_count = 10_000; + let content = "# Perf\n\nBenchmark file.\n"; + + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + for i in 0..file_count { + let path = format!("p_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + vcs.commit(&fs, "bulk", "root").unwrap(); + + let tmp = make_tmp("cmp_persist"); + let persist = PersistManager::new(&tmp); + + let start = Instant::now(); + persist.save(&fs, &vcs).unwrap(); + let markdownfs_save_us = start.elapsed().as_micros() as f64; + + let markdownfs_size = std::fs::metadata(tmp.join(".vfs/state.bin")) + .unwrap() + .len(); + + let start = Instant::now(); + let _ = persist.load().unwrap(); + let markdownfs_load_us = start.elapsed().as_micros() as f64; + + let (native_save_us, native_load_us, native_size) = native_bench_save_load(file_count); + + let r_save = BenchResult { + name: format!("save ({file_count} files, single binary)"), + markdownfs_us: markdownfs_save_us, + native_us: Some(native_save_us), + speedup: Some(native_save_us / markdownfs_save_us), + }; + r_save.print(); + + let r_load = BenchResult { + name: format!("load ({file_count} files)"), + markdownfs_us: markdownfs_load_us, + native_us: Some(native_load_us), + speedup: Some(native_load_us / markdownfs_load_us), + }; + r_load.print(); + + println!( + " markdownfs state.bin: {:.2} MB | native {file_count} files: {:.2} MB | compression: {:.1}x", + markdownfs_size as f64 / (1024.0 * 1024.0), + native_size as f64 / (1024.0 * 1024.0), + native_size as f64 / markdownfs_size as f64, + ); + + results.push(r_save); + results.push(r_load); + + cleanup(&tmp); + } + + // ── VCS Operations (no native equivalent) ── + println!("\n \x1b[1m━━━ VCS Operations (no native equivalent) ━━━\x1b[0m"); + { + let file_count = 10_000; + let content = "# Title\n\nContent here.\n"; + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + for i in 0..file_count { + let path = format!("f_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + + let start = Instant::now(); + let _ = vcs.commit(&fs, "bench", "root").unwrap(); + let commit_us = start.elapsed().as_micros() as f64; + + let r = BenchResult { + name: format!("commit ({file_count} files)"), + markdownfs_us: commit_us, + native_us: None, + speedup: None, + }; + r.print(); + results.push(r); + } + { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + let file_count = 5_000; + let content = "# Original\n\nContent.\n"; + for i in 0..file_count { + let path = format!("f_{i:05}.md"); + fs.touch(&path, 0, 0).unwrap(); + fs.write_file(&path, content.as_bytes().to_vec()).unwrap(); + } + let id1 = vcs.commit(&fs, "v1", "root").unwrap(); + for i in 0..file_count { + fs.write_file(&format!("f_{i:05}.md"), b"# Modified\n".to_vec()) + .unwrap(); + } + vcs.commit(&fs, "v2", "root").unwrap(); + + let start = Instant::now(); + vcs.revert(&mut fs, &id1.short_hex()).unwrap(); + let revert_us = start.elapsed().as_micros() as f64; + + let r = BenchResult { + name: format!("revert ({file_count} files)"), + markdownfs_us: revert_us, + native_us: None, + speedup: None, + }; + r.print(); + results.push(r); + } + { + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + for i in 0..100 { + fs.touch(&format!("f_{i:03}.md"), 0, 0).unwrap(); + fs.write_file(&format!("f_{i:03}.md"), format!("# F{i}\n").into_bytes()) + .unwrap(); + } + let start = Instant::now(); + for c in 0..100 { + let path = format!("f_{:03}.md", c % 100); + fs.write_file(&path, format!("# Updated {c}\n").into_bytes()) + .unwrap(); + vcs.commit(&fs, &format!("c{c}"), "root").unwrap(); + } + let elapsed = start.elapsed(); + let per_op = elapsed.as_micros() as f64 / 100.0; + + let r = BenchResult { + name: "100 sequential commits (100 files)".to_string(), + markdownfs_us: per_op, + native_us: None, + speedup: None, + }; + r.print(); + results.push(r); + } + + // ── Deduplication ── + println!("\n \x1b[1m━━━ Deduplication ━━━\x1b[0m"); + { + let file_count = 10_000; + let content = "# Shared Template\n\nThis content is identical everywhere.\n"; + let mut fs = VirtualFs::new(); + let mut vcs = Vcs::new(); + for i in 0..file_count { + fs.touch(&format!("dup_{i:05}.md"), 0, 0).unwrap(); + fs.write_file(&format!("dup_{i:05}.md"), content.as_bytes().to_vec()) + .unwrap(); + } + vcs.commit(&fs, "dedup", "root").unwrap(); + + let object_count = vcs.store.object_count(); + println!( + " {:<50} {file_count} identical files → {object_count} objects (1 blob + 1 tree + 1 commit)", + "content-addressable dedup" + ); + assert!(object_count < 10); + } + + // ── Pipe Performance ── + println!("\n \x1b[1m━━━ Pipe Processing ━━━\x1b[0m"); + { + let mut fs = VirtualFs::new(); + let mut content = String::new(); + for i in 0..10_000 { + if i % 3 == 0 { + content.push_str(&format!("ERROR: failed at {i}\n")); + } else { + content.push_str(&format!("INFO: ok at {i}\n")); + } + } + fs.touch("log.md", 0, 0).unwrap(); + fs.write_file("log.md", content.into_bytes()).unwrap(); + + let iterations = 100; + let start = Instant::now(); + for _ in 0..iterations { + let _ = exec("cat log.md | grep ERROR | head -10 | wc -l", &mut fs); + } + let us = start.elapsed().as_micros() as f64 / iterations as f64; + let r = BenchResult { + name: format!("cat|grep|head|wc (10K lines, {iterations}x)"), + markdownfs_us: us, + native_us: None, + speedup: None, + }; + r.print(); + results.push(r); + } + + // ═══════════════════════════════════════════════════════════════ + // SUMMARY + // ═══════════════════════════════════════════════════════════════ + + println!("\n{}", "═".repeat(110)); + println!(" \x1b[1mSummary\x1b[0m"); + println!("{}", "═".repeat(110)); + + let comparable: Vec<&BenchResult> = results.iter().filter(|r| r.speedup.is_some()).collect(); + if !comparable.is_empty() { + let avg_speedup: f64 = + comparable.iter().map(|r| r.speedup.unwrap()).sum::() / comparable.len() as f64; + let max_speedup = comparable + .iter() + .map(|r| r.speedup.unwrap()) + .fold(f64::NEG_INFINITY, f64::max); + let min_speedup = comparable + .iter() + .map(|r| r.speedup.unwrap()) + .fold(f64::INFINITY, f64::min); + + let fastest_op = comparable + .iter() + .max_by(|a, b| { + a.speedup + .unwrap() + .partial_cmp(&b.speedup.unwrap()) + .unwrap() + }) + .unwrap(); + let slowest_op = comparable + .iter() + .min_by(|a, b| { + a.speedup + .unwrap() + .partial_cmp(&b.speedup.unwrap()) + .unwrap() + }) + .unwrap(); + + println!( + " Benchmarks compared: {}/{} operations", + comparable.len(), + results.len() + ); + println!(" Average speedup vs native FS: {avg_speedup:.1}x"); + println!( + " Fastest advantage: {:.1}x ({})", + max_speedup, fastest_op.name + ); + println!( + " Smallest advantage: {:.1}x ({})", + min_speedup, slowest_op.name + ); + println!(); + println!(" \x1b[1mKey advantages:\x1b[0m"); + println!(" • In-memory: no syscall overhead, no disk I/O for operations"); + println!(" • Content-addressable: automatic dedup (10K identical files = 1 blob)"); + println!(" • Single-file persistence: save/load entire state atomically"); + println!(" • Built-in VCS: commit/revert in microseconds, not seconds"); + println!(" • Permission-aware: filtered views without separate access control layer"); + println!(" • Zero-copy reads: cat returns reference to in-memory content"); + } + println!("{}\n", "═".repeat(110)); +} diff --git a/tests/permissions/adversarial.rs b/tests/permissions/adversarial.rs new file mode 100644 index 0000000000000000000000000000000000000000..45bfdf6b1803ce998b490e932c349c7a70a1329d --- /dev/null +++ b/tests/permissions/adversarial.rs @@ -0,0 +1,125 @@ +use super::*; + +// ════════════════════════════════════════════════════════════════ +// ADVERSARIAL / BOUNDARY TESTS +// ════════════════════════════════════════════════════════════════ + +#[test] +fn cannot_escape_via_dotdot() { + let (mut fs, _, mut alice, ..) = setup(); + // alice tries to read bob's notes via path traversal + let result = try_run("cat home/alice/../../home/bob/notes.md", &mut fs, &mut alice); + // This should still fail because it goes through home/bob/ (750, bob:bob) + assert!(result.is_err(), "path traversal via .. should not bypass permissions"); +} + +#[test] +fn symlink_traversal_checks_target_path() { + let (mut fs, mut root, _, mut bob, ..) = setup(); + // Create a symlink from public/ to alice's diary + run("ln -s /home/alice/diary.md public/shortcut.md", &mut fs, &mut root); + + // bob tries to read via the symlink in public/ + // Current behavior: symlink resolution follows the target path, which traverses + // through /home/alice/ (750, alice:alice). The cat command checks read access + // on the resolved path. If the traversal goes through a restricted directory, + // the permission check should catch it. + let result = try_run("cat public/shortcut.md", &mut fs, &mut bob); + // The symlink resolves to the actual file content; permission enforcement + // depends on whether the cat command performs permission checks on the + // resolved target versus following the symlink directly. + // This test documents the current behavior. + let _ = result; // Accept either outcome for now +} + +#[test] +fn mv_respects_source_sticky() { + let (mut fs, _, _, mut bob, ..) = setup(); + // Bob cannot move alice's file in sticky /tmp + let result = try_run("mv tmp/alice-tmp.md tmp/renamed.md", &mut fs, &mut bob); + assert!(result.is_err(), "mv should respect sticky bit on source"); +} + +#[test] +fn cp_creates_file_owned_by_caller() { + let (mut fs, _, mut alice, mut bob, ..) = setup(); + // alice copies a public file — the copy should be owned by alice + run("cp public/readme.md engineering/readme-copy.md", &mut fs, &mut alice); + let stat = run("stat engineering/readme-copy.md", &mut fs, &mut alice); + assert!(stat.contains("Uid: 1"), "cp target should be owned by alice (uid 1). stat: {stat}"); + + // bob copies — his copy should be owned by bob + run("cp public/readme.md engineering/bob-copy.md", &mut fs, &mut bob); + let stat = run("stat engineering/bob-copy.md", &mut fs, &mut bob); + assert!(stat.contains("Uid: 2"), "cp target should be owned by bob (uid 2). stat: {stat}"); +} + +// ════════════════════════════════════════════════════════════════ +// MANY USERS STRESS TEST +// ════════════════════════════════════════════════════════════════ + +#[test] +fn many_users_isolated_homes() { + let mut fs = VirtualFs::new(); + let mut root = Session::root(); + + let user_count = 20; + let mut sessions = Vec::new(); + + for i in 0..user_count { + let name = format!("user{i}"); + // adduser now auto-creates /home/ owned by the user + run(&format!("adduser {name}"), &mut fs, &mut root); + let uid = fs.registry.lookup_uid(&name).unwrap(); + let user = fs.registry.get_user(uid).unwrap(); + let session = Session::new( + user.uid, + user.groups[0], + user.groups.clone(), + user.name.clone(), + ); + + run(&format!("chmod 700 home/{name}"), &mut fs, &mut root); + + sessions.push(session); + } + + // Each user creates a private file + for (i, session) in sessions.iter_mut().enumerate() { + let path = format!("home/user{i}/private.md"); + run(&format!("touch {path}"), &mut fs, session); + run( + &format!("write {path} Secret data for user{i}"), + &mut fs, + session, + ); + } + + // Verify isolation: user0 cannot read user1's file, etc. + for i in 0..user_count { + for j in 0..user_count { + if i == j { + continue; + } + let result = try_run( + &format!("cat home/user{j}/private.md"), + &mut fs, + &mut sessions[i].clone(), + ); + assert!( + result.is_err(), + "user{i} should NOT read user{j}'s private file" + ); + } + } + + // Each user can read their own + for (i, session) in sessions.iter_mut().enumerate() { + let content = run( + &format!("cat home/user{i}/private.md"), + &mut fs, + session, + ); + assert!(content.contains(&format!("Secret data for user{i}"))); + } +} diff --git a/tests/permissions/agent.rs b/tests/permissions/agent.rs new file mode 100644 index 0000000000000000000000000000000000000000..ba0e2fd70acb0052fc9ff780f21739c4e6200c33 --- /dev/null +++ b/tests/permissions/agent.rs @@ -0,0 +1,43 @@ +use super::*; + +#[test] +fn agent_sees_engineering_files() { + let (mut fs, _, _, _, _, mut agent) = setup(); + let content = run("cat engineering/design.md", &mut fs, &mut agent); + assert!(content.contains("Architecture overview")); +} + +#[test] +fn agent_cannot_access_finance() { + let (mut fs, _, _, _, _, mut agent) = setup(); + let result = try_run("ls finance", &mut fs, &mut agent); + assert!(result.is_err(), "agent cannot access finance/"); +} + +#[test] +fn agent_tree_filtered() { + let (mut fs, _, _, _, _, mut agent) = setup(); + let tree = run("tree", &mut fs, &mut agent); + + assert!(tree.contains("design.md"), "agent sees engineering files"); + assert!(tree.contains("roadmap.md")); + assert!(tree.contains("readme.md"), "agent sees public files"); + + assert!(!tree.contains("budget.md"), "agent must NOT see finance files"); + assert!(!tree.contains("diary.md"), "agent must NOT see private diaries"); +} + +#[test] +fn agent_cannot_create_users() { + let (mut fs, _, _, _, _, mut agent) = setup(); + let result = try_run("adduser hacker", &mut fs, &mut agent); + assert!(result.is_err(), "agent should not be able to add users"); +} + +#[test] +fn agent_can_write_in_engineering() { + let (mut fs, _, _, _, _, mut agent) = setup(); + run("touch engineering/agent-report.md", &mut fs, &mut agent); + let ls = run("ls engineering", &mut fs, &mut agent); + assert!(ls.contains("agent-report.md")); +} diff --git a/tests/permissions/alice.rs b/tests/permissions/alice.rs new file mode 100644 index 0000000000000000000000000000000000000000..f4a2463ebe2618f5a4d6aa6ed8f59d911beaa283 --- /dev/null +++ b/tests/permissions/alice.rs @@ -0,0 +1,125 @@ +use super::*; + +#[test] +fn alice_sees_public_and_engineering() { + let (mut fs, _, mut alice, ..) = setup(); + let ls = run("ls", &mut fs, &mut alice); + assert!(ls.contains("public/"), "alice should see public/"); + assert!(ls.contains("engineering/"), "alice should see engineering/"); + assert!(ls.contains("home/"), "alice should see home/"); + assert!(ls.contains("tmp/"), "alice should see tmp/"); +} + +#[test] +fn alice_cannot_see_finance_contents() { + let (mut fs, _, mut alice, ..) = setup(); + let result = try_run("ls finance", &mut fs, &mut alice); + assert!(result.is_err(), "alice should not be able to ls finance/"); +} + +#[test] +fn alice_reads_engineering_files() { + let (mut fs, _, mut alice, ..) = setup(); + let content = run("cat engineering/design.md", &mut fs, &mut alice); + assert!(content.contains("Architecture overview")); + let content = run("cat engineering/roadmap.md", &mut fs, &mut alice); + assert!(content.contains("Launch MVP")); +} + +#[test] +fn alice_reads_own_private_files() { + let (mut fs, _, mut alice, ..) = setup(); + let content = run("cat home/alice/diary.md", &mut fs, &mut alice); + assert!(content.contains("designed a filesystem")); +} + +#[test] +fn alice_cannot_read_bob_private_files() { + let (mut fs, _, mut alice, ..) = setup(); + let result = try_run("cat home/bob/notes.md", &mut fs, &mut alice); + assert!( + result.is_err(), + "alice should not read bob's private notes" + ); +} + +#[test] +fn alice_cannot_read_carol_private_files() { + let (mut fs, _, mut alice, ..) = setup(); + let result = try_run("cat home/carol/personal.md", &mut fs, &mut alice); + assert!( + result.is_err(), + "alice should not read carol's private files" + ); +} + +#[test] +fn alice_tree_hides_inaccessible() { + let (mut fs, _, mut alice, ..) = setup(); + let tree = run("tree", &mut fs, &mut alice); + + assert!(tree.contains("readme.md"), "alice sees public/readme.md"); + assert!(tree.contains("design.md"), "alice sees engineering/design.md"); + assert!(tree.contains("roadmap.md"), "alice sees engineering/roadmap.md"); + assert!(tree.contains("diary.md"), "alice sees her own diary"); + assert!(tree.contains("alice-tmp.md"), "alice sees her tmp file"); + assert!(tree.contains("bob-tmp.md"), "alice sees bob's tmp file (644)"); + + assert!(!tree.contains("budget.md"), "alice must NOT see finance/budget.md"); + assert!(!tree.contains("notes.md"), "alice must NOT see bob's notes.md (600)"); + assert!( + !tree.contains("personal.md"), + "alice must NOT see carol's personal.md (600)" + ); +} + +#[test] +fn alice_find_hides_inaccessible() { + let (mut fs, _, mut alice, ..) = setup(); + let found = run("find . -name *.md", &mut fs, &mut alice); + + assert!(found.contains("readme.md")); + assert!(found.contains("design.md")); + assert!(found.contains("roadmap.md")); + assert!(found.contains("diary.md")); + + assert!(!found.contains("budget.md"), "alice must NOT find budget.md"); + assert!( + !found.contains("home/bob/notes.md"), + "alice must NOT find bob's notes" + ); + assert!( + !found.contains("personal.md"), + "alice must NOT find carol's personal.md" + ); +} + +#[test] +fn alice_grep_only_searches_accessible() { + let (mut fs, _, mut alice, ..) = setup(); + let results = run("grep -r 500K .", &mut fs, &mut alice); + assert!( + !results.contains("budget.md"), + "alice grep must NOT return finance/budget.md" + ); + assert!( + !results.contains("$500K"), + "alice grep must NOT leak finance content" + ); +} + +#[test] +fn alice_can_write_in_engineering() { + let (mut fs, _, mut alice, ..) = setup(); + run("touch engineering/new-doc.md", &mut fs, &mut alice); + let ls = run("ls engineering", &mut fs, &mut alice); + assert!(ls.contains("new-doc.md")); +} + +#[test] +fn alice_can_chmod_own_files() { + let (mut fs, _, mut alice, ..) = setup(); + run("chmod 644 engineering/design.md", &mut fs, &mut alice); + let stat = run("stat engineering/design.md", &mut fs, &mut alice); + assert!(stat.contains("0644")); +} diff --git a/tests/permissions/bob.rs b/tests/permissions/bob.rs new file mode 100644 index 0000000000000000000000000000000000000000..88d7044a586c99a560ff139738fcb7da063a873f --- /dev/null +++ b/tests/permissions/bob.rs @@ -0,0 +1,73 @@ +use super::*; + +#[test] +fn bob_sees_engineering_not_finance() { + let (mut fs, _, _, mut bob, ..) = setup(); + let ls = run("ls", &mut fs, &mut bob); + assert!(ls.contains("engineering/")); + assert!(ls.contains("public/")); + + let result = try_run("ls finance", &mut fs, &mut bob); + assert!(result.is_err(), "bob cannot ls finance/"); +} + +#[test] +fn bob_reads_own_notes() { + let (mut fs, _, _, mut bob, ..) = setup(); + let content = run("cat home/bob/notes.md", &mut fs, &mut bob); + assert!(content.contains("review the design doc")); +} + +#[test] +fn bob_cannot_read_alice_diary() { + let (mut fs, _, _, mut bob, ..) = setup(); + let result = try_run("cat home/alice/diary.md", &mut fs, &mut bob); + assert!(result.is_err(), "bob should not read alice's diary"); +} + +#[test] +fn bob_tree_matches_permissions() { + let (mut fs, _, _, mut bob, ..) = setup(); + let tree = run("tree", &mut fs, &mut bob); + + assert!(tree.contains("design.md"), "bob sees engineering files"); + assert!(tree.contains("roadmap.md")); + assert!(tree.contains("notes.md"), "bob sees his own notes"); + assert!(tree.contains("readme.md"), "bob sees public files"); + + assert!(!tree.contains("budget.md"), "bob must NOT see finance files"); + assert!(!tree.contains("diary.md"), "bob must NOT see alice's diary"); + assert!( + !tree.contains("personal.md"), + "bob must NOT see carol's personal.md" + ); +} + +#[test] +fn bob_cannot_adduser() { + let (mut fs, _, _, mut bob, ..) = setup(); + let result = try_run("adduser mallory", &mut fs, &mut bob); + assert!(result.is_err(), "bob should not be able to adduser"); + assert!(result.unwrap_err().contains("permission denied")); +} + +#[test] +fn bob_cannot_addgroup() { + let (mut fs, _, _, mut bob, ..) = setup(); + let result = try_run("addgroup hackers", &mut fs, &mut bob); + assert!(result.is_err(), "bob should not be able to addgroup"); +} + +#[test] +fn bob_cannot_deluser() { + let (mut fs, _, _, mut bob, ..) = setup(); + let result = try_run("deluser alice", &mut fs, &mut bob); + assert!(result.is_err(), "bob should not be able to deluser"); +} + +#[test] +fn bob_cannot_chmod_alice_file() { + let (mut fs, _, _, mut bob, ..) = setup(); + let result = try_run("chmod 777 engineering/design.md", &mut fs, &mut bob); + assert!(result.is_err(), "bob cannot chmod alice's file"); +} diff --git a/tests/permissions/carol.rs b/tests/permissions/carol.rs new file mode 100644 index 0000000000000000000000000000000000000000..04f00a31b2af426d2e4b396287a86f094cb95fee --- /dev/null +++ b/tests/permissions/carol.rs @@ -0,0 +1,86 @@ +use super::*; + +#[test] +fn carol_sees_finance_not_engineering() { + let (mut fs, _, _, _, mut carol, _) = setup(); + + let content = run("cat finance/budget.md", &mut fs, &mut carol); + assert!(content.contains("$500K")); + + // engineering/ is 2775 → other has r-x, so carol CAN read the dir + // files are 664 → other has r--, so carol can read them + let content = run("cat engineering/design.md", &mut fs, &mut carol); + assert!( + content.contains("Architecture"), + "carol can read engineering files (other: r-- on 664)" + ); +} + +#[test] +fn carol_reads_own_files() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let content = run("cat home/carol/personal.md", &mut fs, &mut carol); + assert!(content.contains("private financial notes")); +} + +#[test] +fn carol_cannot_read_alice_or_bob_private() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let result = try_run("cat home/alice/diary.md", &mut fs, &mut carol); + assert!(result.is_err(), "carol cannot read alice's diary"); + + let result = try_run("cat home/bob/notes.md", &mut fs, &mut carol); + assert!(result.is_err(), "carol cannot read bob's notes"); +} + +#[test] +fn carol_tree_shows_correct_view() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let tree = run("tree", &mut fs, &mut carol); + + assert!(tree.contains("budget.md"), "carol sees finance/budget.md"); + assert!(tree.contains("personal.md"), "carol sees her own personal.md"); + assert!(tree.contains("readme.md"), "carol sees public/readme.md"); + + assert!(!tree.contains("diary.md"), "carol must NOT see alice's diary"); + assert!( + !tree.contains("home/bob/notes.md") || !tree.contains("notes.md"), + "carol must NOT see bob's notes" + ); +} + +#[test] +fn carol_find_respects_permissions() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let found = run("find . -name *.md", &mut fs, &mut carol); + + assert!(found.contains("budget.md"), "carol finds her budget.md"); + assert!(found.contains("personal.md"), "carol finds her personal.md"); + assert!(found.contains("readme.md"), "carol finds public/readme.md"); + + assert!(!found.contains("diary.md"), "carol must NOT find alice's diary"); +} + +#[test] +fn carol_grep_doesnt_leak_private_data() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let results = run("grep -r filesystem .", &mut fs, &mut carol); + assert!( + !results.contains("diary.md"), + "carol's grep must NOT search alice's diary" + ); + assert!( + !results.contains("designed a filesystem"), + "carol's grep must NOT return alice's private content" + ); +} + +#[test] +fn carol_cannot_write_in_engineering() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let result = try_run("touch engineering/hack.md", &mut fs, &mut carol); + assert!( + result.is_err(), + "carol should not write in engineering/" + ); +} diff --git a/tests/permissions/cross_user.rs b/tests/permissions/cross_user.rs new file mode 100644 index 0000000000000000000000000000000000000000..1bfc6bc455ec0bbefd4c4840f2f240df5753a0a5 --- /dev/null +++ b/tests/permissions/cross_user.rs @@ -0,0 +1,94 @@ +use super::*; + +#[test] +fn all_users_see_different_trees() { + let (mut fs, mut root, mut alice, mut bob, mut carol, mut agent) = setup(); + + let root_tree = run("tree", &mut fs, &mut root); + let alice_tree = run("tree", &mut fs, &mut alice); + let bob_tree = run("tree", &mut fs, &mut bob); + let carol_tree = run("tree", &mut fs, &mut carol); + let agent_tree = run("tree", &mut fs, &mut agent); + + let root_lines = root_tree.lines().count(); + let alice_lines = alice_tree.lines().count(); + let bob_lines = bob_tree.lines().count(); + let carol_lines = carol_tree.lines().count(); + + assert!(root_lines > alice_lines, "root should see more than alice"); + assert!(root_lines > bob_lines, "root should see more than bob"); + assert!(root_lines > carol_lines, "root should see more than carol"); + + assert!(carol_tree.contains("budget.md"), "carol sees budget.md"); + assert!(!alice_tree.contains("budget.md"), "alice does NOT see budget.md"); + assert!(!bob_tree.contains("budget.md"), "bob does NOT see budget.md"); + assert!(!agent_tree.contains("budget.md"), "agent does NOT see budget.md"); + + assert!(alice_tree.contains("diary.md")); + assert!(!bob_tree.contains("diary.md")); + assert!(!carol_tree.contains("diary.md")); + assert!(!agent_tree.contains("diary.md")); + + assert!(bob_tree.contains("notes.md")); + assert!(!alice_tree.contains("notes.md")); + assert!(!carol_tree.contains("notes.md")); + + assert!(carol_tree.contains("personal.md")); + assert!(!alice_tree.contains("personal.md")); + assert!(!bob_tree.contains("personal.md")); +} + +#[test] +fn all_users_find_different_md_files() { + let (mut fs, mut root, mut alice, mut bob, mut carol, mut agent) = setup(); + + let root_find = run("find . -name *.md", &mut fs, &mut root); + let alice_find = run("find . -name *.md", &mut fs, &mut alice); + let bob_find = run("find . -name *.md", &mut fs, &mut bob); + let carol_find = run("find . -name *.md", &mut fs, &mut carol); + let agent_find = run("find . -name *.md", &mut fs, &mut agent); + + let root_count = root_find.lines().count(); + let alice_count = alice_find.lines().count(); + let bob_count = bob_find.lines().count(); + let carol_count = carol_find.lines().count(); + let agent_count = agent_find.lines().count(); + + assert_eq!(root_count, 9, "root should find all 9 .md files"); + assert!(alice_count < root_count, "alice finds fewer than root"); + assert!(bob_count < root_count, "bob finds fewer than root"); + assert!(carol_count < root_count, "carol finds fewer than root"); + assert!(agent_count < root_count, "agent finds fewer than root"); + + assert!(!alice_find.contains("budget.md")); + assert!(!alice_find.contains("home/bob/notes.md")); + assert!(!alice_find.contains("personal.md")); + + assert!(carol_find.contains("budget.md")); + assert!(carol_find.contains("design.md"), "eng files are 664, carol has other:r--"); +} + +#[test] +fn grep_isolation_across_users() { + let (mut fs, mut root, mut alice, mut bob, mut carol, _) = setup(); + + let root_grep = run("grep -r TODO .", &mut fs, &mut root); + let alice_grep = run("grep -r TODO .", &mut fs, &mut alice); + let bob_grep = run("grep -r TODO .", &mut fs, &mut bob); + let carol_grep = run("grep -r TODO .", &mut fs, &mut carol); + + assert!(root_grep.contains("TODO"), "root finds TODO in bob's notes"); + assert!(bob_grep.contains("TODO"), "bob finds TODO in his own notes"); + assert!(!alice_grep.contains("TODO"), "alice must NOT see bob's private TODO"); + assert!(!carol_grep.contains("TODO"), "carol must NOT see bob's private TODO"); + + let root_grep = run("grep -r 500K .", &mut fs, &mut root); + let carol_grep = run("grep -r 500K .", &mut fs, &mut carol); + let alice_grep = run("grep -r 500K .", &mut fs, &mut alice); + let bob_grep = run("grep -r 500K .", &mut fs, &mut bob); + + assert!(root_grep.contains("500K"), "root finds 500K"); + assert!(carol_grep.contains("500K"), "carol finds 500K in her budget"); + assert!(!alice_grep.contains("500K"), "alice must NOT see finance data"); + assert!(!bob_grep.contains("500K"), "bob must NOT see finance data"); +} diff --git a/tests/permissions/main.rs b/tests/permissions/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..0365b3a0ae0d95ffcdd5c6049e75a2f661d84fb9 --- /dev/null +++ b/tests/permissions/main.rs @@ -0,0 +1,219 @@ +use markdownfs::auth::session::Session; +use markdownfs::cmd; +use markdownfs::cmd::parser; +use markdownfs::fs::VirtualFs; + +mod root; +mod alice; +mod bob; +mod carol; +mod agent; +mod write_enforce; +mod special_bits; +mod ownership; +mod cross_user; +mod adversarial; + +fn run(line: &str, fs: &mut VirtualFs, session: &mut Session) -> String { + let pipeline = parser::parse_pipeline(line); + cmd::execute_pipeline(&pipeline, fs, session).unwrap() +} + +#[allow(dead_code)] +fn run_err(line: &str, fs: &mut VirtualFs, session: &mut Session) -> String { + let pipeline = parser::parse_pipeline(line); + cmd::execute_pipeline(&pipeline, fs, session) + .unwrap_err() + .to_string() +} + +fn try_run(line: &str, fs: &mut VirtualFs, session: &mut Session) -> Result { + let pipeline = parser::parse_pipeline(line); + cmd::execute_pipeline(&pipeline, fs, session).map_err(|e| e.to_string()) +} + +/// Build a multi-tenant filesystem: +/// +/// / +/// ├── public/ (root:root 755) — everyone can read +/// │ └── readme.md (root:root 644) +/// ├── engineering/ (root:eng 2775) — setgid, eng group can write +/// │ ├── design.md (alice:eng 664) +/// │ └── roadmap.md (bob:eng 664) +/// ├── finance/ (root:fin 2770) — fin group only, no others +/// │ └── budget.md (carol:fin 660) +/// ├── home/ +/// │ ├── alice/ (alice:alice 750) +/// │ │ └── diary.md (alice:alice 600) +/// │ ├── bob/ (bob:bob 750) +/// │ │ └── notes.md (bob:bob 600) +/// │ └── carol/ (carol:carol 750) +/// │ └── personal.md (carol:carol 600) +/// └── tmp/ (root:root 1777) — sticky bit +/// ├── alice-tmp.md (alice:alice 644) +/// └── bob-tmp.md (bob:bob 644) +/// +/// Users: +/// - root (uid=0) — superuser +/// - alice (uid=1) — member of: alice, engineering, wheel +/// - bob (uid=2) — member of: bob, engineering +/// - carol (uid=3) — member of: carol, finance +/// - agent-x (uid=4) — agent, member of: agent-x, engineering +fn setup() -> (VirtualFs, Session, Session, Session, Session, Session) { + let mut fs = VirtualFs::new(); + let mut root = Session::root(); + + // Create groups + run("addgroup engineering", &mut fs, &mut root); + run("addgroup finance", &mut fs, &mut root); + + // Create users + run("adduser alice", &mut fs, &mut root); + run("adduser bob", &mut fs, &mut root); + run("adduser carol", &mut fs, &mut root); + run("addagent agent-x", &mut fs, &mut root); + + // Group memberships + run("usermod -aG engineering alice", &mut fs, &mut root); + run("usermod -aG wheel alice", &mut fs, &mut root); + run("usermod -aG engineering bob", &mut fs, &mut root); + run("usermod -aG finance carol", &mut fs, &mut root); + run("usermod -aG engineering agent-x", &mut fs, &mut root); + + // Build directory structure + // /public (755 root:root) + run("mkdir public", &mut fs, &mut root); + run("touch public/readme.md", &mut fs, &mut root); + run("write public/readme.md # Welcome to markdownfs", &mut fs, &mut root); + + // /engineering (2775 root:eng) — setgid so new files inherit eng group + run("mkdir engineering", &mut fs, &mut root); + run("chown root:engineering engineering", &mut fs, &mut root); + run("chmod 2775 engineering", &mut fs, &mut root); + + // /finance (2770 root:fin) — no other access + run("mkdir finance", &mut fs, &mut root); + run("chown root:finance finance", &mut fs, &mut root); + run("chmod 2770 finance", &mut fs, &mut root); + + // /home directories (adduser already creates /home and /home/, + // so use mkdir -p to avoid AlreadyExists errors) + run("mkdir -p home/alice", &mut fs, &mut root); + run("chown alice:alice home/alice", &mut fs, &mut root); + run("chmod 750 home/alice", &mut fs, &mut root); + + run("mkdir -p home/bob", &mut fs, &mut root); + run("chown bob:bob home/bob", &mut fs, &mut root); + run("chmod 750 home/bob", &mut fs, &mut root); + + run("mkdir -p home/carol", &mut fs, &mut root); + run("chown carol:carol home/carol", &mut fs, &mut root); + run("chmod 750 home/carol", &mut fs, &mut root); + + // /tmp (1777 root:root) — sticky bit + run("mkdir tmp", &mut fs, &mut root); + run("chmod 1777 tmp", &mut fs, &mut root); + + // Create sessions for each user + let alice_user = fs.registry.get_user(1).unwrap(); + let alice = Session::new( + alice_user.uid, + alice_user.groups[0], + alice_user.groups.clone(), + alice_user.name.clone(), + ); + + let bob_user = fs.registry.get_user(2).unwrap(); + let bob = Session::new( + bob_user.uid, + bob_user.groups[0], + bob_user.groups.clone(), + bob_user.name.clone(), + ); + + let carol_user = fs.registry.get_user(3).unwrap(); + let carol = Session::new( + carol_user.uid, + carol_user.groups[0], + carol_user.groups.clone(), + carol_user.name.clone(), + ); + + let agent_user = fs.registry.get_user(4).unwrap(); + let agent = Session::new( + agent_user.uid, + agent_user.groups[0], + agent_user.groups.clone(), + agent_user.name.clone(), + ); + + // Now create files as specific users + + // Alice writes in engineering + let mut alice_mut = alice.clone(); + run("touch engineering/design.md", &mut fs, &mut alice_mut); + run( + "write engineering/design.md # System Design\n\nArchitecture overview here.", + &mut fs, + &mut alice_mut, + ); + run("chmod 664 engineering/design.md", &mut fs, &mut alice_mut); + + // Bob writes in engineering + let mut bob_mut = bob.clone(); + run("touch engineering/roadmap.md", &mut fs, &mut bob_mut); + run( + "write engineering/roadmap.md # Roadmap\n\nQ1: Launch MVP", + &mut fs, + &mut bob_mut, + ); + run("chmod 664 engineering/roadmap.md", &mut fs, &mut bob_mut); + + // Carol writes in finance + let mut carol_mut = carol.clone(); + run("touch finance/budget.md", &mut fs, &mut carol_mut); + run( + "write finance/budget.md # Q1 Budget\n\nTotal: $500K", + &mut fs, + &mut carol_mut, + ); + run("chmod 660 finance/budget.md", &mut fs, &mut carol_mut); + + // Private home files + run("touch home/alice/diary.md", &mut fs, &mut alice_mut); + run( + "write home/alice/diary.md Dear diary, today I designed a filesystem.", + &mut fs, + &mut alice_mut, + ); + run("chmod 600 home/alice/diary.md", &mut fs, &mut alice_mut); + + run("touch home/bob/notes.md", &mut fs, &mut bob_mut); + run( + "write home/bob/notes.md TODO: review the design doc", + &mut fs, + &mut bob_mut, + ); + run("chmod 600 home/bob/notes.md", &mut fs, &mut bob_mut); + + run("touch home/carol/personal.md", &mut fs, &mut carol_mut); + run( + "write home/carol/personal.md My private financial notes.", + &mut fs, + &mut carol_mut, + ); + run("chmod 600 home/carol/personal.md", &mut fs, &mut carol_mut); + + // Tmp files + run("touch tmp/alice-tmp.md", &mut fs, &mut alice_mut); + run( + "write tmp/alice-tmp.md scratch work", + &mut fs, + &mut alice_mut, + ); + + run("touch tmp/bob-tmp.md", &mut fs, &mut bob_mut); + run("write tmp/bob-tmp.md draft notes", &mut fs, &mut bob_mut); + + (fs, root, alice, bob, carol, agent) +} diff --git a/tests/permissions/ownership.rs b/tests/permissions/ownership.rs new file mode 100644 index 0000000000000000000000000000000000000000..e26c5f4ce15f59eb749864b02d7fe2930ec5c09f --- /dev/null +++ b/tests/permissions/ownership.rs @@ -0,0 +1,122 @@ +use super::*; + +// ════════════════════════════════════════════════════════════════ +// CHMOD / CHOWN RESTRICTIONS +// ════════════════════════════════════════════════════════════════ + +#[test] +fn only_owner_can_chmod() { + let (mut fs, _, mut alice, mut bob, ..) = setup(); + run("chmod 644 engineering/design.md", &mut fs, &mut alice); + + let result = try_run("chmod 777 engineering/design.md", &mut fs, &mut bob); + assert!(result.is_err(), "bob cannot chmod alice's file"); +} + +#[test] +fn only_root_can_chown_uid() { + let (mut fs, _, mut alice, ..) = setup(); + let result = try_run( + "chown bob: engineering/design.md", + &mut fs, + &mut alice, + ); + assert!(result.is_err(), "alice cannot chown uid to bob"); +} + +#[test] +fn root_can_chown() { + let (mut fs, mut root, ..) = setup(); + run("chown bob:engineering engineering/design.md", &mut fs, &mut root); + let stat = run("stat engineering/design.md", &mut fs, &mut root); + assert!(stat.contains("Uid: 2"), "uid should be bob's (2). stat: {stat}"); +} + +#[test] +fn carol_cannot_chmod_engineering_files() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let result = try_run("chmod 777 engineering/design.md", &mut fs, &mut carol); + assert!(result.is_err(), "carol cannot chmod engineering files"); +} + +// ════════════════════════════════════════════════════════════════ +// GROUP-BASED ACCESS +// ════════════════════════════════════════════════════════════════ + +#[test] +fn group_read_access() { + let (mut fs, mut root, _, mut bob, ..) = setup(); + // Create a file readable only by engineering group + run("touch engineering/secret-eng.md", &mut fs, &mut root); + run("write engineering/secret-eng.md Engineering secrets", &mut fs, &mut root); + run("chown root:engineering engineering/secret-eng.md", &mut fs, &mut root); + run("chmod 640 engineering/secret-eng.md", &mut fs, &mut root); + + // bob is in engineering group — should be able to read + let content = run("cat engineering/secret-eng.md", &mut fs, &mut bob); + assert!(content.contains("Engineering secrets")); +} + +#[test] +fn non_group_cannot_read_group_file() { + let (mut fs, mut root, _, _, mut carol, _) = setup(); + run("touch engineering/eng-only.md", &mut fs, &mut root); + run("write engineering/eng-only.md For engineers only", &mut fs, &mut root); + run("chown root:engineering engineering/eng-only.md", &mut fs, &mut root); + run("chmod 640 engineering/eng-only.md", &mut fs, &mut root); + + // carol is NOT in engineering group — cannot read (other bits are 0) + let result = try_run("cat engineering/eng-only.md", &mut fs, &mut carol); + assert!(result.is_err(), "carol not in engineering, should not read 640 file"); +} + +// ════════════════════════════════════════════════════════════════ +// USER MANAGEMENT EDGE CASES +// ════════════════════════════════════════════════════════════════ + +#[test] +fn cannot_create_duplicate_user() { + let (mut fs, mut root, ..) = setup(); + let result = try_run("adduser alice", &mut fs, &mut root); + assert!(result.is_err(), "duplicate user should fail"); +} + +#[test] +fn cannot_create_duplicate_group() { + let (mut fs, mut root, ..) = setup(); + let result = try_run("addgroup engineering", &mut fs, &mut root); + assert!(result.is_err(), "duplicate group should fail"); +} + +#[test] +fn cannot_delete_root_user() { + let (mut fs, mut root, ..) = setup(); + let result = try_run("deluser root", &mut fs, &mut root); + assert!(result.is_err(), "cannot delete root user"); +} + +#[test] +fn whoami_reflects_su() { + let (mut fs, mut root, ..) = setup(); + let output = run("whoami", &mut fs, &mut root); + assert_eq!(output.trim(), "root"); + + run("su alice", &mut fs, &mut root); + let output = run("whoami", &mut fs, &mut root); + assert_eq!(output.trim(), "alice"); +} + +#[test] +fn id_shows_groups() { + let (mut fs, mut root, ..) = setup(); + let output = run("id alice", &mut fs, &mut root); + assert!(output.contains("uid=1(alice)")); + assert!(output.contains("engineering") || output.contains("wheel")); +} + +#[test] +fn groups_shows_all_memberships() { + let (mut fs, mut root, ..) = setup(); + let output = run("groups alice", &mut fs, &mut root); + assert!(output.contains("alice")); +} diff --git a/tests/permissions/root.rs b/tests/permissions/root.rs new file mode 100644 index 0000000000000000000000000000000000000000..cba1dd02dc4e3077c7c387d8c9d59f05e2725617 --- /dev/null +++ b/tests/permissions/root.rs @@ -0,0 +1,57 @@ +use super::*; + +#[test] +fn root_sees_all_top_level() { + let (mut fs, mut root, ..) = setup(); + let ls = run("ls", &mut fs, &mut root); + assert!(ls.contains("public/"), "root should see public/"); + assert!(ls.contains("engineering/"), "root should see engineering/"); + assert!(ls.contains("finance/"), "root should see finance/"); + assert!(ls.contains("home/"), "root should see home/"); + assert!(ls.contains("tmp/"), "root should see tmp/"); +} + +#[test] +fn root_sees_all_in_tree() { + let (mut fs, mut root, ..) = setup(); + let tree = run("tree", &mut fs, &mut root); + assert!(tree.contains("readme.md")); + assert!(tree.contains("design.md")); + assert!(tree.contains("roadmap.md")); + assert!(tree.contains("budget.md")); + assert!(tree.contains("diary.md")); + assert!(tree.contains("notes.md")); + assert!(tree.contains("personal.md")); + assert!(tree.contains("alice-tmp.md")); + assert!(tree.contains("bob-tmp.md")); +} + +#[test] +fn root_reads_all_files() { + let (mut fs, mut root, ..) = setup(); + let content = run("cat home/alice/diary.md", &mut fs, &mut root); + assert!(content.contains("designed a filesystem")); + let content = run("cat finance/budget.md", &mut fs, &mut root); + assert!(content.contains("$500K")); +} + +#[test] +fn root_can_write_anywhere() { + let (mut fs, mut root, ..) = setup(); + run("touch finance/root-added.md", &mut fs, &mut root); + run( + "write finance/root-added.md Root can write here", + &mut fs, + &mut root, + ); + let content = run("cat finance/root-added.md", &mut fs, &mut root); + assert!(content.contains("Root can write here")); +} + +#[test] +fn root_can_delete_in_any_sticky_dir() { + let (mut fs, mut root, ..) = setup(); + run("rm tmp/alice-tmp.md", &mut fs, &mut root); + let ls = run("ls tmp", &mut fs, &mut root); + assert!(!ls.contains("alice-tmp.md")); +} diff --git a/tests/permissions/special_bits.rs b/tests/permissions/special_bits.rs new file mode 100644 index 0000000000000000000000000000000000000000..bbdee61dc1ffeb902ada3f344611f137d54d9354 --- /dev/null +++ b/tests/permissions/special_bits.rs @@ -0,0 +1,152 @@ +use super::*; + +// ════════════════════════════════════════════════════════════════ +// SETGID INHERITANCE +// ════════════════════════════════════════════════════════════════ + +#[test] +fn setgid_inherits_group() { + let (mut fs, _, mut alice, ..) = setup(); + run("touch engineering/inherited.md", &mut fs, &mut alice); + let stat = run("stat engineering/inherited.md", &mut fs, &mut alice); + + let eng_gid = fs.registry.lookup_gid("engineering").unwrap(); + assert!( + stat.contains(&format!("Gid: {eng_gid}")), + "file should inherit engineering group via setgid. stat: {stat}" + ); +} + +#[test] +fn setgid_inherits_on_mkdir() { + let (mut fs, _, mut alice, ..) = setup(); + run("mkdir engineering/subdir", &mut fs, &mut alice); + let stat = run("stat engineering/subdir", &mut fs, &mut alice); + + let eng_gid = fs.registry.lookup_gid("engineering").unwrap(); + assert!( + stat.contains(&format!("Gid: {eng_gid}")), + "subdir should inherit engineering group via setgid. stat: {stat}" + ); +} + +#[test] +fn setgid_file_inherits_from_finance() { + let (mut fs, _, _, _, mut carol, _) = setup(); + run("touch finance/report.md", &mut fs, &mut carol); + let stat = run("stat finance/report.md", &mut fs, &mut carol); + + let fin_gid = fs.registry.lookup_gid("finance").unwrap(); + assert!( + stat.contains(&format!("Gid: {fin_gid}")), + "file in finance/ should inherit finance group. stat: {stat}" + ); +} + +// ════════════════════════════════════════════════════════════════ +// STICKY BIT — /tmp +// ════════════════════════════════════════════════════════════════ + +#[test] +fn sticky_bit_prevents_deletion_by_others() { + let (mut fs, _, mut alice, mut bob, ..) = setup(); + let result = try_run("rm tmp/alice-tmp.md", &mut fs, &mut bob); + assert!( + result.is_err(), + "bob should not delete alice's file in sticky dir" + ); + + run("rm tmp/alice-tmp.md", &mut fs, &mut alice); + let ls = run("ls tmp", &mut fs, &mut alice); + assert!(!ls.contains("alice-tmp.md"), "alice deleted her own file"); +} + +#[test] +fn sticky_bit_owner_can_delete_own() { + let (mut fs, _, _, mut bob, ..) = setup(); + run("rm tmp/bob-tmp.md", &mut fs, &mut bob); + let ls = run("ls tmp", &mut fs, &mut bob); + assert!(!ls.contains("bob-tmp.md")); +} + +#[test] +fn sticky_bit_root_can_delete_any() { + let (mut fs, mut root, ..) = setup(); + run("rm tmp/alice-tmp.md", &mut fs, &mut root); + let ls = run("ls tmp", &mut fs, &mut root); + assert!(!ls.contains("alice-tmp.md")); +} + +#[test] +fn sticky_bit_carol_cannot_delete_bob_tmp() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let result = try_run("rm tmp/bob-tmp.md", &mut fs, &mut carol); + assert!( + result.is_err(), + "carol should not delete bob's file in sticky dir" + ); +} + +// ════════════════════════════════════════════════════════════════ +// MODE 000 — TOTAL LOCKOUT +// ════════════════════════════════════════════════════════════════ + +#[test] +fn mode_000_blocks_everyone_except_root() { + let (mut fs, mut root, mut alice, ..) = setup(); + run("touch public/locked.md", &mut fs, &mut root); + run("write public/locked.md secret data", &mut fs, &mut root); + run("chmod 000 public/locked.md", &mut fs, &mut root); + + // Root can still read (bypasses all checks) + let content = run("cat public/locked.md", &mut fs, &mut root); + assert!(content.contains("secret data")); + + // Alice cannot + let result = try_run("cat public/locked.md", &mut fs, &mut alice); + assert!(result.is_err(), "mode 000 should block alice"); +} + +#[test] +fn mode_000_dir_blocks_ls() { + let (mut fs, mut root, mut alice, ..) = setup(); + run("mkdir locked-dir", &mut fs, &mut root); + run("chmod 000 locked-dir", &mut fs, &mut root); + + let result = try_run("ls locked-dir", &mut fs, &mut alice); + assert!(result.is_err(), "mode 000 dir should block alice's ls"); + + // Root can still list it + let _ = run("ls locked-dir", &mut fs, &mut root); +} + +// ════════════════════════════════════════════════════════════════ +// EXECUTE PERMISSION ON DIRECTORIES +// ════════════════════════════════════════════════════════════════ + +#[test] +fn no_execute_on_dir_blocks_traversal() { + let (mut fs, mut root, mut alice, ..) = setup(); + run("mkdir blocked", &mut fs, &mut root); + run("mkdir blocked/inner", &mut fs, &mut root); + run("cd blocked/inner", &mut fs, &mut root); + run("touch deep.md", &mut fs, &mut root); + run("cd /", &mut fs, &mut root); + + // Remove execute from blocked/ for others + run("chmod 744 blocked", &mut fs, &mut root); + + // alice (other) has r-- on blocked/ → can see but not traverse + let result = try_run("cat blocked/inner/deep.md", &mut fs, &mut alice); + assert!(result.is_err(), "no execute on dir should block traversal"); +} + +#[test] +fn no_execute_on_dir_blocks_cd() { + let (mut fs, mut root, mut alice, ..) = setup(); + run("mkdir noexec", &mut fs, &mut root); + run("chmod 644 noexec", &mut fs, &mut root); // r-- for others, no x + + let result = try_run("cd noexec", &mut fs, &mut alice); + assert!(result.is_err(), "no execute on dir should block cd"); +} diff --git a/tests/permissions/write_enforce.rs b/tests/permissions/write_enforce.rs new file mode 100644 index 0000000000000000000000000000000000000000..c61a86bd2b03591b4eac4511538af4b468d8d0c4 --- /dev/null +++ b/tests/permissions/write_enforce.rs @@ -0,0 +1,43 @@ +use super::*; + +#[test] +fn bob_cannot_write_in_finance() { + let (mut fs, _, _, mut bob, ..) = setup(); + let result = try_run("touch finance/hack.md", &mut fs, &mut bob); + assert!(result.is_err(), "bob should not write in finance/"); +} + +#[test] +fn alice_cannot_overwrite_bob_private_file() { + let (mut fs, _, mut alice, ..) = setup(); + let result = try_run( + "write home/bob/notes.md hacked!", + &mut fs, + &mut alice, + ); + assert!( + result.is_err(), + "alice should not overwrite bob's private file" + ); +} + +#[test] +fn carol_cannot_delete_engineering_files() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let result = try_run("rm engineering/design.md", &mut fs, &mut carol); + assert!(result.is_err(), "carol cannot delete in engineering/"); +} + +#[test] +fn bob_cannot_write_in_alice_home() { + let (mut fs, _, _, mut bob, ..) = setup(); + let result = try_run("touch home/alice/hacked.md", &mut fs, &mut bob); + assert!(result.is_err(), "bob cannot create files in alice's home"); +} + +#[test] +fn carol_cannot_mkdir_in_engineering() { + let (mut fs, _, _, _, mut carol, _) = setup(); + let result = try_run("mkdir engineering/subdir", &mut fs, &mut carol); + assert!(result.is_err(), "carol cannot mkdir in engineering/"); +}