diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..561d0f8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3714 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aegis" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58541132f980da31e9aa99f7bdee69bc84bf1e168b9b91ef2dbe8abb7b4ce5dd" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc 0.2.189", +] + +[[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 = "antithesis_sdk" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08410fcac93669a476c006cd6c4512ac1e2b30fd117231a5d55d8a2c76599b82" +dependencies = [ + "libc 0.2.189", + "libloading", + "linkme", + "once_cell", + "rand 0.8.8", + "rustc_version_runtime", + "serde", + "serde_json", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "aristo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdeefa800110050103e2459a2f8dbdbd560ad046e30f1f87e24ce19c2cb8bde" +dependencies = [ + "aristo-macros", +] + +[[package]] +name = "aristo-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64a66d21a80182b35b1741997a6d2456911f54b7eb1918aa4e2382fc205268d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "assoc" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "branches" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7436b21fd6195415058eb41c9090e156b556bc7e0377bb57c5c026a421521525" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cfg_block" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc 0.2.189", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ab9a1a7094014a0d0813f8e7c5a0058ad8fe6cf7475b9151364259f5a35d0f" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config-ctdra" +version = "1.0.6" +source = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" +checksum = "83eb23d473fce79e8234ad66baf210289b5c9e4c4a587273b4df7deceb73f416" +dependencies = [ + "confy", + "program-ctdra", + "serde", + "sudo-ctdra", +] + +[[package]] +name = "confy" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8807c397789cbe02bbdb1a27ea5f345584132808697b2a3f957c829829ee4814" +dependencies = [ + "etcetera", + "lazy_static", + "serde", + "thiserror", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc 0.2.189", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc 0.2.189", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc 0.2.189", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc 0.2.189", +] + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "env_filter", + "log", +] + +[[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 0.2.189", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.59.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "genawaiter-macro", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc 0.2.189", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc 0.2.189", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc 0.2.189", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc 0.2.189", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[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 = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "785f595c61ef57169a467eeed7b4b6936a66f6cacbb116a96ee86da983251bf4" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea" + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags", + "cfg-if", + "libc 0.2.189", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[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.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libc" +version = "1.0.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0f24f33af482526a4e3f9b47f0abb2c6377a1713c8aa4a8106994689a4cfa5" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + +[[package]] +name = "linkme" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "logger-ctdra" +version = "1.0.5" +source = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" +checksum = "ce612b7d943b77060fa36eab0c85782e650c4cf023e9d560bef791951ed92cad" +dependencies = [ + "program-ctdra", + "time", +] + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "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 = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc 0.2.189", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[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" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[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_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc 0.2.189", +] + +[[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 = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "pack1" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b7bb0ecf2e447b1f20ee94ee79ef6eed1e9d4b3c36ce1903b9dea3bf205523" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 0.2.189", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "program-ctdra" +version = "1.0.1" +source = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" +checksum = "528d5771916f74bdffb77ad60ea00c0ae4bd85f9a84e920cd65730a082133d67" + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc 0.2.189", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "roaring" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc 0.2.189", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc 0.2.189", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes 0.9.3", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc 0.2.189", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc 0.2.189", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "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 = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "shuttle" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab17edba38d63047f46780cf7360acf7467fec2c048928689a5c1dd1c2b4e31" +dependencies = [ + "assoc", + "bitvec", + "cfg-if", + "generator", + "hex", + "owo-colors", + "rand 0.8.8", + "rand_core 0.6.4", + "rand_pcg", + "scoped-tls", + "smallvec", + "tracing", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc 0.2.189", +] + +[[package]] +name = "simsimd" +version = "6.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fb3bc3cdce07a7d7d4caa4c54f8aa967f6be41690482b54b24100a2253fa70" +dependencies = [ + "cc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "smart-mount" +version = "2.0.0" +dependencies = [ + "aes-gcm 0.11.1", + "anyhow", + "clap", + "clap_complete", + "config-ctdra", + "dialoguer", + "getrandom 0.4.3", + "keyring", + "logger-ctdra", + "program-ctdra", + "serde", + "serde_json", + "sudo-ctdra", + "tempfile", + "thiserror", + "tokio", + "toml 1.1.6+spec-1.1.0", + "turso", + "uuid", +] + +[[package]] +name = "softaes" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e14297decde697ddf377c25752aead0927d5cfc89c2684d2af96901a4ceeea" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sudo-ctdra" +version = "1.0.1" +source = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" +checksum = "20e576be60eb2050d475d0fbe46f0d09462ba9985ab55c7833e0c2e6d116d341" +dependencies = [ + "libc 1.0.0-alpha.4", +] + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc 0.2.189", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc 0.2.189", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[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.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "turso" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9491d7a80312c5abe66a4409e4dce02065503a235453c94b9e4133877e39ffc" +dependencies = [ + "mimalloc", + "thiserror", + "tracing", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sync_sdk_kit", +] + +[[package]] +name = "turso_core" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a833cc3bf8d4e6c101c504fa470f8ab4270c2202ff2591b61b2e373b4f20d9b" +dependencies = [ + "aegis", + "aes 0.8.4", + "aes-gcm 0.10.3", + "allocator-api2", + "antithesis_sdk", + "arc-swap", + "aristo", + "bigdecimal", + "bitflags", + "branches", + "bumpalo", + "bytemuck", + "cfg_aliases", + "cfg_block", + "chrono", + "crc32c", + "crossbeam-epoch", + "crossbeam-utils", + "either", + "fallible-iterator", + "fastbloom", + "hex", + "icu_collator", + "icu_locale", + "intrusive-collections", + "io-uring", + "libc 0.2.189", + "libloading", + "libm", + "loom", + "miette", + "num-bigint", + "num-traits", + "pack1", + "parking_lot", + "pastey", + "polling", + "rand 0.9.5", + "rapidhash", + "regex", + "regex-syntax", + "roaring", + "rustc-hash 2.1.3", + "rustix 1.1.4", + "ryu", + "serde_json", + "shuttle", + "simsimd", + "smallvec", + "strum", + "strum_macros", + "tempfile", + "thiserror", + "tracing", + "tracing-subscriber", + "turso_ext", + "turso_macros", + "turso_parser", + "twox-hash", + "uncased", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "turso_ext" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7452fe676450b6840e9eb80e512d14293265221154eba66aba3845dfc0d659f" +dependencies = [ + "chrono", + "getrandom 0.4.3", + "turso_macros", +] + +[[package]] +name = "turso_macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2825eaa6b7b893693636947f988e252c1196393fa54d4b85637c70d616d92fa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "turso_parser" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e58d029f02e442df4be0b5036ce520b49c553505d9d52ef5a98fc4056e95b7" +dependencies = [ + "bitflags", + "memchr", + "miette", + "strum", + "strum_macros", + "thiserror", + "turso_macros", +] + +[[package]] +name = "turso_sdk_kit" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18c1dc1c0304348c39b97bc6b27cdcb1d7292454ebd0de0f30b5ee3a4c61f9bb" +dependencies = [ + "bindgen", + "env_logger", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_ext", + "turso_sdk_kit_macros", +] + +[[package]] +name = "turso_sdk_kit_macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4708b5fe678b8730c85964e3d378f75e18c23c4409971360b2209f4e53e4956" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "turso_sync_engine" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8f03e430240d590d209ac874db52773b7382c627cd604a685affde77622b09" +dependencies = [ + "base64", + "bytes", + "crc32c", + "genawaiter", + "http", + "libc 0.2.189", + "prost", + "roaring", + "serde", + "serde_json", + "thiserror", + "tracing", + "turso_core", + "turso_parser", + "uuid", +] + +[[package]] +name = "turso_sync_sdk_kit" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cb47d056c3ec567745761fa6f832358ca30ef9eb0435fdcfb77c558e70089" +dependencies = [ + "bindgen", + "env_logger", + "genawaiter", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sdk_kit_macros", + "turso_sync_engine", +] + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" +dependencies = [ + "rand 0.10.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[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 = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[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 = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc 0.2.189", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index f75168c..60dc93d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,25 +1,86 @@ [package] -name = "SmartMount" -version = "0.2.0" +name = "smart-mount" +version = "2.0.0" edition = "2024" authors = ['DragonSlayer_14'] readme = "README.md" -license-file = "LICENSE" -repository = "https://gitea.creative-dragonslayer.de/creative-dragonslayer/SmartMount" -description = "SmartMount ist ein innovatives Tool zur intelligenten Verwaltung von Netzwerk-Dateisystemen. Es ermöglicht das automatische Einbinden von Netzwerk-Freigaben über das lokale Netzwerk und wechselt nahtlos zu einer Cloud-basierten Lösung, falls keine lokale Verbindung verfügbar ist. Durch diese hybride Architektur wird ein zuverlässiger Zugriff auf wichtige Daten sichergestellt - egal ob zu Hause oder unterwegs." +license = "GPL-3.0-or-later" +repository = "https://gitea.creative-dragonslayer.de/Linuxapps/SmartMount" +description = "Bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und schaltet automatisch zwischen LAN und Cloud um" [dependencies] -time = { version="0.3.41", features = ["formatting", "macros", "local-offset"] } -serde = { version="1.0.219", features = ["derive"] } -confy = "1.0.0" -libc = "1.0.0-alpha.1" +config-ctdra = { version = "1.0.6", registry = "gitea" } +logger-ctdra = { version = "1.0.5", registry = "gitea" } +program-ctdra = { version = "1.0.1", registry = "gitea" } +sudo-ctdra = { version = "1.0.1", registry = "gitea" } +clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "fs", "time", "sync"] } +# WORKAROUND für RUSTSEC-2026-0253 (Use-after-free in lru < 0.18.2), siehe mac2ip Cargo.toml: +# das Default-Feature "fts" zieht tantivy -> lru "^0.16.3" (verwundbar) ein, obwohl +# smart-mount keine Volltextsuche nutzt. "fts" bleibt deaktiviert, bis eine stabile +# turso-Version tantivy>=eine lru>=0.18.2 zulassende Version pinnt. +turso = { version = "0.7", default-features = false, features = ["mimalloc"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +anyhow = "1" +aes-gcm = "0.11.1" +keyring = "4.2.0" +dialoguer = "0.12.0" +uuid = { version = "1", features = ["v4"] } +# Eigene, direkte Zufallsquelle für Nonce-/Schlüssel-Generierung, entkoppelt von aes-gcms +# rand_core-Re-Export-Kette (siehe crypto/mod.rs, crypto/key.rs). +getrandom = "0.4" + +[dev-dependencies] +tempfile = "3" +toml = "1.1.6" [profile.release] debug = "none" -[package.metadata.deb] -section = "utils" -priority = "optional" +# --- Paketierungs-Metadaten für Linux-Distributionen --- -provides = ["smartmount"] -depends = ["nmap", "$auto"] \ No newline at end of file +[package.metadata.deb] +name = "smart-mount" +maintainer = "DragonSlayer_14" +copyright = "2026 DragonSlayer_14" +section = "net" +priority = "optional" +# mac2ip und nmap sind harte Laufzeitabhängigkeiten (MAC->IP-Auflösung); davfs2/cifs-utils/ +# nfs-common werden bewusst NICHT hart verlangt, da smart-mount pro konfiguriertem Laufwerk +# nur das jeweils benötigte Mount-Backend zur Laufzeit prüft/lädt (siehe MountBackend::check_available). +depends = "$auto, mac2ip, nmap" +recommends = "davfs2, cifs-utils, nfs-common" +extended-description = """\ +smart-mount bindet Paare aus einem lokalen (LAN, WebDAV/SMB/NFS) und einem Cloud-Laufwerk +ein: ist das lokale Laufwerk erreichbar, wird es gemountet, sonst automatisch das +Cloud-Laufwerk. Ein Watchdog prüft periodisch die Erreichbarkeit und schaltet bei Bedarf +zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen Turso-Datenbank +gespeichert; smart-mount kann als root/System-Dienst und als Nutzer-Dienst laufen.\ +""" +assets = [ + ["target/release/smart-mount", "usr/bin/smart-mount", "755"], + ["README.md", "usr/share/doc/smart-mount/README.md", "644"], + ["LICENSE", "usr/share/doc/smart-mount/copyright", "644"], +] + +[package.metadata.generate-rpm] +assets = [ + { source = "target/release/smart-mount", dest = "/usr/bin/smart-mount", mode = "755" }, + { source = "README.md", dest = "/usr/share/doc/smart-mount/README.md", mode = "644", doc = true }, + { source = "LICENSE", dest = "/usr/share/licenses/smart-mount/LICENSE", mode = "644", license = true }, +] +requires = { "mac2ip" = "*", "nmap" = "*" } +suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" } + +[package.metadata.arch] +pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben +arch = "x86_64" +depends = ["gcc-libs", "glibc", "mac2ip", "nmap"] +optdepends = [ + "davfs2: WebDAV-Laufwerke einbinden", + "cifs-utils: SMB/CIFS-Laufwerke einbinden", + "nfs-utils: NFS-Laufwerke einbinden", +] diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs new file mode 100644 index 0000000..952d570 --- /dev/null +++ b/src/cli/doctor.rs @@ -0,0 +1,50 @@ +//! `smart-mount doctor` - prüft die im Laufe der Entwicklung angesammelten Voraussetzungen +//! (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) gebündelt an einer Stelle. + +use smart_mount::config; +use smart_mount::doctor::{self, CheckStatus}; + +pub async fn run(json: bool) -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let results = doctor::run_checks(&cfg); + + if json { + #[derive(serde::Serialize)] + struct JsonResult<'a> { + label: &'a str, + status: &'static str, + detail: &'a str, + } + let json_results: Vec = results + .iter() + .map(|r| JsonResult { + label: &r.label, + status: match r.status { + CheckStatus::Ok => "ok", + CheckStatus::Warn => "warn", + CheckStatus::Fail => "fail", + }, + detail: &r.detail, + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_results)?); + } else { + for r in &results { + let symbol = match r.status { + CheckStatus::Ok => "[ok] ", + CheckStatus::Warn => "[warn]", + CheckStatus::Fail => "[FAIL]", + }; + println!("{symbol} {}: {}", r.label, r.detail); + } + } + + let failures = results + .iter() + .filter(|r| r.status == CheckStatus::Fail) + .count(); + if failures > 0 { + anyhow::bail!("{failures} problem(s) found"); + } + Ok(()) +} diff --git a/src/cli/drive.rs b/src/cli/drive.rs new file mode 100644 index 0000000..5a28d7f --- /dev/null +++ b/src/cli/drive.rs @@ -0,0 +1,607 @@ +//! `smart-mount drive add|edit|remove|list`. +//! +//! `add`/`edit` unterstützen zwei Modi: interaktiv (Standard, `dialoguer`-Prompts, mit +//! bereits per Flag/`edit` vorhandenen Werten als Vorbelegung) und nicht-interaktiv +//! (`--non-interactive`, für Skripte/Automatisierung - fehlende Pflichtfelder sind dann ein +//! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde). + +use std::net::Ipv4Addr; +use std::str::FromStr; + +use clap::{Args, Subcommand}; +use dialoguer::{Confirm, Input, Password, Select}; + +use smart_mount::config::{ + self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountContext, MountKind, +}; +use smart_mount::db::credentials::{Credential, CredentialStore, Side}; + +#[derive(Subcommand)] +pub enum DriveAction { + /// Create a new drive pair (interactive, unless all fields are given via flags). + Add(DriveArgs), + /// Edit an existing drive pair - only the given fields change. + Edit { + id: String, + #[command(flatten)] + args: DriveArgs, + }, + /// Remove an existing drive pair. + Remove { id: String }, + /// List all configured drive pairs. + List { + /// Output as JSON instead of a table - for scripts. + #[arg(long)] + json: bool, + }, +} + +#[derive(Args, Default)] +pub struct DriveArgs { + #[arg(long)] + name: Option, + #[arg(long, value_enum)] + context: Option, + #[arg(long)] + owner_user: Option, + + #[arg(long, value_enum)] + local_kind: Option, + #[arg(long, conflicts_with = "local_mac")] + local_ip: Option, + #[arg(long, conflicts_with = "local_ip")] + local_mac: Option, + #[arg(long)] + local_share: Option, + #[arg(long)] + local_username: Option, + /// Insecure (visible in process listing/shell history) - prefer + /// `--local-password-stdin` for scripts. + #[arg(long, conflicts_with = "local_password_stdin")] + local_password: Option, + /// Reads the local password as one line from stdin instead of passing it as an argument. + #[arg(long)] + local_password_stdin: bool, + + #[arg(long, value_enum)] + cloud_kind: Option, + #[arg(long)] + cloud_host: Option, + #[arg(long)] + cloud_share: Option, + #[arg(long)] + cloud_username: Option, + /// Insecure (visible in process listing/shell history) - prefer + /// `--cloud-password-stdin` for scripts. + #[arg(long, conflicts_with = "cloud_password_stdin")] + cloud_password: Option, + /// Reads the cloud password as one line from stdin instead of passing it as an argument. + #[arg(long)] + cloud_password_stdin: bool, + + /// Does not prompt for anything interactively - missing required fields cause an error + /// instead of a prompt (which would fail anyway without a terminal). For scripts/automation. + #[arg(long)] + non_interactive: bool, +} + +pub async fn run(action: DriveAction) -> anyhow::Result<()> { + match action { + DriveAction::Add(args) => add(args).await, + DriveAction::Edit { id, args } => edit(&id, args).await, + DriveAction::Remove { id } => remove(&id).await, + DriveAction::List { json } => list(json).await, + } +} + +async fn list(json: bool) -> anyhow::Result<()> { + let cfg: AppConfig = config::pairs::load()?; + + if json { + println!("{}", serde_json::to_string_pretty(&cfg.pairs)?); + return Ok(()); + } + + if cfg.pairs.is_empty() { + println!("No drive pairs configured."); + return Ok(()); + } + for pair in &cfg.pairs { + println!( + "{} \"{}\" [{:?}] lokal={} cloud={} -> {}", + pair.id, + pair.name, + pair.context, + pair.local.kind.as_str(), + pair.cloud.kind.as_str(), + pair.mount_point.display() + ); + } + Ok(()) +} + +async fn remove(id: &str) -> anyhow::Result<()> { + // Vor dem Entfernen aus der Config nachschlagen, damit wir hinterher noch wissen, welche + // Mount-Typen/Adressen betroffen sind - nötig, um die passenden Klartext-Zugangsdaten + // (davfs2 secrets, .cred-Datei) aufzuräumen, siehe smart_mount::mount::cleanup_credentials. + let cfg = config::pairs::load()?; + let pair = config::pairs::find_pair(&cfg, id)?; + + config::pairs::remove_pair(id)?; + let creds = CredentialStore::open().await?; + creds.delete(id, None).await?; + smart_mount::mount::cleanup_credentials(&pair, &cfg.settings); + + println!("Drive pair '{id}' removed."); + Ok(()) +} + +async fn add(args: DriveArgs) -> anyhow::Result<()> { + let ni = args.non_interactive; + + let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required"); + let context = resolve_context(args.context, None, ni)?; + let owner_user = resolve_owner_user(args.owner_user, context, None, ni)?; + + let local_kind = resolve_kind(args.local_kind, None, "Local mount type", ni)?; + let local_address = resolve_local_address(args.local_ip, args.local_mac, None, ni)?; + let local_share = resolve_field(args.local_share, None, "Local share/export path", ni, true)? + .expect("required"); + let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?; + let (local_username, local_password) = resolve_credentials( + local_kind, + args.local_username, + local_password_flag, + None, + ni, + "Local credentials", + )?; + + let cloud_kind = resolve_kind(args.cloud_kind, None, "Cloud mount type", ni)?; + let cloud_host = resolve_field(args.cloud_host, None, "Cloud server address/URL", ni, true)? + .expect("required"); + let cloud_share = resolve_field(args.cloud_share, None, "Cloud share/export path", ni, true)? + .expect("required"); + let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?; + let (cloud_username, cloud_password) = resolve_credentials( + cloud_kind, + args.cloud_username, + cloud_password_flag, + None, + ni, + "Cloud credentials", + )?; + + let id = uuid::Uuid::new_v4().to_string(); + let settings = config::pairs::load() + .map(|c| c.settings) + .unwrap_or_default(); + let mount_point = settings.mount_base_dir.join(&id); + + let pair = DrivePair { + id: id.clone(), + name, + enabled: true, + context, + owner_user, + mount_point, + local: LocalSide { + kind: local_kind, + address: local_address, + share: local_share, + username: local_username.clone(), + extra_options: vec![], + }, + cloud: CloudSide { + kind: cloud_kind, + host_or_url: cloud_host, + share: cloud_share, + username: cloud_username.clone(), + extra_options: vec![], + }, + }; + + config::pairs::add_pair(pair)?; + + let creds = CredentialStore::open().await?; + if let Some(pw) = local_password { + creds + .put(&id, Side::Local, local_username.as_deref(), None, &pw) + .await?; + } + if let Some(pw) = cloud_password { + creds + .put(&id, Side::Cloud, cloud_username.as_deref(), None, &pw) + .await?; + } + + println!("Drive pair '{id}' created."); + if context == MountContext::User { + println!("Note: for user-context pairs, 'sudo smart-mount setup fstab' must be run once."); + } + Ok(()) +} + +async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let existing = config::pairs::find_pair(&cfg, id)?; + let ni = args.non_interactive; + + let name = resolve_field(args.name, Some(&existing.name), "Drive pair name", ni, true)? + .expect("required"); + let context = resolve_context(args.context, Some(existing.context), ni)?; + let owner_user = + resolve_owner_user(args.owner_user, context, existing.owner_user.as_deref(), ni)?; + + let local_kind = resolve_kind( + args.local_kind, + Some(existing.local.kind), + "Local mount type", + ni, + )?; + let local_address = resolve_local_address( + args.local_ip, + args.local_mac, + Some(&existing.local.address), + ni, + )?; + let local_share = resolve_field( + args.local_share, + Some(&existing.local.share), + "Local share/export path", + ni, + true, + )? + .expect("required"); + + let creds = CredentialStore::open().await?; + let current_local_cred = creds.get(id, Side::Local).await?; + let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?; + let (local_username, local_password) = resolve_credentials( + local_kind, + args.local_username, + local_password_flag, + current_local_cred.as_ref(), + ni, + "Local credentials", + )?; + + let cloud_kind = resolve_kind( + args.cloud_kind, + Some(existing.cloud.kind), + "Cloud mount type", + ni, + )?; + let cloud_host = resolve_field( + args.cloud_host, + Some(&existing.cloud.host_or_url), + "Cloud server address/URL", + ni, + true, + )? + .expect("required"); + let cloud_share = resolve_field( + args.cloud_share, + Some(&existing.cloud.share), + "Cloud share/export path", + ni, + true, + )? + .expect("required"); + let current_cloud_cred = creds.get(id, Side::Cloud).await?; + let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?; + let (cloud_username, cloud_password) = resolve_credentials( + cloud_kind, + args.cloud_username, + cloud_password_flag, + current_cloud_cred.as_ref(), + ni, + "Cloud credentials", + )?; + + let updated = DrivePair { + id: id.to_string(), + name, + enabled: existing.enabled, + context, + owner_user, + // Mountpoint (und damit die Backing-Verzeichnisse) bleiben unverändert - sonst würden + // eventuell noch aktive Mounts/fstab-Einträge verwaisen. + mount_point: existing.mount_point.clone(), + local: LocalSide { + kind: local_kind, + address: local_address, + share: local_share, + username: local_username.clone(), + extra_options: existing.local.extra_options.clone(), + }, + cloud: CloudSide { + kind: cloud_kind, + host_or_url: cloud_host, + share: cloud_share, + username: cloud_username.clone(), + extra_options: existing.cloud.extra_options.clone(), + }, + }; + + config::pairs::update_pair(updated)?; + + if let Some(pw) = local_password { + creds + .put(id, Side::Local, local_username.as_deref(), None, &pw) + .await?; + } + if let Some(pw) = cloud_password { + creds + .put(id, Side::Cloud, cloud_username.as_deref(), None, &pw) + .await?; + } + + println!("Drive pair '{id}' updated."); + Ok(()) +} + +/// Liest ein Passwort entweder von stdin (eine Zeile, `\r`/`\n` abgeschnitten) oder gibt das +/// per Flag übergebene zurück. +fn read_password_flag(flag: Option, stdin: bool) -> anyhow::Result> { + if stdin { + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf)?; + Ok(Some(buf.trim_end_matches(['\n', '\r']).to_string())) + } else { + Ok(flag) + } +} + +/// Löst ein einzelnes String-Feld auf: Flag > (nicht-interaktiv: `current`, sonst Fehler bei +/// Pflichtfeld) > interaktiver Prompt (vorbelegt mit `current`, falls vorhanden). +fn resolve_field( + flag: Option, + current: Option<&str>, + label: &str, + non_interactive: bool, + required: bool, +) -> anyhow::Result> { + if let Some(v) = flag { + return Ok(Some(v)); + } + if non_interactive { + if required && current.is_none() { + anyhow::bail!( + "Field '{label}' is missing - specify it via a flag in non-interactive mode." + ); + } + return Ok(current.map(str::to_string)); + } + let mut input = Input::::new(); + input = input.with_prompt(label); + if let Some(d) = current { + input = input.default(d.to_string()); + } + Ok(Some(input.interact_text()?)) +} + +fn resolve_context( + flag: Option, + current: Option, + non_interactive: bool, +) -> anyhow::Result { + if let Some(c) = flag { + return Ok(c); + } + if let Some(c) = current + && non_interactive + { + return Ok(c); + } + if non_interactive { + anyhow::bail!( + "Field 'context' is missing - specify it via '--context system|user' in non-interactive mode." + ); + } + let default_idx = if current == Some(MountContext::System) { + 0 + } else { + 1 + }; + let idx = Select::new() + .with_prompt("Context") + .items(["System (root)", "User"]) + .default(default_idx) + .interact()?; + Ok(if idx == 0 { + MountContext::System + } else { + MountContext::User + }) +} + +fn resolve_owner_user( + flag: Option, + context: MountContext, + current: Option<&str>, + non_interactive: bool, +) -> anyhow::Result> { + if let Some(v) = flag { + return Ok(Some(v)); + } + match context { + MountContext::User => { + // Pflicht: wird auch für 'setup fstab' (Gruppenmitgliedschaft, Verzeichnis-Owner) + // und für die uid=/gid=-Zugriffsrechte benötigt. + if non_interactive { + return current.map(str::to_string).map(Some).ok_or_else(|| { + anyhow::anyhow!("Field 'owner_user' is required for context 'user'.") + }); + } + let default_user = current + .map(str::to_string) + .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())); + Ok(Some( + Input::new() + .with_prompt("Linux username (owner)") + .default(default_user) + .interact_text()?, + )) + } + MountContext::System => { + // Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff + // (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership. + if non_interactive { + return Ok(current.map(str::to_string)); + } + let want_owner = Confirm::new() + .with_prompt("Should a specific user get full access to this drive (uid/gid, including script execution)?") + .default(current.is_some()) + .interact()?; + if !want_owner { + return Ok(None); + } + let mut input = Input::::new().with_prompt("Linux username"); + if let Some(c) = current { + input = input.default(c.to_string()); + } + Ok(Some(input.interact_text()?)) + } + } +} + +fn resolve_kind( + flag: Option, + current: Option, + label: &str, + non_interactive: bool, +) -> anyhow::Result { + if let Some(k) = flag { + return Ok(k); + } + if let Some(k) = current + && non_interactive + { + return Ok(k); + } + if non_interactive { + anyhow::bail!( + "Field '{label}' is missing - specify it via a flag in non-interactive mode." + ); + } + let default_idx = match current { + Some(MountKind::Smb) => 1, + Some(MountKind::Nfs) => 2, + _ => 0, + }; + let idx = Select::new() + .with_prompt(label) + .items(["WebDAV", "SMB/CIFS", "NFS"]) + .default(default_idx) + .interact()?; + Ok(match idx { + 0 => MountKind::WebDav, + 1 => MountKind::Smb, + _ => MountKind::Nfs, + }) +} + +fn resolve_local_address( + ip_flag: Option, + mac_flag: Option, + current: Option<&LocalAddress>, + non_interactive: bool, +) -> anyhow::Result { + if let Some(ip) = ip_flag { + return Ok(LocalAddress::Ip(ip)); + } + if let Some(mac) = mac_flag { + return Ok(LocalAddress::Mac(mac)); + } + if non_interactive { + return current.cloned().ok_or_else(|| { + anyhow::anyhow!("Local address is missing - specify '--local-ip' or '--local-mac'.") + }); + } + + let default_idx = usize::from(matches!(current, Some(LocalAddress::Mac(_)))); + let idx = Select::new() + .with_prompt("Local addressing") + .items(["IP address", "MAC address (resolved via mac2ip)"]) + .default(default_idx) + .interact()?; + + if idx == 0 { + let mut input = Input::::new().with_prompt("IP address"); + if let Some(LocalAddress::Ip(ip)) = current { + input = input.default(ip.to_string()); + } + let ip_str: String = input.interact_text()?; + Ok(LocalAddress::Ip(Ipv4Addr::from_str(&ip_str)?)) + } else { + let mut input = Input::::new().with_prompt("MAC address (e.g. aa:bb:cc:dd:ee:ff)"); + if let Some(LocalAddress::Mac(mac)) = current { + input = input.default(mac.clone()); + } + Ok(LocalAddress::Mac(input.interact_text()?)) + } +} + +/// Löst Nutzername/Passwort für eine Seite auf. +/// +/// - Explizit per Flag/stdin gegebenes Passwort wird immer übernommen. +/// - Nicht-interaktiv ohne neues Passwort: nur der Nutzername wird ggf. aktualisiert, das +/// gespeicherte Passwort bleibt unangetastet (`None` im Rückgabewert = "nicht ändern"). +/// - Interaktiv beim Bearbeiten (`current.is_some()`): fragt separat, ob das Passwort +/// überhaupt geändert werden soll (Standard: nein) - ein Edit erzwingt keine Neueingabe. +/// - Interaktiv beim Anlegen: fragt Nutzername+Passwort zusammen ab, sofern der Mount-Typ +/// Zugangsdaten braucht (WebDAV/SMB immer, NFS nur auf Wunsch für Kerberos). +fn resolve_credentials( + kind: MountKind, + username_flag: Option, + password_flag: Option, + current: Option<&Credential>, + non_interactive: bool, + label: &str, +) -> anyhow::Result<(Option, Option)> { + let current_username = current.and_then(|c| c.username.clone()); + + if let Some(pw) = password_flag { + let username = username_flag.or(current_username); + return Ok((username, Some(pw))); + } + + if non_interactive { + return Ok((username_flag.or(current_username), None)); + } + + let is_edit = current.is_some(); + let needs = match kind { + MountKind::WebDav | MountKind::Smb => true, + MountKind::Nfs => Confirm::new() + .with_prompt(format!("{label}: Kerberos credentials for NFS?")) + .default(is_edit) + .interact()?, + }; + if !needs { + return Ok((username_flag.or(current_username), None)); + } + + let mut username_input = Input::::new().with_prompt(format!("{label}: username")); + if let Some(u) = username_flag.or(current_username) { + username_input = username_input.default(u); + } + let username: String = username_input.interact_text()?; + + if is_edit { + let change_password = Confirm::new() + .with_prompt(format!("{label}: change password?")) + .default(false) + .interact()?; + if !change_password { + return Ok((Some(username), None)); + } + } + + let password: String = Password::new() + .with_prompt(format!("{label}: password")) + .with_confirmation("Confirm password", "Passwords do not match") + .interact()?; + Ok((Some(username), Some(password))) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..73ca3dd --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,100 @@ +//! `clap`-CLI-Definition und Dispatch. + +pub mod doctor; +pub mod drive; +pub mod mount_cmd; +pub mod service; +pub mod setup; +pub mod status; +pub mod watch; + +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::Shell; + +#[derive(Parser)] +#[command( + name = "smart-mount", + version, + about = "Dynamically mounts local/cloud drive pairs and switches between them automatically" +)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Manage drive pairs (add/edit/remove/list). + Drive { + #[command(subcommand)] + action: Box, + }, + /// Mount configured drive pairs. + Mount { + /// Only mount this pair (ID or name). + #[arg(long)] + name: Option, + /// Mount all configured pairs. + #[arg(long)] + all: bool, + }, + /// Unmount configured drive pairs. + Unmount { + #[arg(long)] + name: Option, + #[arg(long)] + all: bool, + }, + /// Shows the current mount status. + Status { + #[arg(long)] + name: Option, + /// Output as JSON instead of text - for scripts. + #[arg(long)] + json: bool, + }, + /// A single reconcile pass (local/cloud switching) - meant for systemd timers/cron. + Watch, + /// Install/remove systemd units. + Service { + #[command(subcommand)] + action: service::ServiceAction, + }, + /// One-time root setup for unprivileged user mounts. + Setup { + #[command(subcommand)] + action: setup::SetupAction, + }, + /// Checks prerequisites (binaries, group membership, fstab setup, scheduler). + Doctor { + /// Output as JSON instead of text - for scripts. + #[arg(long)] + json: bool, + }, + /// Prints a shell completion script, e.g.: + /// `smart-mount completions bash > /etc/bash_completion.d/smart-mount`. + Completions { shell: Shell }, +} + +/// Führt das per `Cli` geparste Subcommand aus. +pub async fn dispatch(cli: Cli) -> anyhow::Result<()> { + match cli.command { + Commands::Drive { action } => drive::run(*action).await, + Commands::Mount { name, all } => mount_cmd::run_mount(name, all).await, + Commands::Unmount { name, all } => mount_cmd::run_unmount(name, all).await, + Commands::Status { name, json } => status::run(name, json).await, + Commands::Watch => watch::run().await, + Commands::Service { action } => service::run(action), + Commands::Setup { action } => setup::run(action), + Commands::Doctor { json } => doctor::run(json).await, + Commands::Completions { shell } => { + clap_complete::generate( + shell, + &mut Cli::command(), + "smart-mount", + &mut std::io::stdout(), + ); + Ok(()) + } + } +} diff --git a/src/cli/mount_cmd.rs b/src/cli/mount_cmd.rs new file mode 100644 index 0000000..986baaa --- /dev/null +++ b/src/cli/mount_cmd.rs @@ -0,0 +1,82 @@ +//! `smart-mount mount` / `smart-mount unmount`. + +use smart_mount::config::{self, DrivePair, MountContext}; +use smart_mount::db::credentials::CredentialStore; +use smart_mount::reconcile; + +fn select_pairs( + cfg: &smart_mount::config::AppConfig, + name: Option<&str>, + all: bool, +) -> anyhow::Result> { + if let Some(name) = name { + return Ok(vec![config::pairs::find_pair(cfg, name)?]); + } + if !all { + anyhow::bail!("Please specify '--name ' or '--all'."); + } + + let pairs = if sudo_ctdra::is_run_as_root() { + cfg.pairs + .iter() + .filter(|p| p.context == MountContext::System) + .cloned() + .collect() + } else { + let user = std::env::var("USER").unwrap_or_default(); + cfg.pairs + .iter() + .filter(|p| { + p.context == MountContext::User && p.owner_user.as_deref() == Some(user.as_str()) + }) + .cloned() + .collect() + }; + Ok(pairs) +} + +pub async fn run_mount(name: Option, all: bool) -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let pairs = select_pairs(&cfg, name.as_deref(), all)?; + if pairs.is_empty() { + println!("No matching drive pairs found."); + return Ok(()); + } + + let creds = CredentialStore::open().await?; + for pair in &pairs { + let outcome = reconcile::reconcile_pair(pair, &cfg.settings, &creds).await; + print_outcome(&outcome); + } + Ok(()) +} + +pub async fn run_unmount(name: Option, all: bool) -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let pairs = select_pairs(&cfg, name.as_deref(), all)?; + if pairs.is_empty() { + println!("No matching drive pairs found."); + return Ok(()); + } + + for pair in &pairs { + match reconcile::unmount_pair(pair, &cfg.settings).await { + Ok(_) => println!("{} ({}): unmounted", pair.name, pair.id), + Err(e) => println!("{} ({}): ERROR: {e}", pair.name, pair.id), + } + } + Ok(()) +} + +pub(crate) fn print_outcome(outcome: &reconcile::ReconcileOutcome) { + use reconcile::Action; + let action_str = match &outcome.action { + Action::NoOp => "no change".to_string(), + Action::MountedLocal => "mounted local".to_string(), + Action::MountedCloud => "mounted cloud".to_string(), + Action::SwitchedToLocal => "switched to local".to_string(), + Action::SwitchedToCloud => "switched to cloud".to_string(), + Action::Failed(e) => format!("ERROR: {e}"), + }; + println!("{} ({}): {action_str}", outcome.pair_name, outcome.pair_id); +} diff --git a/src/cli/service.rs b/src/cli/service.rs new file mode 100644 index 0000000..158b05e --- /dev/null +++ b/src/cli/service.rs @@ -0,0 +1,152 @@ +//! `smart-mount service install|uninstall --system|--user`. + +use clap::{Args, Subcommand}; +use smart_mount::config; +use smart_mount::fstab; +use smart_mount::systemd::{self, Scope}; + +#[derive(Subcommand)] +pub enum ServiceAction { + /// Sets up periodic execution: systemd if available, otherwise falls back to cron + /// automatically (see `crontab`). + Install(ScopeArgs), + /// Removes everything that `install`/`crontab`/`setup fstab` have set up - systemd + /// units, cron entry, and (only for `--system`) the managed `/etc/fstab` block. + /// Missing parts are skipped, not treated as an error. + Uninstall(ScopeArgs), + /// Sets up periodic execution via cron (alternative to `install` for systems without + /// systemd) - system or user context is chosen automatically based on the current + /// privileges (root -> `/etc/cron.d/smart-mount`, otherwise personal crontab). If no + /// cron mechanism is present, the lines for manual entry are printed instead. + Crontab, +} + +#[derive(Args)] +pub struct ScopeArgs { + #[arg(long, conflicts_with = "user")] + system: bool, + #[arg(long, conflicts_with = "system")] + user: bool, +} + +impl ScopeArgs { + fn scope(&self) -> anyhow::Result { + match (self.system, self.user) { + (true, false) => Ok(Scope::System), + (false, true) => Ok(Scope::User), + _ => anyhow::bail!("Please specify exactly one of '--system' or '--user'."), + } + } +} + +pub fn run(action: ServiceAction) -> anyhow::Result<()> { + match action { + ServiceAction::Install(args) => { + let scope = args.scope()?; + if scope == Scope::System && !sudo_ctdra::is_run_as_root() { + anyhow::bail!( + "'service install --system' requires root privileges (re-run with sudo)." + ); + } + let cfg = config::pairs::load()?; + let interval = cfg.settings.watch_interval_secs; + + if systemd::is_available() { + systemd::install(scope, interval)?; + println!("systemd units installed and enabled ({scope:?})."); + } else { + println!("systemd not found - setting up cron instead."); + match systemd::install_cron(scope, interval)? { + systemd::CronInstallOutcome::SystemFile(path) => { + println!("Cron entry written: {}", path.display()); + } + systemd::CronInstallOutcome::UserCrontab => { + println!("Personal crontab updated (see 'crontab -l')."); + } + systemd::CronInstallOutcome::Unavailable => { + println!( + "Neither systemd nor cron found - here are the lines for manual entry:" + ); + print!("{}", systemd::crontab_equivalent(interval)); + } + } + } + Ok(()) + } + ServiceAction::Uninstall(args) => { + let scope = args.scope()?; + if scope == Scope::System && !sudo_ctdra::is_run_as_root() { + anyhow::bail!( + "'service uninstall --system' requires root privileges (re-run with sudo)." + ); + } + + let mut removed = Vec::new(); + + if systemd::is_available() { + match systemd::uninstall(scope)? { + systemd::SystemdUninstallOutcome::Removed => removed.push("systemd units"), + systemd::SystemdUninstallOutcome::NotPresent => {} + } + } + + match systemd::uninstall_cron(scope)? { + systemd::CronUninstallOutcome::Removed => removed.push("cron entry"), + systemd::CronUninstallOutcome::NotPresent => {} + } + + // fstab-Einträge sind unabhängig vom --system/--user-Scope des Aufrufers immer + // root-weit (setup() betrifft alle User-Kontext-Paare) - nur bei --system mit + // aufräumen, damit ein `--user`-Uninstall nicht versehentlich Root-Konfiguration + // anfasst, die ein anderer Nutzer noch braucht. + if scope == Scope::System { + match fstab::teardown()? { + fstab::FstabTeardownOutcome::Removed => removed.push("fstab entries"), + fstab::FstabTeardownOutcome::NotPresent => {} + } + } + + if removed.is_empty() { + println!("Nothing to remove - nothing was installed ({scope:?})."); + } else { + println!("Removed ({scope:?}): {}", removed.join(", ")); + } + Ok(()) + } + ServiceAction::Crontab => { + let cfg = config::pairs::load()?; + let interval = cfg.settings.watch_interval_secs; + // Scope folgt automatisch den aktuellen Rechten, wie bei `mount --all` - + // root pflegt den systemweiten Cron-Eintrag, ein normaler Nutzer seine eigene + // Crontab. Anders als bei `install`/`uninstall` gibt es hier bewusst keine + // expliziten `--system`/`--user`-Flags, weil die Wahl ohnehin durch die Rechte + // vorgegeben ist (root kann nicht "versehentlich" die falsche Crontab treffen). + let scope = if sudo_ctdra::is_run_as_root() { + Scope::System + } else { + Scope::User + }; + + match systemd::install_cron(scope, interval)? { + systemd::CronInstallOutcome::SystemFile(path) => { + println!("Cron entry written: {}", path.display()); + } + systemd::CronInstallOutcome::UserCrontab => { + println!("Personal crontab updated (see 'crontab -l')."); + } + systemd::CronInstallOutcome::Unavailable => { + println!( + "No cron mechanism found ({}) - here are the lines for manual entry:", + if scope == Scope::System { + "/etc/cron.d is missing" + } else { + "'crontab' not in PATH" + } + ); + print!("{}", systemd::crontab_equivalent(interval)); + } + } + Ok(()) + } + } +} diff --git a/src/cli/setup.rs b/src/cli/setup.rs new file mode 100644 index 0000000..4a47bdb --- /dev/null +++ b/src/cli/setup.rs @@ -0,0 +1,21 @@ +//! `smart-mount setup fstab`. + +use clap::Subcommand; +use smart_mount::fstab; + +#[derive(Subcommand)] +pub enum SetupAction { + /// One-time root setup: `/etc/fstab` entries + group membership for + /// unprivileged user mounts. + Fstab, +} + +pub fn run(action: SetupAction) -> anyhow::Result<()> { + match action { + SetupAction::Fstab => { + fstab::setup()?; + println!("fstab setup complete."); + Ok(()) + } + } +} diff --git a/src/cli/status.rs b/src/cli/status.rs new file mode 100644 index 0000000..bd911be --- /dev/null +++ b/src/cli/status.rs @@ -0,0 +1,98 @@ +//! `smart-mount status`. + +use serde::Serialize; + +use smart_mount::config; +use smart_mount::db::credentials::Side; +use smart_mount::mount::target; +use smart_mount::network; + +#[derive(Serialize)] +struct PairStatus { + id: String, + name: String, + mount_point: String, + active: Option<&'static str>, + local_source: String, + local_reachable: bool, + cloud_source: String, + cloud_reachable: bool, +} + +pub async fn run(name: Option, json: bool) -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let pairs = match &name { + Some(name) => vec![config::pairs::find_pair(&cfg, name)?], + None => cfg.pairs.clone(), + }; + + if pairs.is_empty() { + if json { + println!("[]"); + } else { + println!("No drive pairs configured."); + } + return Ok(()); + } + + let statuses: Vec = pairs + .iter() + .map(|pair| { + let active = match target::active_side(pair) { + Some(Side::Local) => Some("local"), + Some(Side::Cloud) => Some("cloud"), + None => None, + }; + PairStatus { + id: pair.id.clone(), + name: pair.name.clone(), + mount_point: pair.mount_point.display().to_string(), + active, + local_source: target::local_source(&pair.local, &cfg.settings), + local_reachable: network::is_reachable(&local_src_host(&pair.local, &cfg.settings)), + cloud_source: target::cloud_source(&pair.cloud), + cloud_reachable: network::is_reachable(&pair.cloud.host_or_url), + } + }) + .collect(); + + if json { + println!("{}", serde_json::to_string_pretty(&statuses)?); + return Ok(()); + } + + for status in &statuses { + println!("{} ({})", status.name, status.id); + println!(" Symlink: {}", status.mount_point); + println!(" Mounted: {}", status.active.unwrap_or("not mounted")); + println!( + " Local: {} [{}]", + status.local_source, + reachable_str(status.local_reachable) + ); + println!( + " Cloud: {} [{}]", + status.cloud_source, + reachable_str(status.cloud_reachable) + ); + } + + Ok(()) +} + +fn reachable_str(reachable: bool) -> &'static str { + if reachable { + "reachable" + } else { + "unreachable" + } +} + +fn local_src_host( + local: &smart_mount::config::LocalSide, + settings: &smart_mount::config::GlobalSettings, +) -> String { + smart_mount::network::address::resolve_ip(&local.address, settings) + .map(|ip| ip.to_string()) + .unwrap_or_else(|_| "unresolved".to_string()) +} diff --git a/src/cli/watch.rs b/src/cli/watch.rs new file mode 100644 index 0000000..39dea80 --- /dev/null +++ b/src/cli/watch.rs @@ -0,0 +1,26 @@ +//! `smart-mount watch` - ein einzelner Reconcile-Durchlauf, gedacht für systemd-Timer/Cron. + +use smart_mount::config; +use smart_mount::db::credentials::CredentialStore; +use smart_mount::reconcile::{self, Action}; + +use crate::cli::mount_cmd::print_outcome; + +pub async fn run() -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let creds = CredentialStore::open().await?; + let outcomes = reconcile::watch_once(&cfg, &creds).await; + + let mut had_failure = false; + for outcome in &outcomes { + print_outcome(outcome); + if matches!(outcome.action, Action::Failed(_)) { + had_failure = true; + } + } + + if had_failure { + anyhow::bail!("at least one drive pair could not be reconciled"); + } + Ok(()) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..692ecfd --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,15 @@ +//! Konfigurationsverwaltung: Schema + CRUD auf Laufwerkspaaren, aufbauend auf `config-ctdra`. + +pub mod pairs; +pub mod schema; + +pub use schema::{ + AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountContext, + MountKind, +}; + +/// Initialisiert den Konfigurationsdateinamen bei `config-ctdra`. Muss vor dem ersten +/// `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter Zustand). +pub fn init() { + config_ctdra::set_config_name("config"); +} diff --git a/src/config/pairs.rs b/src/config/pairs.rs new file mode 100644 index 0000000..a8c902d --- /dev/null +++ b/src/config/pairs.rs @@ -0,0 +1,51 @@ +//! CRUD-Operationen auf [`AppConfig::pairs`], atomar über `config_ctdra::modify`. + +use crate::config::schema::{AppConfig, DrivePair}; +use crate::error::{Error, Result}; + +/// Lädt die aktuelle Konfiguration frisch von der Platte. +pub fn load() -> Result { + Ok(config_ctdra::load::()?) +} + +/// Fügt ein neues Laufwerkspaar hinzu (load-mutate-store in einem atomaren Schritt). +pub fn add_pair(pair: DrivePair) -> Result { + Ok(config_ctdra::modify::(|cfg| { + cfg.pairs.push(pair.clone()); + })?) +} + +/// Ersetzt ein bestehendes Laufwerkspaar (Vergleich über `id`). +pub fn update_pair(pair: DrivePair) -> Result { + let id = pair.id.clone(); + let updated = config_ctdra::modify::(move |cfg| { + if let Some(existing) = cfg.pairs.iter_mut().find(|p| p.id == pair.id) { + *existing = pair.clone(); + } + })?; + if !updated.pairs.iter().any(|p| p.id == id) { + return Err(Error::PairNotFound(id)); + } + Ok(updated) +} + +/// Entfernt ein Laufwerkspaar per ID. +pub fn remove_pair(id: &str) -> Result { + let before_len = load()?.pairs.len(); + let updated = config_ctdra::modify::(|cfg| { + cfg.pairs.retain(|p| p.id != id); + })?; + if updated.pairs.len() == before_len { + return Err(Error::PairNotFound(id.to_string())); + } + Ok(updated) +} + +/// Sucht ein Laufwerkspaar per ID oder (fallback) exaktem Namen. +pub fn find_pair(cfg: &AppConfig, id_or_name: &str) -> Result { + cfg.pairs + .iter() + .find(|p| p.id == id_or_name || p.name == id_or_name) + .cloned() + .ok_or_else(|| Error::PairNotFound(id_or_name.to_string())) +} diff --git a/src/config/schema.rs b/src/config/schema.rs new file mode 100644 index 0000000..0b14b7f --- /dev/null +++ b/src/config/schema.rs @@ -0,0 +1,221 @@ +//! Konfigurationsschema: globale Einstellungen + Liste konfigurierter Laufwerkspaare. +//! +//! Passwörter sind hier bewusst NICHT enthalten - sie leben verschlüsselt in der +//! Turso-Datenbank (siehe [`crate::db::credentials`]), niemals im Klartext in der TOML-Datei. + +use std::net::Ipv4Addr; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene +/// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei +/// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie +/// persistieren müssen. Root-/System-Kontext-Paare landen flach unter `/run/media/smart-mount` +/// (ein systemweiter Dienst, keinem einzelnen Nutzer zugeordnet); Nutzer-Kontext-Paare unter +/// `/run/media//smart-mount`, damit mehrere lokale Nutzer mit eigenen Paaren sich +/// nicht denselben Namensraum teilen. +fn default_mount_base_dir() -> PathBuf { + if sudo_ctdra::is_run_as_root() { + PathBuf::from("/run/media/smart-mount") + } else { + let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string()); + PathBuf::from("/run/media").join(user).join("smart-mount") + } +} + +fn default_log_level() -> String { + "info".to_string() +} + +fn default_watch_interval_secs() -> u64 { + 120 +} + +fn default_mac2ip_binary() -> String { + "mac2ip".to_string() +} + +/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` unter +/// `~/.config/smart-mount/config.toml` (Nutzerkontext) bzw. `/etc/smart-mount/config.toml` +/// (Root-Kontext). +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct AppConfig { + #[serde(default)] + pub settings: GlobalSettings, + #[serde(default)] + pub pairs: Vec, +} + +/// Globale, paarunabhängige Einstellungen. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GlobalSettings { + #[serde(default = "default_mount_base_dir")] + pub mount_base_dir: PathBuf, + #[serde(default = "default_log_level")] + pub log_level: String, + /// Periode, mit der `smart-mount watch` über den generierten systemd-Timer bzw. die + /// dokumentierte Crontab-Zeile ausgeführt werden soll. + #[serde(default = "default_watch_interval_secs")] + pub watch_interval_secs: u64, + /// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad). + #[serde(default = "default_mac2ip_binary")] + pub mac2ip_binary: String, +} + +impl Default for GlobalSettings { + fn default() -> Self { + Self { + mount_base_dir: default_mount_base_dir(), + log_level: default_log_level(), + watch_interval_secs: default_watch_interval_secs(), + mac2ip_binary: default_mac2ip_binary(), + } + } +} + +/// Ein konfiguriertes Laufwerkspaar: eine lokale (LAN) und eine Cloud-Seite, die dasselbe +/// logische Laufwerk repräsentieren. Nur eine Seite ist zu einem Zeitpunkt an `mount_point` +/// eingebunden - siehe [`crate::reconcile`]. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DrivePair { + /// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten, Mount-Unterverzeichnis, + /// systemd-Unit-Namen und fstab-Einträge. + pub id: String, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + pub context: MountContext, + /// Pflicht bei `context == User`: der Linux-Benutzername, dem dieses Paar gehört. + #[serde(default)] + pub owner_user: Option, + pub mount_point: PathBuf, + pub local: LocalSide, + pub cloud: CloudSide, +} + +fn default_true() -> bool { + true +} + +/// Ob ein Laufwerkspaar systemweit (root, `/etc/fstab`+systemd-System-Service) oder als +/// einzelner Nutzer (`systemd --user`, unprivilegiert über `setup fstab`) eingebunden wird. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] +#[serde(rename_all = "lowercase")] +#[value(rename_all = "lowercase")] +pub enum MountContext { + System, + User, +} + +/// Die lokale (LAN-)Seite eines Laufwerkspaars. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct LocalSide { + pub kind: MountKind, + pub address: LocalAddress, + /// Freigabename/Exportpfad (SMB-Share, NFS-Export, WebDAV-Pfadsegment). + pub share: String, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub extra_options: Vec, +} + +/// Die Cloud-Seite eines Laufwerkspaars. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CloudSide { + pub kind: MountKind, + /// Volle URL (WebDAV) bzw. Server-Adresse (SMB/NFS). + pub host_or_url: String, + pub share: String, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub extra_options: Vec, +} + +/// Unterstützte Mount-Verfahren. Jede Variante wird dynamisch auf ein +/// [`crate::mount::MountBackend`] dispatcht - siehe dort für die "nur bei Bedarf laden"-Logik. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash, clap::ValueEnum)] +#[serde(rename_all = "lowercase")] +#[value(rename_all = "lowercase")] +pub enum MountKind { + WebDav, + Smb, + Nfs, +} + +impl MountKind { + pub fn as_str(&self) -> &'static str { + match self { + MountKind::WebDav => "webdav", + MountKind::Smb => "smb", + MountKind::Nfs => "nfs", + } + } +} + +/// Adressierung der lokalen Seite: entweder direkt per IP oder per MAC-Adresse, die zur +/// Laufzeit über `mac2ip` aufgelöst wird (siehe [`crate::network::mac2ip`]). +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum LocalAddress { + Ip(Ipv4Addr), + Mac(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_config_round_trips_through_toml() { + let cfg = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![DrivePair { + id: "pair-1".into(), + name: "NAS".into(), + enabled: true, + context: MountContext::User, + owner_user: Some("dragon".into()), + mount_point: PathBuf::from("/home/dragon/smart-mount/pair-1"), + local: LocalSide { + kind: MountKind::WebDav, + address: LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into()), + share: "/dav".into(), + username: Some("nasuser".into()), + extra_options: vec![], + }, + cloud: CloudSide { + kind: MountKind::WebDav, + host_or_url: "https://cloud.example.com/remote.php/dav/files/dragon".into(), + share: "/".into(), + username: Some("dragon".into()), + extra_options: vec![], + }, + }], + }; + + let toml_str = toml::to_string_pretty(&cfg).expect("serialize"); + let round_tripped: AppConfig = toml::from_str(&toml_str).expect("deserialize"); + + assert_eq!(round_tripped.pairs.len(), 1); + assert_eq!(round_tripped.pairs[0].id, "pair-1"); + assert_eq!(round_tripped.pairs[0].context, MountContext::User); + match &round_tripped.pairs[0].local.address { + LocalAddress::Mac(mac) => assert_eq!(mac, "aa:bb:cc:dd:ee:ff"), + LocalAddress::Ip(_) => panic!("expected Mac variant"), + } + } + + #[test] + fn default_mount_base_dir_uses_run_media() { + // Tests laufen nie als root, daher greift hier immer der Nutzerkontext-Zweig. + let dir = default_mount_base_dir(); + assert!(dir.starts_with("/run/media")); + assert!(dir.ends_with("smart-mount")); + if let Ok(user) = std::env::var("USER") { + assert!(dir.to_string_lossy().contains(&user)); + } + } +} diff --git a/src/crypto/key.rs b/src/crypto/key.rs new file mode 100644 index 0000000..016585c --- /dev/null +++ b/src/crypto/key.rs @@ -0,0 +1,151 @@ +//! Master-Schlüssel-Auflösung für die Zugangsdaten-Verschlüsselung. +//! +//! Reihenfolge (wie mit dem Nutzer abgestimmt): +//! - Root-/System-Kontext: **immer** die Schlüsseldatei (kein Nutzer-Keyring im +//! Systemdienst-Kontext verfügbar). +//! - Nutzerkontext: zuerst das OS-Keyring (GNOME Keyring/KWallet über secret-service) +//! versuchen, bei Nichtverfügbarkeit (z. B. Headless-Server, kein D-Bus-Secret-Service) +//! transparent auf die Schlüsseldatei zurückfallen. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use crate::error::{Error, Result}; + +const KEYRING_SERVICE: &str = "smart-mount"; +const KEYRING_USERNAME: &str = "master-key"; +const KEY_FILE_NAME: &str = "master.key"; +const KEY_LEN: usize = 32; + +/// Ermittelt (und erzeugt bei Bedarf) den 256-Bit-Master-Schlüssel für die +/// Zugangsdaten-Verschlüsselung, siehe Modul-Dokumentation für die Fallback-Reihenfolge. +pub fn resolve_master_key() -> Result<[u8; 32]> { + if sudo_ctdra::is_run_as_root() { + return file_key::load_or_create(&key_file_path()); + } + + match keyring_key::load_or_create() { + Ok(key) => Ok(key), + Err(reason) => { + logger_ctdra::warn( + "crypto", + &format!("OS keyring not available ({reason}), using key file"), + ); + file_key::load_or_create(&key_file_path()) + } + } +} + +fn key_file_path() -> PathBuf { + let config_path = config_ctdra::get_config_path(); + config_path + .parent() + .map(|dir| dir.join(KEY_FILE_NAME)) + .unwrap_or_else(|| PathBuf::from(KEY_FILE_NAME)) +} + +mod file_key { + use super::*; + + pub fn load_or_create(path: &Path) -> Result<[u8; 32]> { + if path.exists() { + return read(path); + } + create(path) + } + + fn read(path: &Path) -> Result<[u8; 32]> { + let mut file = File::open(path).map_err(|e| Error::io(path, e))?; + let mut buf = Vec::with_capacity(KEY_LEN); + file.read_to_end(&mut buf).map_err(|e| Error::io(path, e))?; + if buf.len() != KEY_LEN { + return Err(Error::Crypto(format!( + "key file '{}' has unexpected length ({} instead of {KEY_LEN} bytes)", + path.display(), + buf.len() + ))); + } + let mut key = [0u8; KEY_LEN]; + key.copy_from_slice(&buf); + Ok(key) + } + + fn create(path: &Path) -> Result<[u8; 32]> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; + } + + let mut key = [0u8; KEY_LEN]; + fill_random(&mut key)?; + + #[cfg(unix)] + let mut opts = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + #[cfg(not(unix))] + let mut opts = OpenOptions::new(); + + let mut file = opts + .write(true) + .create_new(true) + .open(path) + .map_err(|e| Error::io(path, e))?; + file.write_all(&key).map_err(|e| Error::io(path, e))?; + Ok(key) + } + + fn fill_random(buf: &mut [u8]) -> Result<()> { + ::getrandom::fill(buf) + .map_err(|e| Error::Crypto(format!("Random number generator failed: {e}"))) + } +} + +mod keyring_key { + use super::*; + + pub fn load_or_create() -> std::result::Result<[u8; 32], String> { + let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USERNAME) + .map_err(|e| format!("Could not create keyring entry: {e}"))?; + + match entry.get_password() { + Ok(hex_key) => decode(&hex_key), + Err(keyring::Error::NoEntry) => { + let key = generate()?; + entry + .set_password(&encode(&key)) + .map_err(|e| format!("Could not store key in keyring: {e}"))?; + Ok(key) + } + Err(e) => Err(format!("Keyring access failed: {e}")), + } + } + + fn generate() -> std::result::Result<[u8; 32], String> { + let mut key = [0u8; 32]; + ::getrandom::fill(&mut key).map_err(|e| format!("Random number generator failed: {e}"))?; + Ok(key) + } + + fn encode(key: &[u8; 32]) -> String { + key.iter().map(|b| format!("{b:02x}")).collect() + } + + fn decode(hex_key: &str) -> std::result::Result<[u8; 32], String> { + if hex_key.len() != 64 { + return Err(format!( + "unexpected key length in keyring ({} instead of 64 hex characters)", + hex_key.len() + )); + } + let mut key = [0u8; 32]; + for (i, chunk) in hex_key.as_bytes().chunks(2).enumerate() { + let byte_str = std::str::from_utf8(chunk).map_err(|e| e.to_string())?; + key[i] = u8::from_str_radix(byte_str, 16).map_err(|e| e.to_string())?; + } + Ok(key) + } +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..a73d600 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,84 @@ +//! Verschlüsselung von Zugangsdaten vor der Ablage in der Turso-Datenbank. +//! +//! Turso hat aktuell keine produktionsreife eingebaute Verschlüsselung (nur ein +//! experimentelles, unauditiertes Feature) - Passwörter werden daher hier selbst mit +//! AES-256-GCM verschlüsselt, bevor sie als Ciphertext-BLOB in die DB geschrieben werden. + +pub mod key; + +use aes_gcm::aead::{Aead, KeyInit}; +// `aead::Nonce` ist über den AEAD-Algorithmus-Typ parametrisiert (löst intern +// `::NonceSize` auf) - anders als `aes_gcm::Nonce`, das direkt +// über die Array-Länge parametrisiert ist. Für `Nonce::` brauchen wir Ersteres. +use aes_gcm::aead::Nonce; +use aes_gcm::{Aes256Gcm, Key}; + +use crate::error::{Error, Result}; + +/// AES-GCM-Nonce-Länge in Byte (96 Bit, Standard für AES-256-GCM). +pub const NONCE_LEN: usize = 12; + +/// Verschlüsselt `plaintext` mit dem gegebenen 256-Bit-Schlüssel. +/// +/// Gibt `(ciphertext, nonce)` zurück - beide werden zusammen mit dem Datensatz gespeichert; +/// der Schlüssel selbst wird niemals in der Datenbank abgelegt. +/// +/// Erzeugt die Nonce bewusst über eine eigene, direkte `getrandom`-Abhängigkeit statt über +/// `aes_gcm::aead::rand_core::OsRng` - Letzteres ist seit aes-gcm 0.11 nicht mehr ohne +/// Weiteres erreichbar (rand_core 0.9s `OsRng` steckt hinter dem `os_rng`-Feature, das über +/// aes-gcms Re-Export-Kette nicht automatisch aktiviert wird). `getrandom::fill` ist +/// unabhängig davon stabil und genau für diesen Zweck gedacht. +pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec, Vec)> { + let cipher = Aes256Gcm::new(&Key::::from(*key)); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + getrandom::fill(&mut nonce_bytes) + .map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))?; + let nonce: Nonce = nonce_bytes.into(); + + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|e| Error::Crypto(format!("Encryption failed: {e}")))?; + Ok((ciphertext, nonce.to_vec())) +} + +/// Entschlüsselt einen zuvor mit [`encrypt`] erzeugten Ciphertext. +pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result> { + if nonce.len() != NONCE_LEN { + return Err(Error::Crypto(format!( + "invalid nonce length: expected {NONCE_LEN}, got {}", + nonce.len() + ))); + } + let cipher = Aes256Gcm::new(&Key::::from(*key)); + let nonce: Nonce = Nonce::::try_from(nonce) + .map_err(|_| Error::Crypto("invalid nonce".to_string()))?; + cipher + .decrypt(&nonce, ciphertext) + .map_err(|e| Error::Crypto(format!("Decryption failed: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_round_trip() { + let key = [7u8; 32]; + let plaintext = b"correct horse battery staple"; + + let (ciphertext, nonce) = encrypt(plaintext, &key).expect("encrypt"); + assert_ne!(ciphertext, plaintext); + + let decrypted = decrypt(&ciphertext, &nonce, &key).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decrypt_fails_with_wrong_key() { + let key = [1u8; 32]; + let other_key = [2u8; 32]; + let (ciphertext, nonce) = encrypt(b"secret", &key).expect("encrypt"); + assert!(decrypt(&ciphertext, &nonce, &other_key).is_err()); + } +} diff --git a/src/db/credentials.rs b/src/db/credentials.rs new file mode 100644 index 0000000..8090314 --- /dev/null +++ b/src/db/credentials.rs @@ -0,0 +1,140 @@ +//! Verschlüsselte Zugangsdaten-CRUD auf der `credentials`-Tabelle. + +use crate::crypto; +use crate::crypto::key::resolve_master_key; +use crate::error::Result; + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Welche Seite eines Laufwerkspaars die Zugangsdaten betreffen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Local, + Cloud, +} + +impl Side { + pub fn as_str(&self) -> &'static str { + match self { + Side::Local => "local", + Side::Cloud => "cloud", + } + } +} + +/// Entschlüsselte Zugangsdaten für eine Seite eines Laufwerkspaars. +#[derive(Debug, Clone)] +pub struct Credential { + pub username: Option, + pub domain: Option, + pub password: String, +} + +/// Dünner Wrapper um die `credentials`-Tabelle; ver-/entschlüsselt transparent mit dem +/// per [`resolve_master_key`] ermittelten Schlüssel. +pub struct CredentialStore { + conn: turso::Connection, +} + +impl CredentialStore { + /// Öffnet die Datenbank und initialisiert das Schema bei Bedarf. + pub async fn open() -> Result { + Ok(Self { + conn: crate::db::open().await?, + }) + } + + /// Speichert (oder ersetzt) die Zugangsdaten für `pair_id`/`side`. + pub async fn put( + &self, + pair_id: &str, + side: Side, + username: Option<&str>, + domain: Option<&str>, + password: &str, + ) -> Result<()> { + let key = resolve_master_key()?; + let (ciphertext, nonce) = crypto::encrypt(password.as_bytes(), &key)?; + let now = now_unix(); + + self.conn + .execute( + "INSERT INTO credentials (pair_id, side, username, domain, ciphertext, nonce, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(pair_id, side) DO UPDATE SET \ + username = excluded.username, domain = excluded.domain, \ + ciphertext = excluded.ciphertext, nonce = excluded.nonce, updated_at = excluded.updated_at", + ( + pair_id.to_string(), + side.as_str().to_string(), + username.map(str::to_string), + domain.map(str::to_string), + ciphertext, + nonce, + now, + ), + ) + .await?; + Ok(()) + } + + /// Liest und entschlüsselt die Zugangsdaten für `pair_id`/`side`, falls vorhanden. + pub async fn get(&self, pair_id: &str, side: Side) -> Result> { + let mut rows = self + .conn + .query( + "SELECT username, domain, ciphertext, nonce FROM credentials WHERE pair_id = ?1 AND side = ?2", + (pair_id.to_string(), side.as_str().to_string()), + ) + .await?; + + let Some(row) = rows.next().await? else { + return Ok(None); + }; + + let username: Option = row.get(0)?; + let domain: Option = row.get(1)?; + let ciphertext: Vec = row.get(2)?; + let nonce: Vec = row.get(3)?; + + let key = resolve_master_key()?; + let plaintext = crypto::decrypt(&ciphertext, &nonce, &key)?; + let password = String::from_utf8(plaintext).map_err(|e| { + crate::error::Error::Crypto(format!("password is not valid UTF-8: {e}")) + })?; + + Ok(Some(Credential { + username, + domain, + password, + })) + } + + /// Löscht Zugangsdaten. `side = None` löscht beide Seiten (z. B. beim Entfernen eines Paars). + pub async fn delete(&self, pair_id: &str, side: Option) -> Result<()> { + match side { + Some(side) => { + self.conn + .execute( + "DELETE FROM credentials WHERE pair_id = ?1 AND side = ?2", + (pair_id.to_string(), side.as_str().to_string()), + ) + .await?; + } + None => { + self.conn + .execute( + "DELETE FROM credentials WHERE pair_id = ?1", + (pair_id.to_string(),), + ) + .await?; + } + } + Ok(()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..db3a816 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,53 @@ +//! Lokale Turso-Datenbank für verschlüsselt gespeicherte Zugangsdaten. + +pub mod credentials; + +use std::path::PathBuf; + +use crate::error::{Error, Result}; + +const DB_FILE_NAME: &str = "smart-mount.db"; + +/// Pfad zur Datenbankdatei: dasselbe Verzeichnis wie die Konfigurationsdatei, folgt also +/// automatisch derselben Root-/User-Auflösung wie `config-ctdra`. +pub fn resolve_db_path() -> PathBuf { + let config_path = config_ctdra::get_config_path(); + config_path + .parent() + .map(|dir| dir.join(DB_FILE_NAME)) + .unwrap_or_else(|| PathBuf::from(DB_FILE_NAME)) +} + +/// Öffnet (und initialisiert bei Bedarf) die lokale Datenbank am aufgelösten Pfad. +pub async fn open() -> Result { + let path = resolve_db_path(); + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir) + .await + .map_err(|e| Error::io(dir, e))?; + } + let db = turso::Builder::new_local(path.to_string_lossy().as_ref()) + .build() + .await?; + let conn = db.connect()?; + init_schema(&conn).await?; + Ok(conn) +} + +async fn init_schema(conn: &turso::Connection) -> Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS credentials (\ + pair_id TEXT NOT NULL, \ + side TEXT NOT NULL CHECK(side IN ('local','cloud')), \ + username TEXT, \ + domain TEXT, \ + ciphertext BLOB NOT NULL, \ + nonce BLOB NOT NULL, \ + updated_at INTEGER NOT NULL, \ + PRIMARY KEY (pair_id, side)\ + )", + (), + ) + .await?; + Ok(()) +} diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..a854ef0 --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,344 @@ +//! Diagnose-Checks für `smart-mount doctor` - prüft die im Laufe der Entwicklung +//! angesammelten Voraussetzungen (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) +//! gebündelt an einer Stelle, statt sie einzeln erst beim Mount-Fehlschlag zu entdecken. + +use std::collections::HashSet; +use std::process::Command; + +use crate::config::{AppConfig, DrivePair, LocalAddress, MountContext, MountKind}; +use crate::mount; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStatus { + Ok, + Warn, + Fail, +} + +#[derive(Debug)] +pub struct CheckResult { + pub label: String, + pub status: CheckStatus, + pub detail: String, +} + +fn ok(label: impl Into, detail: impl Into) -> CheckResult { + CheckResult { + label: label.into(), + status: CheckStatus::Ok, + detail: detail.into(), + } +} +fn warn(label: impl Into, detail: impl Into) -> CheckResult { + CheckResult { + label: label.into(), + status: CheckStatus::Warn, + detail: detail.into(), + } +} +fn fail(label: impl Into, detail: impl Into) -> CheckResult { + CheckResult { + label: label.into(), + status: CheckStatus::Fail, + detail: detail.into(), + } +} + +/// Führt alle Checks gegen die aktuelle Konfiguration aus. +pub fn run_checks(cfg: &AppConfig) -> Vec { + let mut results = Vec::new(); + + results.push(check_timeout_binary()); + results.push(check_scheduler()); + results.push(check_mount_base_dir(&cfg.settings.mount_base_dir)); + + let used_kinds = used_mount_kinds(cfg); + for kind in [MountKind::WebDav, MountKind::Smb, MountKind::Nfs] { + if used_kinds.contains(&kind) { + results.push(check_backend(kind)); + } + } + + if uses_mac_addressing(cfg) { + results.push(check_binary( + "mac2ip", + &cfg.settings.mac2ip_binary, + "install mac2ip (private tool, see README)", + )); + results.push(check_binary("nmap", "nmap", "install package 'nmap'")); + } + + if cfg.pairs.iter().any(|p| p.context == MountContext::User) { + results.push(check_fstab_setup(cfg)); + if used_kinds.contains(&MountKind::WebDav) { + results.push(check_davfs2_group_membership(cfg)); + } + } + + for pair in &cfg.pairs { + if pair.context == MountContext::User && pair.owner_user.is_none() { + results.push(fail( + format!("Pair '{}': owner_user", pair.name), + "context 'user', but no owner_user set - 'setup fstab' will reject this." + .to_string(), + )); + } + } + + results +} + +fn used_mount_kinds(cfg: &AppConfig) -> HashSet { + let mut set = HashSet::new(); + for pair in &cfg.pairs { + set.insert(pair.local.kind); + set.insert(pair.cloud.kind); + } + set +} + +fn uses_mac_addressing(cfg: &AppConfig) -> bool { + cfg.pairs + .iter() + .any(|p| matches!(p.local.address, LocalAddress::Mac(_))) +} + +fn check_timeout_binary() -> CheckResult { + if mount::binary_available("timeout") { + ok( + "timeout (coreutils)", + "found - protects mount/umount against hanging indefinitely", + ) + } else { + fail( + "timeout (coreutils)", + "not found - mount/umount calls would block indefinitely, should be present on every Linux system", + ) + } +} + +fn check_binary(name: &str, binary: &str, install_hint: &str) -> CheckResult { + if mount::binary_available(binary) { + ok(name, format!("'{binary}' found")) + } else { + fail(name, format!("'{binary}' not found - {install_hint}")) + } +} + +fn check_backend(kind: MountKind) -> CheckResult { + let backend = mount::backend_for(kind); + match backend.check_available() { + Ok(()) => ok(format!("Backend: {}", backend.name()), "available"), + Err(e) => fail(format!("Backend: {}", backend.name()), e.to_string()), + } +} + +fn check_scheduler() -> CheckResult { + let systemd = crate::systemd::is_available(); + let cron_d = std::path::Path::new("/etc/cron.d").is_dir(); + let crontab = mount::binary_available("crontab"); + + if systemd { + ok( + "Scheduler", + "systemd found - 'smart-mount service install' uses systemd timers", + ) + } else if cron_d || crontab { + warn( + "Scheduler", + "no systemd, but cron found - 'smart-mount service install' automatically falls back to cron", + ) + } else { + fail( + "Scheduler", + "neither systemd nor cron found - periodic 'watch' must be set up manually", + ) + } +} + +fn check_mount_base_dir(dir: &std::path::Path) -> CheckResult { + if dir.is_dir() { + ok("mount_base_dir", format!("'{}' exists", dir.display())) + } else if dir.parent().is_some_and(std::path::Path::is_dir) { + warn( + "mount_base_dir", + format!( + "'{}' does not exist yet, will be created on first mount", + dir.display() + ), + ) + } else { + fail( + "mount_base_dir", + format!( + "'{}' does not exist and its parent directory is also missing", + dir.display() + ), + ) + } +} + +fn check_fstab_setup(cfg: &AppConfig) -> CheckResult { + let existing = std::fs::read_to_string("/etc/fstab").unwrap_or_default(); + if existing.contains("# BEGIN smart-mount managed block") { + ok("setup fstab", "managed block found in /etc/fstab") + } else { + let count = cfg + .pairs + .iter() + .filter(|p| p.context == MountContext::User) + .count(); + fail( + "setup fstab", + format!( + "no managed block found in /etc/fstab, but {count} user-context pair(s) configured - run 'sudo smart-mount setup fstab'" + ), + ) + } +} + +fn check_davfs2_group_membership(cfg: &AppConfig) -> CheckResult { + let owners: HashSet<&str> = cfg + .pairs + .iter() + .filter(|p| { + p.context == MountContext::User + && (p.local.kind == MountKind::WebDav || p.cloud.kind == MountKind::WebDav) + }) + .filter_map(|p: &DrivePair| p.owner_user.as_deref()) + .collect(); + + if owners.is_empty() { + return ok( + "davfs2 group membership", + "no WebDAV user-context pairs with owner_user - nothing to check", + ); + } + + let mut missing = Vec::new(); + for owner in &owners { + let output = Command::new("id").args(["-nG", owner]).output(); + let is_member = output + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .split_whitespace() + .any(|g| g == "davfs2") + }) + .unwrap_or(false); + if !is_member { + missing.push(*owner); + } + } + + if missing.is_empty() { + ok( + "davfs2 group membership", + format!( + "all affected users ({}) are members of the 'davfs2' group", + owners.len() + ), + ) + } else { + warn( + "davfs2 group membership", + format!( + "users without 'davfs2' group: {} - run 'sudo smart-mount setup fstab' (log out and back in afterwards if needed)", + missing.join(", ") + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CloudSide, GlobalSettings, LocalSide}; + use std::net::Ipv4Addr; + + fn sample_pair(context: MountContext, owner_user: Option<&str>, mac: bool) -> DrivePair { + DrivePair { + id: "pair-1".into(), + name: "Test".into(), + enabled: true, + context, + owner_user: owner_user.map(str::to_string), + mount_point: "/media/smart-mount/pair-1".into(), + local: LocalSide { + kind: MountKind::Nfs, + address: if mac { + LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into()) + } else { + LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)) + }, + share: "share".into(), + username: None, + extra_options: vec![], + }, + cloud: CloudSide { + kind: MountKind::WebDav, + host_or_url: "https://cloud.example.com/dav".into(), + share: "/".into(), + username: None, + extra_options: vec![], + }, + } + } + + #[test] + fn used_mount_kinds_collects_both_sides_across_pairs() { + let cfg = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![sample_pair(MountContext::System, None, false)], + }; + let kinds = used_mount_kinds(&cfg); + assert!(kinds.contains(&MountKind::Nfs)); + assert!(kinds.contains(&MountKind::WebDav)); + assert!(!kinds.contains(&MountKind::Smb)); + } + + #[test] + fn uses_mac_addressing_detects_mac_pairs() { + let with_mac = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![sample_pair(MountContext::System, None, true)], + }; + let without_mac = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![sample_pair(MountContext::System, None, false)], + }; + assert!(uses_mac_addressing(&with_mac)); + assert!(!uses_mac_addressing(&without_mac)); + } + + #[test] + fn flags_user_context_pair_without_owner_user() { + let cfg = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![sample_pair(MountContext::User, None, false)], + }; + let results = run_checks(&cfg); + assert!( + results + .iter() + .any(|r| r.status == CheckStatus::Fail && r.label.contains("owner_user")) + ); + } + + #[test] + fn does_not_flag_owner_user_when_present() { + let cfg = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![sample_pair(MountContext::User, Some("dragon"), false)], + }; + let results = run_checks(&cfg); + assert!(!results.iter().any(|r| r.label.contains("owner_user"))); + } + + #[test] + fn empty_config_still_runs_global_checks_without_panicking() { + let cfg = AppConfig::default(); + let results = run_checks(&cfg); + assert!(results.iter().any(|r| r.label.contains("timeout"))); + assert!(results.iter().any(|r| r.label == "Scheduler")); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..fedbb2f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,59 @@ +//! Zentraler Fehlertyp für alle Bibliotheksmodule. + +use std::path::PathBuf; + +/// Sammelfehler für alle `smart-mount`-Bibliotheksmodule. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Configuration error: {0}")] + Config(#[from] config_ctdra::ConfyError), + + #[error("Database error: {0}")] + Db(#[from] turso::Error), + + #[error("Encryption error: {0}")] + Crypto(String), + + #[error("Key storage error: {0}")] + Keyring(String), + + #[error("I/O error on '{path}': {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Drive pair '{0}' not found")] + PairNotFound(String), + + #[error("Mount backend '{backend}' not available: {reason}")] + BackendUnavailable { + backend: &'static str, + reason: String, + }, + + #[error("Mount command failed ({context}): {stderr}")] + MountFailed { context: String, stderr: String }, + + #[error("mac2ip resolution failed for MAC {mac}: {reason}")] + Mac2Ip { mac: String, reason: String }, + + #[error("No root context: {0}")] + RequiresRoot(&'static str), + + #[error("{0}")] + Other(String), +} + +/// Ergebnistyp-Alias für `smart-mount`-Bibliotheksfunktionen. +pub type Result = std::result::Result; + +impl Error { + pub fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } +} diff --git a/src/fstab/mod.rs b/src/fstab/mod.rs new file mode 100644 index 0000000..bd4d5e4 --- /dev/null +++ b/src/fstab/mod.rs @@ -0,0 +1,425 @@ +//! Einmaliges root-Setup, das unprivilegierten `User`-Kontext-Paaren erlaubt, sich selbst +//! (unprivilegiert) zu mounten/unmounten. +//! +//! Kernidee: pro Paar werden **zwei** `/etc/fstab`-Zeilen geschrieben - je eine pro Seite, +//! auf das jeweils eindeutige Backing-Verzeichnis dieser Seite (siehe +//! [`crate::mount::target::backing_dir`]), nicht auf einen gemeinsamen Mountpoint. Damit +//! entspricht jede Zeile exakt dem einzigen in `man 8 mount` ("Non-superuser mounts") +//! dokumentierten Fall - genau eine fstab-Zeile pro Ziel - statt sich auf unspezifiziertes +//! Verhalten bei zwei Zeilen mit demselben Ziel zu verlassen. Der sichtbare `pair.mount_point` +//! selbst erscheint dadurch gar nicht in `/etc/fstab` - er ist ein Symlink, den smart-mount +//! zur Laufzeit zwischen den beiden Backing-Verzeichnissen umschaltet (siehe +//! [`crate::reconcile`]). + +use std::path::PathBuf; +use std::process::Command; + +use crate::config::{AppConfig, DrivePair, GlobalSettings, MountContext, MountKind}; +use crate::db::credentials::Side; +use crate::error::{Error, Result}; +use crate::mount::smb; +use crate::mount::target::{self, backing_dir}; + +const BEGIN_MARKER: &str = "# BEGIN smart-mount managed block"; +const END_MARKER: &str = "# END smart-mount managed block"; +const FSTAB_PATH: &str = "/etc/fstab"; + +/// Führt das einmalige root-Setup für alle `User`-Kontext-Paare aus: fstab-Block +/// regenerieren, Gruppenmitgliedschaft sicherstellen, Mountpoints anlegen. +/// +/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root - das ist der +/// einzige Befehl in smart-mount, der das tut (alle anderen root-Aktionen verlangen +/// explizit, bereits als root aufgerufen zu werden). +pub fn setup() -> Result<()> { + if !sudo_ctdra::is_run_as_root() { + let err = sudo_ctdra::run_as_root(); + return Err(Error::Other(format!( + "Restart with root privileges failed: {err}" + ))); + } + + let cfg = crate::config::pairs::load()?; + let user_pairs: Vec<&DrivePair> = cfg + .pairs + .iter() + .filter(|p| p.context == MountContext::User) + .collect(); + + if user_pairs.is_empty() { + logger_ctdra::info("fstab", "No user-context pairs configured - nothing to do."); + return Ok(()); + } + + validate_user_pairs_have_owner(&user_pairs)?; + + for pair in &user_pairs { + ensure_backing_dirs(pair)?; + ensure_group_membership(pair)?; + } + + write_managed_block(&user_pairs, &cfg.settings)?; + + logger_ctdra::info( + "fstab", + "Done. Affected users may need to log out and back in for new group memberships to \ + take effect. For MAC-based local drives in user context: set up passwordless sudo \ + access to 'nmap' if resolution does not already succeed via the ARP neighbor table \ + (see README).", + ); + + Ok(()) +} + +/// Ergebnis von [`teardown`]. +pub enum FstabTeardownOutcome { + /// Der verwaltete Block wurde gefunden und entfernt. + Removed, + /// Kein von smart-mount verwalteter Block vorhanden - nichts zu tun. + NotPresent, +} + +/// Gegenstück zu [`setup`]: entfernt den von smart-mount verwalteten Block wieder aus +/// `/etc/fstab` (Backup wie bei `setup` nach `/etc/fstab.smart-mount.bak`). Rührt bewusst +/// **keine** Backing-Verzeichnisse, gemounteten Daten oder Gruppenmitgliedschaften an - nur +/// die fstab-Zeilen selbst, da das Löschen von Verzeichnissen/Cache-Daten oder das Entfernen +/// aus einer Gruppe ungewollte Nebenwirkungen haben könnte (die Gruppe könnte z. B. auch +/// unabhängig von smart-mount genutzt werden). +/// +/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root (wie `setup`). +pub fn teardown() -> Result { + if !sudo_ctdra::is_run_as_root() { + let err = sudo_ctdra::run_as_root(); + return Err(Error::Other(format!( + "Restart with root privileges failed: {err}" + ))); + } + + let fstab_path = PathBuf::from(FSTAB_PATH); + let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default(); + + if !existing.contains(BEGIN_MARKER) { + return Ok(FstabTeardownOutcome::NotPresent); + } + + backup(&fstab_path, &existing)?; + let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER); + std::fs::write(&fstab_path, without_block).map_err(|e| Error::io(&fstab_path, e))?; + Ok(FstabTeardownOutcome::Removed) +} + +/// Ohne `owner_user` würde die fstab-Zeile ohne `uid=`/`gid=` geschrieben - bei davfs2 heißt +/// das laut `man mount.davfs` ("uid=user"/"gid=group"): JEDES Mitglied der Gruppe 'davfs2' +/// dürfte dieses Paar mounten, nicht nur der vorgesehene Besitzer. Lieber hart fehlschlagen, +/// bevor eine unsichere Zeile geschrieben wird, als das still zuzulassen. +fn validate_user_pairs_have_owner(pairs: &[&DrivePair]) -> Result<()> { + for pair in pairs { + if pair.owner_user.is_none() { + return Err(Error::Other(format!( + "Drive pair '{}' has context 'user', but no owner_user set. \ + Without owner_user, mount access cannot be restricted to a specific \ + user - please set owner_user in the configuration \ + (e.g. via 'smart-mount drive add').", + pair.id + ))); + } + } + Ok(()) +} + +/// Legt beide Backing-Verzeichnisse an (nicht `pair.mount_point` selbst - das bleibt ein +/// Symlink, siehe Moduldoku) und macht `owner_user` zum Besitzer beider. +fn ensure_backing_dirs(pair: &DrivePair) -> Result<()> { + for side in [Side::Local, Side::Cloud] { + create_dir_all_owned(&backing_dir(pair, side), pair.owner_user.as_deref())?; + } + + // Der Elternordner des sichtbaren Mountpoints (z. B. `/run/media//smart-mount`) + // muss dem Nutzer ebenfalls gehören - dort legt `activate_symlink` bei JEDEM `mount`/ + // `watch`-Lauf den Symlink an/ersetzt ihn, und das läuft (anders als dieses einmalige + // Setup) unprivilegiert als der Nutzer selbst. `/run/media` ist standardmäßig `root:root + // 0755` - ohne diesen Schritt könnte der Nutzer dort nicht einmal ein eigenes + // Unterverzeichnis anlegen. + if let Some(parent) = pair.mount_point.parent() { + create_dir_all_owned(parent, pair.owner_user.as_deref())?; + } + Ok(()) +} + +/// Wie `std::fs::create_dir_all`, macht aber zusätzlich `owner` zum Besitzer aller dabei +/// **neu angelegten** Verzeichnisse - nicht bereits vorhandener Elternverzeichnisse (z. B. +/// `/run/media` selbst, das root-eigen bleiben muss). Läuft von `dir` aus rückwärts nach +/// oben, bis der erste bereits existierende Vorfahre gefunden ist. +fn create_dir_all_owned(dir: &std::path::Path, owner: Option<&str>) -> Result<()> { + let Some(owner) = owner else { + return std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e)); + }; + + let mut newly_created = Vec::new(); + let mut current = dir; + while !current.exists() { + newly_created.push(current.to_path_buf()); + match current.parent() { + Some(parent) => current = parent, + None => break, + } + } + + std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; + + // Von oben nach unten chownen (Eltern vor Kindern) - rein kosmetisch, jeder Aufruf ist + // unabhängig, aber so bleibt die Reihenfolge nachvollziehbar. + for path in newly_created.iter().rev() { + let status = Command::new("chown") + .arg(format!("{owner}:{owner}")) + .arg(path) + .status() + .map_err(|e| Error::Other(format!("could not run chown: {e}")))?; + if !status.success() { + return Err(Error::Other(format!( + "chown failed for '{}'", + path.display() + ))); + } + } + Ok(()) +} + +fn ensure_group_membership(pair: &DrivePair) -> Result<()> { + let Some(owner) = &pair.owner_user else { + return Ok(()); + }; + if pair.local.kind == MountKind::WebDav || pair.cloud.kind == MountKind::WebDav { + let status = Command::new("usermod") + .args(["-aG", "davfs2", owner]) + .status() + .map_err(|e| Error::Other(format!("could not run usermod: {e}")))?; + if !status.success() { + logger_ctdra::warn( + "fstab", + &format!( + "Could not add '{owner}' to group 'davfs2' - does the group exist (package 'davfs2' installed)?" + ), + ); + } + } + Ok(()) +} + +fn write_managed_block(pairs: &[&DrivePair], settings: &GlobalSettings) -> Result<()> { + let fstab_path = PathBuf::from(FSTAB_PATH); + let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default(); + + backup(&fstab_path, &existing)?; + + let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER); + let block = render_managed_block(pairs, settings)?; + + let new_contents = format!( + "{}\n{}\n{}\n{}\n", + without_block.trim_end(), + BEGIN_MARKER, + block.trim_end(), + END_MARKER + ); + std::fs::write(&fstab_path, new_contents).map_err(|e| Error::io(&fstab_path, e)) +} + +fn backup(fstab_path: &PathBuf, contents: &str) -> Result<()> { + let backup_path = PathBuf::from(format!("{FSTAB_PATH}.smart-mount.bak")); + std::fs::write(&backup_path, contents).map_err(|e| Error::io(&backup_path, e))?; + let _ = fstab_path; // nur zur Doku der Herkunft von `contents`. + Ok(()) +} + +fn render_managed_block(pairs: &[&DrivePair], settings: &GlobalSettings) -> Result { + let mut lines = Vec::new(); + for pair in pairs { + lines.push(fstab_line(pair, Side::Local, settings)?); + lines.push(fstab_line(pair, Side::Cloud, settings)?); + } + Ok(lines.join("\n")) +} + +fn fstab_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result { + // Wiederverwendet dieselbe Options-Berechnung wie der tatsächliche Mount-Aufruf + // (`mount::target::build_target`, inkl. `apply_owner_permissions`) - insbesondere die + // dort injizierten uid=/gid= sind hier nicht optional: laut `man mount.davfs` + // ("uid=user"/"gid=group") darf ein unprivilegierter Nutzer eine Zeile nur mounten, wenn + // uid= auf ihn selbst zeigt und er Mitglied der in gid= genannten Gruppe ist. Ohne diese + // Optionen dürfte JEDES Mitglied der Gruppe 'davfs2' JEDES konfigurierte Paar mounten, + // nicht nur der vorgesehene Besitzer (`setup()` verweigert daher bereits vorab Paare ohne + // `owner_user`). + // + // Für CIFS ist per `man mount.cifs` BESTÄTIGT, dass dieselbe Beschränkung NICHT existiert: + // uid=/gid= betreffen dort ausschließlich die simulierte Datei-Ownership nach dem Mount, + // nicht das Mount-*Recht* selbst - mount(8)/mount.cifs bieten keinen Mechanismus, eine + // 'user'-fstab-Zeile auf eine bestimmte Person einzuschränken. Für CIFS-Nutzer-Kontext- + // Paare bleibt das eine bewusst akzeptierte, strukturelle Lücke (siehe README) statt einer + // über Mount-Optionen behebbaren - die Optionen werden trotzdem gesetzt, da korrekte + // Ownership unabhängig davon nötig ist. + let target = target::build_target(pair, settings, side)?; + let kind = target::side_kind(pair, side); + + let (fstype, mut extra_opts) = match kind { + MountKind::WebDav => ("davfs", String::new()), + MountKind::Smb => { + let creds = smb::credentials_path(&pair.id, side); + ("cifs", format!(",credentials={}", creds.display())) + } + MountKind::Nfs => ("nfs", String::new()), + }; + + for opt in &target.options { + extra_opts.push_str(&format!(",{opt}")); + } + + // Jede Seite bekommt ihr eigenes, eindeutiges Backing-Verzeichnis als Ziel - siehe + // Moduldoku. `pair.mount_point` selbst taucht bewusst NICHT in fstab auf. + // + // WICHTIG: die `user`-Option impliziert laut `man 8 mount` ("Non-superuser mounts") für + // JEDES Dateisystem `noexec,nosuid,nodev`, sofern nicht direkt im selben Optionslisten- + // Eintrag überschrieben. Ohne das explizite `exec` hier könnten auf einem User-Kontext- + // Laufwerk liegende Skripte NICHT ausgeführt werden. `nosuid`/`nodev` bleiben bewusst + // implizit (sinnvolle Absicherung, dafür gab es keine Anforderung). + Ok(format!( + "{source} {mount_point} {fstype} user,exec,noauto{extra_opts} 0 0", + source = target.source, + mount_point = target.mount_point.display() + )) +} + +/// Zeigt an, dass diese Konfiguration bereits ein einmaliges `setup fstab` benötigt hat. +pub fn requires_setup(cfg: &AppConfig) -> bool { + cfg.pairs.iter().any(|p| p.context == MountContext::User) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + fn sample_pair() -> DrivePair { + // Nutzt den tatsächlich ausführenden Testnutzer statt eines hartkodierten Namens, + // da `fstab_line` jetzt `id -u`/`id -g` für `owner_user` aufruft (siehe + // `mount::target::apply_owner_permissions`) - ein fester Name wäre auf anderen + // Maschinen/CI nicht garantiert vorhanden. + let user = std::env::var("USER").expect("USER env var set in test environment"); + DrivePair { + id: "pair-1".into(), + name: "Test".into(), + enabled: true, + context: MountContext::User, + owner_user: Some(user.clone()), + mount_point: "/home/dragon/smart-mount/pair-1".into(), + local: crate::config::LocalSide { + kind: MountKind::Smb, + address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)), + share: "share".into(), + username: Some("nasuser".into()), + extra_options: vec![], + }, + cloud: crate::config::CloudSide { + kind: MountKind::Smb, + host_or_url: "cloud.example.com".into(), + share: "share".into(), + username: Some(user), + extra_options: vec![], + }, + } + } + + #[test] + fn validate_user_pairs_have_owner_rejects_missing_owner() { + let mut pair = sample_pair(); + pair.owner_user = None; + let err = validate_user_pairs_have_owner(&[&pair]).unwrap_err(); + assert!(err.to_string().contains("owner_user")); + } + + #[test] + fn validate_user_pairs_have_owner_accepts_pair_with_owner() { + let pair = sample_pair(); + assert!(validate_user_pairs_have_owner(&[&pair]).is_ok()); + } + + #[test] + fn renders_two_lines_per_pair_each_with_its_own_unique_target() { + let pair = sample_pair(); + let block = render_managed_block(&[&pair], &GlobalSettings::default()).expect("render"); + let lines: Vec<&str> = block.lines().collect(); + + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("user,exec,noauto")); + assert!(lines[0].contains("uid=")); + assert!(lines[0].contains("gid=")); + + // Der sichtbare pair.mount_point selbst darf in KEINER Zeile als Ziel auftauchen - + // das ist der Symlink, den smart-mount zur Laufzeit umschaltet, kein fstab-Ziel. + let visible = pair.mount_point.display().to_string(); + let target_tokens: Vec<&str> = [lines[0], lines[1]] + .iter() + .map(|l| l.split_whitespace().nth(1).unwrap()) + .collect(); + assert!(!target_tokens.contains(&visible.as_str())); + + // Jede Zeile hat ein eigenes, eindeutiges Ziel (Backing-Verzeichnis) - keine zwei + // Zeilen mit demselben Mountpoint, auf dessen Disambiguierung sich mount(8) laut + // `man 8 mount` nicht verlassen ließe. + let target_of = |line: &str| line.split_whitespace().nth(1).unwrap().to_string(); + assert_ne!(target_of(lines[0]), target_of(lines[1])); + assert_eq!( + target_of(lines[0]), + backing_dir(&pair, Side::Local).display().to_string() + ); + assert_eq!( + target_of(lines[1]), + backing_dir(&pair, Side::Cloud).display().to_string() + ); + } + + #[test] + fn strip_managed_block_removes_only_the_marked_section() { + let contents = "/dev/sda1 / ext4 defaults 0 1\n# BEGIN smart-mount managed block\nfoo\n# END smart-mount managed block\n"; + let stripped = crate::util::strip_managed_block(contents, BEGIN_MARKER, END_MARKER); + assert!(stripped.contains("/dev/sda1")); + assert!(!stripped.contains("foo")); + } + + #[test] + fn create_dir_all_owned_creates_multi_level_path_and_chowns_new_dirs() { + // `chown` zu einem ANDEREN Nutzer bräuchte Root - hier wird bewusst auf den eigenen + // Nutzer "umgechownt" (funktioniert unprivilegiert, ist ein No-op auf die tatsächliche + // Ownership, prüft aber, dass der `chown`-Aufruf pro neu angelegtem Verzeichnis + // fehlerfrei durchläuft und die Verzeichnisstruktur korrekt entsteht). + let user = std::env::var("USER").expect("USER env var set in test environment"); + let base = tempfile::tempdir().expect("tempdir"); + let target = base.path().join("a").join("b").join("c"); + + create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned"); + + assert!(target.is_dir()); + assert!(base.path().join("a").is_dir()); + } + + #[test] + fn create_dir_all_owned_does_not_touch_already_existing_ancestors() { + let user = std::env::var("USER").expect("USER env var set in test environment"); + let base = tempfile::tempdir().expect("tempdir"); + let target = base.path().join("existing").join("new-child"); + std::fs::create_dir_all(base.path().join("existing")).expect("pre-create ancestor"); + + // Darf nicht versuchen, `base.path()` selbst zu chownen (das existierte schon vorher) - + // nur `existing/new-child`. Schlägt fehl, falls die Funktion stattdessen versucht, + // einen nicht existierenden Nutzer für einen bereits vorhandenen Ordner zu setzen o. Ä. + create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned"); + assert!(target.is_dir()); + } + + #[test] + fn create_dir_all_owned_without_owner_just_creates_directories() { + let base = tempfile::tempdir().expect("tempdir"); + let target = base.path().join("x").join("y"); + create_dir_all_owned(&target, None).expect("create_dir_all_owned"); + assert!(target.is_dir()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..70a86c2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +//! smart-mount: bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und +//! schaltet automatisch zwischen LAN und Cloud um. + +pub mod config; +pub mod crypto; +pub mod db; +pub mod doctor; +pub mod error; +pub mod fstab; +pub mod mount; +pub mod network; +pub mod reconcile; +pub mod systemd; +pub(crate) mod util; + +pub use error::{Error, Result}; diff --git a/src/main.rs b/src/main.rs index cb7fbe4..f9238f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,335 +1,34 @@ -use crate::config::{Storage, get_config, modify_config}; -use crate::filesystem::credentials::save_credentials_webdav; -use crate::filesystem::mount::{mount, unmount}; -use crate::filesystem::mounted::{is_mounted, is_mounted_as}; -use crate::log::LogLevel; -use crate::log::log; -use crate::network::network_interface::{get_active_network_interface, get_interface_ip_address}; -use crate::network::utils::{ - get_ip_from_mac, get_mac_from_ip, get_network_address, is_reachable, wake_on_land, -}; -use crate::sudo::{is_run_as_root, run_as_root}; -use std::process::exit; -use std::thread; -use std::thread::{JoinHandle, sleep}; -use std::time::Duration; +mod cli; -mod config; -mod filesystem; -mod log; -mod network; -mod program; -mod sudo; +use std::process::ExitCode; -fn main() { - log( - "main", - "========== PROGRAM START ==========", - LogLevel::Info, - ); +use clap::Parser; - if !is_run_as_root() { - log( - "main", - "Program is not run as root. Trying to run as root...", - LogLevel::Warn, - ); - run_as_root(); - } +#[tokio::main] +async fn main() -> ExitCode { + smart_mount::config::init(); - let network_interface: String; + let log_level = match smart_mount::config::pairs::load() { + Ok(cfg) => cfg.settings.log_level, + Err(_) => "info".to_string(), + }; + logger_ctdra::set_log_level(parse_log_level(&log_level)); - let mut count: i32 = 0; - loop { - let interface_str: String = get_active_network_interface().unwrap().trim().to_string(); - - if !interface_str.is_empty() { - network_interface = interface_str; - break; - } else if count >= 10 { - log( - "main", - "Couldn't find active network card, exiting.", - LogLevel::Error, - ); - exit(1); - } - - log( - "main", - "No active network card found, waiting 1 second.", - LogLevel::Warn, - ); - count = count + 1; - sleep(Duration::from_secs(1)); - } - log( - "main", - &*format!("Active network interface found: {}", network_interface), - LogLevel::Info, - ); - - let interface_address = get_interface_ip_address(network_interface.as_str()) - .unwrap() - .trim() - .to_string(); - log( - "main", - &*format!("Interface address: {}", interface_address), - LogLevel::Info, - ); - - let network_address = get_network_address(interface_address.as_str()) - .unwrap() - .trim() - .to_string(); - log( - "main", - &*format!("Network address: {}", network_address), - LogLevel::Info, - ); - - count = 0; - loop { - if is_reachable(network_address.as_str()) { - break; - } else if count >= 10 { - log( - "main", - "Couldn't reach network address, exiting.", - LogLevel::Error, - ); - exit(1); - } - - log( - "main", - "Network address not reachable, waiting 1 second.", - LogLevel::Warn, - ); - count = count + 1; - sleep(Duration::from_secs(1)); - } - log("main", "Network address is reachable.", LogLevel::Info); - - let handle_local: JoinHandle<()> = thread::spawn(move || mount_local(network_address)); - let handle_remote: JoinHandle<()> = thread::spawn(mount_remote); - - handle_local.join().unwrap(); - handle_remote.join().unwrap(); - - log("main", "========== PROGRAM END ==========", LogLevel::Info); -} - -fn mount_local(network_address: String) { - log( - "main", - "Trying to mount filesystem locally...", - LogLevel::Info, - ); - - let mount_point: &str = get_config().general.mount_point.as_str(); - - let mac_address: &str = get_config().local.device_mac.as_str(); - let mount_type: &str = get_config().local.mount_type.as_str(); - - let mut device_address: Option = None; - - if get_config().storage.is_some() { - device_address = Some(get_config().storage.clone().unwrap().device_ip); - - if is_reachable(&device_address.clone().unwrap()) { - log( - "main", - format!( - "Searching mac for device address {}.", - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Info, - ); - let mac_of_ip = - get_mac_from_ip(device_address.clone().unwrap().as_str()).unwrap_or("".to_string()); - log( - "main", - format!( - "Found mac {} for device address {}.", - mac_of_ip, - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Info, - ); - - if mac_of_ip == mac_address { - log( - "main", - format!( - "Found device mac {} on saved ip {}.", - mac_address, - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Info, - ); - } else { - log( - "main", - format!( - "Device mac {} is not the same as {} of ip {}.", - mac_address, - mac_of_ip, - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Warn, - ); - device_address = None; - } - } else { - log( - "main", - format!( - "Device address {} is not reachable.", - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Warn, - ); - device_address = None; - } - } - - let mut count: i32 = 0; - - if device_address.is_none() { - loop { - device_address = get_ip_from_mac(mac_address, network_address.as_str()); - if device_address.is_some() || count >= 10 { - modify_config(|config| { - let storage = config.storage.get_or_insert(Storage::default()); - storage.device_ip = device_address.clone().unwrap(); - }); - - break; - } - - log( - "main", - "Couldn't find MAC adress in local network, sending awake call...", - LogLevel::Info, - ); - wake_on_land(mac_address); - - log("main", "Waiting 30 seconds...", LogLevel::Info); - count = count + 1; - sleep(Duration::from_secs(30)); - } - } - - if device_address.is_none() { - log( - "main", - "Couldn't find device address for MAC address.", - LogLevel::Warn, - ); - - if is_mounted_as(mount_point, mount_type) { - log( - "main", - "Filesystem is mounted locally. Unmounting...", - LogLevel::Info, - ); - unmount(mount_point); - } - - mount_remote(); - } else { - let dev_ip: String = device_address.unwrap(); - log( - "main", - "Found MAC address in local network.", - LogLevel::Info, - ); - - if !is_mounted_as(mount_point, mount_type) { - if is_mounted(mount_point) { - log( - "main", - "Filesystem is mounted. Unmounting...", - LogLevel::Info, - ); - unmount(mount_point); - } - - log("main", "Mounting local filesystem...", LogLevel::Info); - mount( - mount_point, - &*format!("{}:{}", dev_ip, get_config().local.mount_path), - mount_type, - ); - } else { - log( - "main", - "Filesystem is already mounted locally. Doing nothing.", - LogLevel::Info, - ); + let cli = cli::Cli::parse(); + match cli::dispatch(cli).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("Error: {e:#}"); + ExitCode::FAILURE } } } -fn mount_remote() { - let mount_point: &str = get_config().general.mount_point.as_str(); - let mount_type: &str = &get_config().remote.mount_type.as_str(); - - let mut could_reach: bool; - let mut count: i32 = 0; - - loop { - could_reach = is_reachable("https://ping.creative-dragonslayer.de"); - - if could_reach || count >= 10 { - break; - } - - log( - "main", - "Couldn't reach remote server, waiting 1 second.", - LogLevel::Warn, - ); - count = count + 1; - sleep(Duration::from_secs(1)); - } - - if could_reach { - log("main", "Remote server reachable.", LogLevel::Info); - - if mount_type == "davfs" || mount_type == "webdav" { - save_credentials_webdav(); - } - - if is_mounted_as(mount_point, get_config().local.mount_type.as_str()) - || is_mounted_as(mount_point, mount_type) - { - log( - "main", - "Filesystem is already mounted. Doing nothing.", - LogLevel::Info, - ); - } else { - if is_mounted(mount_point) { - log( - "main", - "Filesystem is already mounted. Unmounting...", - LogLevel::Info, - ); - unmount(mount_point); - } - - log("main", "Mounting remote filesystem...", LogLevel::Info); - mount(mount_point, &*get_config().remote.mount_path, mount_type); - } - } else { - log("main", "Remote server not reachable.", LogLevel::Warn); +fn parse_log_level(level: &str) -> logger_ctdra::LogLevel { + match level.to_lowercase().as_str() { + "error" => logger_ctdra::LogLevel::Error, + "warn" => logger_ctdra::LogLevel::Warn, + "debug" => logger_ctdra::LogLevel::Debug, + _ => logger_ctdra::LogLevel::Info, } } diff --git a/src/mount/lock.rs b/src/mount/lock.rs new file mode 100644 index 0000000..a78b549 --- /dev/null +++ b/src/mount/lock.rs @@ -0,0 +1,31 @@ +//! Pro-Paar-Mutex-Registry, damit ein manueller `mount --name X` nicht mit einem +//! gleichzeitig laufenden `watch` für dasselbe Paar kollidiert. +//! +//! Ersetzt den globalen `RwLock`+`Mutex` aus dem alten `src/filesystem/mount.rs` (v0.2.0): +//! dort war die Sperre prozessweit global, hier ist sie pro Laufwerkspaar - mehrere Paare +//! können also parallel gemountet werden, ohne sich gegenseitig zu blockieren. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; + +type Registry = Mutex>>>; + +fn registry() -> &'static Registry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Sperrt ein Laufwerkspaar für die Dauer des zurückgegebenen Guards. `tokio::sync::Mutex`s +/// `lock_owned()` erlaubt einen Guard, der seine eigene `Arc`-Referenz hält - keine +/// selbstreferenzielle Struktur/`unsafe` nötig. +pub async fn acquire(pair_id: &str) -> OwnedMutexGuard<()> { + let mutex = { + let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner()); + reg.entry(pair_id.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() + }; + mutex.lock_owned().await +} diff --git a/src/mount/mod.rs b/src/mount/mod.rs new file mode 100644 index 0000000..c8fcdfc --- /dev/null +++ b/src/mount/mod.rs @@ -0,0 +1,257 @@ +//! Mount-Backends für WebDAV/SMB/NFS mit dynamischem Dispatch. +//! +//! "Dynamisch nachladen" bedeutet hier: Trait-Object-Dispatch je nach [`MountKind`], und +//! jedes Backend prüft sein benötigtes System-Binary (`mount.davfs`/`mount.cifs`/`mount.nfs`) +//! erst, wenn es tatsächlich benutzt wird ([`MountBackend::check_available`]) - ein reiner +//! WebDAV-Nutzer wird also nicht gezwungen, `cifs-utils`/`nfs-common` zu installieren. + +pub mod lock; +pub mod nfs; +pub mod smb; +pub mod state; +pub mod target; +pub mod webdav; + +use std::path::PathBuf; + +use crate::config::{DrivePair, GlobalSettings, MountKind}; +use crate::db::credentials::{Credential, Side}; +use crate::error::Result; + +/// Wie ein Mount-Aufruf ausgeführt wird. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MountInvocation { + /// Root, volle `-o`-Optionen: `mount -t -o `. + Direct, + /// Unprivilegiert über eine passende `user,noauto`-fstab-Zeile: `mount ` (nur das + /// Ziel, genau der in `man 8 mount` dokumentierte Fall `mount /cd`). Da jede Seite ihr + /// eigenes, eindeutiges Backing-Verzeichnis hat (siehe [`target::backing_dir`]), gibt es + /// dabei nie mehr als eine passende fstab-Zeile. Voraussetzung: `smart-mount setup fstab` + /// wurde für dieses Paar bereits ausgeführt. + ViaFstab, +} + +/// Alle Informationen, die ein Backend braucht, um eine Seite eines Laufwerkspaars ein- +/// bzw. auszuhängen. +pub struct MountTarget { + pub pair_id: String, + /// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile + /// Dateinamen für Credentials-/Secrets-Dateien. + pub side: Side, + pub mount_point: PathBuf, + /// Vollständiger Quellstring, z. B. `//server/share`, `https://host/path`, `server:/export`. + pub source: String, + /// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`), nur bei + /// `MountInvocation::Direct` verwendet - mit Kommas verbindbar für `mount -o`. + pub options: Vec, + pub invocation: MountInvocation, + /// Für `MountContext::User`-Paare: der Linux-Benutzername, dem Zugangsdaten-/Secrets- + /// Dateien gehören sollen. + pub owner_user: Option, +} + +/// Gemeinsame Schnittstelle für WebDAV/SMB/NFS-Backends. +pub trait MountBackend: Send + Sync { + fn name(&self) -> &'static str; + + /// Prüft, ob das für dieses Backend nötige System-Binary vorhanden ist. Liefert bei + /// Fehlen einen Fehler mit dem Namen des nachzuinstallierenden Pakets. + fn check_available(&self) -> Result<()>; + + /// Schreibt/aktualisiert alles, was der eigentliche `mount`-Aufruf voraussetzt + /// (Credentials-/Secrets-Dateien, Config-Anpassungen wie davfs2s `gui_optimize`). + fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()>; + + fn mount(&self, target: &MountTarget) -> Result<()>; + fn unmount(&self, target: &MountTarget) -> Result<()>; +} + +/// Wählt das passende Backend für einen [`MountKind`] (Trait-Object-Dispatch). +pub fn backend_for(kind: MountKind) -> Box { + match kind { + MountKind::WebDav => Box::new(webdav::WebDavBackend), + MountKind::Smb => Box::new(smb::SmbBackend), + MountKind::Nfs => Box::new(nfs::NfsBackend), + } +} + +/// Timeout in Sekunden für `mount`/`umount`-Subprozesse (siehe [`run_tolerating_already_done`]). +/// Ein nicht mehr erreichbarer Server darf `smart-mount watch` niemals unbegrenzt blockieren - +/// z. B. ist ein `umount` auf einem davfs2-Mount laut davfs2-eigener FAQ absichtlich so lange +/// blockierend, bis alle zwischengespeicherten Daten geschrieben sind, was bei einem dauerhaft +/// unerreichbaren Server sonst nie zurückkehren würde. +pub(crate) const MOUNT_TIMEOUT_SECS: u64 = 30; + +/// Führt einen `mount`/`umount`-Subprozess mit einem externen Timeout aus (über das +/// coreutils-Tool `timeout`, auf jedem Linux-System vorhanden) und toleriert "bereits +/// eingebunden"/"busy"/"nicht eingebunden" als No-op statt als Fehler (portiert aus dem alten +/// `src/filesystem/mount.rs`, v0.2.0). +/// +/// `tolerate_timeout`: bei `umount`-Aufrufen (`true`) wird ein durch den Timeout abgebrochener +/// Versuch nur geloggt und als Erfolg gewertet - der nächste `watch`-Durchlauf versucht es +/// erneut (idempotent, `umount` toleriert bereits "nicht eingebunden"). Bei `mount`-Aufrufen +/// (`false`) ist ein Timeout ein echter Fehlschlag, da dabei nichts erfolgreich eingebunden +/// wurde. +pub(crate) fn run_tolerating_already_done( + cmd: std::process::Command, + context: &str, + tolerate_timeout: bool, +) -> Result<()> { + run_tolerating_already_done_with_timeout(cmd, context, tolerate_timeout, MOUNT_TIMEOUT_SECS) +} + +fn run_tolerating_already_done_with_timeout( + cmd: std::process::Command, + context: &str, + tolerate_timeout: bool, + timeout_secs: u64, +) -> Result<()> { + let output = + run_with_timeout(cmd, timeout_secs).map_err(|e| crate::error::Error::MountFailed { + context: context.to_string(), + stderr: e.to_string(), + })?; + + if output.status.success() { + return Ok(()); + } + + // GNU coreutils `timeout` beendet sich mit Exit-Code 124, wenn es den Kindprozess wegen + // Zeitüberschreitung abbrechen musste (dokumentiertes Verhalten von `timeout(1)`). + if tolerate_timeout && output.status.code() == Some(124) { + logger_ctdra::warn( + "mount", + &format!( + "{context}: aborted after {timeout_secs}s (server likely unreachable) - will be retried on the next run" + ), + ); + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase(); + if stderr.contains("already mounted") + || stderr.contains("busy") + || stderr.contains("not mounted") + { + return Ok(()); + } + + Err(crate::error::Error::MountFailed { + context: context.to_string(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +/// Führt `cmd` über das coreutils-Tool `timeout` aus, damit ein hängender `mount`/`umount` +/// den aufrufenden `smart-mount`-Prozess nie unbegrenzt blockiert. +fn run_with_timeout( + cmd: std::process::Command, + timeout_secs: u64, +) -> std::io::Result { + let program = cmd.get_program().to_os_string(); + let args: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect(); + std::process::Command::new("timeout") + .arg(timeout_secs.to_string()) + .arg(program) + .args(args) + .output() +} + +/// Prüft per `which`, ob ein Binary im `PATH` auffindbar ist. +pub(crate) fn binary_available(binary: &str) -> bool { + std::process::Command::new("which") + .arg(binary) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Entfernt (best-effort) die für `pair` auf beiden Seiten hinterlegten Klartext- +/// Zugangsdaten, die `mount -t davfs`/`mount -t cifs` benötigen (NICHT die verschlüsselten +/// DB-Zeilen - die werden separat über `CredentialStore::delete` entfernt). Für +/// `smart-mount drive remove`, damit kein Passwort für ein gelöschtes Paar auf der Platte +/// zurückbleibt. Fehler werden nur geloggt, nie propagiert: eine nicht perfekt aufräumbare +/// Restdatei darf das Entfernen des Paars nicht blockieren. +pub fn cleanup_credentials(pair: &DrivePair, settings: &GlobalSettings) { + cleanup_side_credentials(pair, settings, Side::Local, pair.local.kind); + cleanup_side_credentials(pair, settings, Side::Cloud, pair.cloud.kind); +} + +fn cleanup_side_credentials( + pair: &DrivePair, + settings: &GlobalSettings, + side: Side, + kind: MountKind, +) { + match kind { + MountKind::Smb => { + let path = smb::credentials_path(&pair.id, side); + if path.exists() + && let Err(e) = std::fs::remove_file(&path) + { + logger_ctdra::warn( + "mount", + &format!( + "Could not delete credentials file '{}': {e}", + path.display() + ), + ); + } + } + MountKind::WebDav => { + let source = match side { + Side::Local => target::local_source(&pair.local, settings), + Side::Cloud => target::cloud_source(&pair.cloud), + }; + let path = webdav::davfs2_secrets_path(); + if let Err(e) = webdav::remove_secrets_entry(&path, &source) { + logger_ctdra::warn( + "mount", + &format!("Could not remove secrets entry for '{source}': {e}"), + ); + } + } + MountKind::Nfs => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + #[test] + fn run_with_timeout_kills_a_hanging_command_and_reports_exit_124() { + let mut cmd = Command::new("sleep"); + cmd.arg("5"); + let output = run_with_timeout(cmd, 1).expect("timeout wrapper itself should run"); + // GNU coreutils `timeout` exits 124 when it had to kill the child - this is the + // signal `run_tolerating_already_done_with_timeout` checks for below. + assert_eq!(output.status.code(), Some(124)); + } + + #[test] + fn run_with_timeout_returns_promptly_for_a_command_that_finishes_in_time() { + let cmd = Command::new("true"); + let started = std::time::Instant::now(); + let output = run_with_timeout(cmd, 10).expect("run"); + assert!(output.status.success()); + assert!(started.elapsed() < std::time::Duration::from_secs(5)); + } + + #[test] + fn timeout_is_tolerated_for_unmount_and_returns_ok() { + let mut cmd = Command::new("sleep"); + cmd.arg("5"); + let result = run_tolerating_already_done_with_timeout(cmd, "test umount", true, 1); + assert!(result.is_ok()); + } + + #[test] + fn timeout_is_a_hard_error_for_mount() { + let mut cmd = Command::new("sleep"); + cmd.arg("5"); + let result = run_tolerating_already_done_with_timeout(cmd, "test mount", false, 1); + assert!(result.is_err()); + } +} diff --git a/src/mount/nfs.rs b/src/mount/nfs.rs new file mode 100644 index 0000000..1b07822 --- /dev/null +++ b/src/mount/nfs.rs @@ -0,0 +1,59 @@ +//! NFS-Backend (`mount.nfs`). In der Regel keine Zugangsdaten nötig - Autorisierung erfolgt +//! serverseitig über Export-ACLs (`sec=sys`), optional `sec=krb5*` über `extra_options`. + +use std::process::Command; + +use crate::db::credentials::Credential; +use crate::error::{Error, Result}; +use crate::mount::{ + MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done, +}; + +pub struct NfsBackend; + +impl MountBackend for NfsBackend { + fn name(&self) -> &'static str { + "nfs" + } + + fn check_available(&self) -> Result<()> { + if binary_available("mount.nfs") || binary_available("mount.nfs4") { + Ok(()) + } else { + Err(Error::BackendUnavailable { + backend: "nfs", + reason: "'mount.nfs'/'mount.nfs4' not found - install package 'nfs-common' (Debian/Ubuntu) or 'nfs-utils' (Fedora/Arch)".to_string(), + }) + } + } + + fn prepare(&self, _target: &MountTarget, _cred: Option<&Credential>) -> Result<()> { + // Normalerweise kein Vorbereitungsschritt nötig (kein Credentials-File). + Ok(()) + } + + fn mount(&self, target: &MountTarget) -> Result<()> { + let mut cmd = Command::new("mount"); + match target.invocation { + MountInvocation::Direct => { + cmd.arg("-t") + .arg("nfs") + .arg(&target.source) + .arg(&target.mount_point); + if !target.options.is_empty() { + cmd.arg("-o").arg(target.options.join(",")); + } + } + MountInvocation::ViaFstab => { + cmd.arg(&target.mount_point); + } + } + run_tolerating_already_done(cmd, "nfs mount", false) + } + + fn unmount(&self, target: &MountTarget) -> Result<()> { + let mut cmd = Command::new("umount"); + cmd.arg(&target.mount_point); + run_tolerating_already_done(cmd, "nfs umount", true) + } +} diff --git a/src/mount/smb.rs b/src/mount/smb.rs new file mode 100644 index 0000000..2553039 --- /dev/null +++ b/src/mount/smb.rs @@ -0,0 +1,147 @@ +//! SMB/CIFS-Backend (`mount.cifs`). + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; + +use crate::db::credentials::Credential; +use crate::error::{Error, Result}; +use crate::mount::{ + MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done, +}; + +pub struct SmbBackend; + +impl MountBackend for SmbBackend { + fn name(&self) -> &'static str { + "cifs" + } + + fn check_available(&self) -> Result<()> { + if binary_available("mount.cifs") { + Ok(()) + } else { + Err(Error::BackendUnavailable { + backend: "cifs", + reason: "'mount.cifs' not found - install package 'cifs-utils'".to_string(), + }) + } + } + + fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> { + if let Some(cred) = cred { + write_credentials_file(&credentials_path(&target.pair_id, target.side), cred)?; + } + Ok(()) + } + + fn mount(&self, target: &MountTarget) -> Result<()> { + let mut cmd = Command::new("mount"); + match target.invocation { + MountInvocation::Direct => { + cmd.arg("-t") + .arg("cifs") + .arg(&target.source) + .arg(&target.mount_point); + let mut opts = target.options.clone(); + opts.push(format!( + "credentials={}", + credentials_path(&target.pair_id, target.side).display() + )); + cmd.arg("-o").arg(opts.join(",")); + } + MountInvocation::ViaFstab => { + cmd.arg(&target.mount_point); + } + } + run_tolerating_already_done(cmd, "cifs mount", false) + } + + fn unmount(&self, target: &MountTarget) -> Result<()> { + let mut cmd = Command::new("umount"); + cmd.arg(&target.mount_point); + run_tolerating_already_done(cmd, "cifs umount", true) + } +} + +/// Stabiler Pfad (nicht ein Tempfile!), da bei `MountInvocation::ViaFstab` die fstab-Zeile +/// (von `setup fstab` einmalig geschrieben) exakt auf diesen `credentials=`-Pfad verweist. +pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf { + let base = config_ctdra::get_config_path() + .parent() + .map(|d| d.join("creds")) + .unwrap_or_else(|| PathBuf::from("creds")); + base.join(format!("{pair_id}-{}.cred", side.as_str())) +} + +fn write_credentials_file(path: &PathBuf, cred: &Credential) -> Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; + } + + let mut contents = String::new(); + if let Some(username) = &cred.username { + contents.push_str(&format!("username={username}\n")); + } + contents.push_str(&format!("password={}\n", cred.password)); + if let Some(domain) = &cred.domain { + contents.push_str(&format!("domain={domain}\n")); + } + + #[cfg(unix)] + let mut opts = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + #[cfg(not(unix))] + let mut opts = OpenOptions::new(); + + let mut file = opts + .write(true) + .create(true) + .truncate(true) + .open(path) + .map_err(|e| Error::io(path, e))?; + file.write_all(contents.as_bytes()) + .map_err(|e| Error::io(path, e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::credentials::Side; + + #[test] + fn writes_expected_credentials_format() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.cred"); + let cred = Credential { + username: Some("nasuser".to_string()), + domain: Some("WORKGROUP".to_string()), + password: "s3cret".to_string(), + }; + + write_credentials_file(&path, &cred).expect("write"); + let contents = std::fs::read_to_string(&path).expect("read"); + + assert!(contents.contains("username=nasuser")); + assert!(contents.contains("password=s3cret")); + assert!(contents.contains("domain=WORKGROUP")); + } + + #[test] + fn credentials_path_differs_per_side() { + let local = credentials_path("pair-1", Side::Local); + let cloud = credentials_path("pair-1", Side::Cloud); + assert_ne!(local, cloud); + } +} diff --git a/src/mount/state.rs b/src/mount/state.rs new file mode 100644 index 0000000..c95ffea --- /dev/null +++ b/src/mount/state.rs @@ -0,0 +1,76 @@ +//! Ermittelt den aktuellen Mount-Zustand eines Mountpoints durch Parsen von +//! `/proc/self/mountinfo` - keine Zusatzabhängigkeit auf `findmnt`. + +use std::path::Path; + +use crate::error::{Error, Result}; + +/// Ob `mount_point` aktuell eingebunden ist. +pub fn is_mounted(mount_point: &Path) -> Result { + Ok(current_source(mount_point)?.is_some()) +} + +/// Die aktuell an `mount_point` eingebundene Quelle (die Spalte "source" aus mountinfo), +/// oder `None`, falls dort nichts eingebunden ist. +pub fn current_source(mount_point: &Path) -> Result> { + let contents = std::fs::read_to_string("/proc/self/mountinfo") + .map_err(|e| Error::io("/proc/self/mountinfo", e))?; + Ok(parse_mountinfo_source(&contents, mount_point)) +} + +/// Reine, testbare Parse-Funktion: `mountinfo`-Zeilenformat ist +/// `... - `. +/// Bei mehreren Treffern (verschachtelte Mounts) zählt der letzte (= zuletzt gemountete, +/// aktuell sichtbare) Eintrag. +fn parse_mountinfo_source(mountinfo: &str, mount_point: &Path) -> Option { + let target = mount_point.to_string_lossy(); + let mut result = None; + + for line in mountinfo.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + // Feld 4 (Index 4) ist der Mountpoint; danach folgen optionale Felder bis zum + // Trenner "-", danach fs_type (Index+1) und source (Index+2). + if fields.len() < 5 || fields[4] != target { + continue; + } + let Some(dash_pos) = fields.iter().position(|&f| f == "-") else { + continue; + }; + if let Some(source) = fields.get(dash_pos + 2) { + result = Some(source.to_string()); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn finds_source_for_matching_mount_point() { + let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw\n\ + 43 36 0:26 / /home/dragon/smart-mount/pair-1 rw,relatime shared:2 - cifs //server/share rw,uid=1000"; + let source = + parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1")); + assert_eq!(source.as_deref(), Some("//server/share")); + } + + #[test] + fn returns_none_for_unmounted_path() { + let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw"; + let source = + parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1")); + assert_eq!(source, None); + } + + #[test] + fn last_matching_entry_wins_for_stacked_mounts() { + let mountinfo = "36 35 98:0 / /mnt/x rw - nfs server:/export rw\n\ + 37 36 0:26 / /mnt/x rw - davfs https://cloud/dav rw"; + let source = parse_mountinfo_source(mountinfo, &PathBuf::from("/mnt/x")); + assert_eq!(source.as_deref(), Some("https://cloud/dav")); + } +} diff --git a/src/mount/target.rs b/src/mount/target.rs new file mode 100644 index 0000000..45657cc --- /dev/null +++ b/src/mount/target.rs @@ -0,0 +1,444 @@ +//! Baut [`MountTarget`]s für eine Seite eines Paars und verwaltet den symlink-basierten +//! "welche Seite ist aktiv"-Zustand. +//! +//! **Warum ein Symlink statt zwei fstab-Zeilen auf denselben Mountpoint:** `mount(8)`s +//! Berechtigungsprüfung für unprivilegierte `user`-Mounts ist nur für den Fall EINER +//! passenden fstab-Zeile dokumentiert (`man 8 mount`, Abschnitt "Non-superuser mounts", +//! Beispiel `mount /cd`). Der Fall zweier `user,noauto`-Zeilen mit demselben Ziel, aber +//! unterschiedlicher Quelle, ist nirgends spezifiziert - und `mount --fstab ` +//! verlangt selbst Root, sodass sich das Verhalten nicht einmal gefahrlos in einer Sandbox +//! verifizieren ließ. Um uns nicht auf unspezifiziertes Verhalten zu verlassen, bekommt jede +//! Seite stattdessen ein eigenes, eindeutiges Backing-Verzeichnis mit genau einer fstab-Zeile +//! (der dokumentierte, eindeutige Fall). Der konfigurierte `pair.mount_point` ist kein +//! Mountpoint mehr, sondern ein Symlink, den smart-mount selbst atomar zwischen beiden +//! Backing-Verzeichnissen umschaltet ([`activate_symlink`]). Zu jedem Zeitpunkt ist höchstens +//! kurz während eines Umschaltens (siehe [`crate::reconcile`]) mehr als ein Backing-Verzeichnis +//! gemountet - im Ruhezustand immer nur eines, wie ursprünglich vorgesehen. + +use std::path::{Path, PathBuf}; + +use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountContext, MountKind}; +use crate::db::credentials::Side; +use crate::error::{Error, Result}; +use crate::mount::{self, MountInvocation, MountTarget}; +use crate::network::address; + +/// Eindeutiges Backing-Verzeichnis für eine Seite eines Paars - hier (und nur hier) wird +/// tatsächlich `mount(8)` aufgerufen. Liegt als verstecktes Verzeichnis neben `pair.mount_point`. +pub fn backing_dir(pair: &DrivePair, side: Side) -> PathBuf { + let parent = pair.mount_point.parent().unwrap_or_else(|| Path::new(".")); + parent.join(format!(".{}-{}", pair.id, side.as_str())) +} + +/// Welches [`MountKind`] eine Seite eines Paars hat. +pub fn side_kind(pair: &DrivePair, side: Side) -> MountKind { + match side { + Side::Local => pair.local.kind, + Side::Cloud => pair.cloud.kind, + } +} + +/// Baut ein [`MountTarget`] für eine Seite eines Paars, inkl. Auflösung der lokalen +/// MAC-Adresse zu einer IP (siehe [`address::resolve_ip`]). `target.mount_point` ist das +/// Backing-Verzeichnis (siehe [`backing_dir`]), nicht der sichtbare `pair.mount_point`. +pub fn build_target( + pair: &DrivePair, + settings: &GlobalSettings, + side: Side, +) -> Result { + let invocation = match pair.context { + MountContext::System => MountInvocation::Direct, + MountContext::User => MountInvocation::ViaFstab, + }; + let kind = side_kind(pair, side); + + let (source, mut options) = match side { + Side::Local => ( + local_source(&pair.local, settings), + parse_options(&pair.local.extra_options), + ), + Side::Cloud => ( + cloud_source(&pair.cloud), + parse_options(&pair.cloud.extra_options), + ), + }; + apply_owner_permissions(kind, pair.owner_user.as_deref(), &mut options)?; + apply_nfs_resilience_defaults(kind, &mut options); + + Ok(MountTarget { + pair_id: pair.id.clone(), + side, + mount_point: backing_dir(pair, side), + source, + options, + invocation, + owner_user: pair.owner_user.clone(), + }) +} + +/// Setzt `uid`/`gid`/`file_mode`/`dir_mode` für Protokolle ohne native Unix-Rechte (CIFS, +/// WebDAV), damit `pair.owner_user` vollen Zugriff auf den Mount hat - inklusive +/// Ausführrechten, damit dort liegende Skripte laufen können (`file_mode`/`dir_mode` steuern +/// bei diesen Protokollen den simulierten `stat()`-Modus jeder Datei/jedes Verzeichnisses +/// einheitlich; `0700` gibt ausschließlich dem Owner volle Rechte). Bereits in +/// `extra_options` explizit gesetzte Werte werden respektiert und nicht überschrieben. +/// +/// **NFS ist bewusst ausgenommen:** dort gibt es keine clientseitige `uid=`/`gid=`-Option - +/// welcher lokale Nutzer Zugriff hat, bestimmt der NFS-Server über die tatsächlichen +/// Datei-Eigentümer/-Rechte des Exports (siehe README, Abschnitt "Voraussetzungen"). +fn apply_owner_permissions( + kind: MountKind, + owner_user: Option<&str>, + options: &mut Vec, +) -> Result<()> { + if !matches!(kind, MountKind::Smb | MountKind::WebDav) { + return Ok(()); + } + let Some(owner) = owner_user else { + return Ok(()); + }; + + if !has_option(options, "uid") || !has_option(options, "gid") { + let (uid, gid) = resolve_uid_gid(owner)?; + if !has_option(options, "uid") { + options.push(format!("uid={uid}")); + } + if !has_option(options, "gid") { + options.push(format!("gid={gid}")); + } + } + if !has_option(options, "file_mode") { + options.push("file_mode=0700".to_string()); + } + if !has_option(options, "dir_mode") { + options.push("dir_mode=0700".to_string()); + } + Ok(()) +} + +/// Setzt `soft` als NFS-Standard, sofern der Nutzer nicht bereits selbst `hard`/`soft`/ +/// `softerr` gesetzt hat. `hard` (der Standard von `mount.nfs`, wenn nichts angegeben ist) +/// lässt NFS-Anfragen unbegrenzt oft erneut versuchen, wenn der Server nicht antwortet - +/// genau das Einfrierverhalten (auch bei `umount`), das automatisches Umschalten unmöglich +/// machen würde. Siehe `man 5 nfs`, Abschnitt "soft / softerr / hard": dort wird ein +/// dauerhaft nicht erreichbarer Server als der Anwendungsfall genannt, für den `soft` +/// gedacht ist. +fn apply_nfs_resilience_defaults(kind: MountKind, options: &mut Vec) { + if kind != MountKind::Nfs { + return; + } + if !has_option(options, "hard") + && !has_option(options, "soft") + && !has_option(options, "softerr") + { + options.push("soft".to_string()); + } +} + +/// Prüft, ob `options` bereits einen Eintrag für `key` enthält - entweder als `key=wert` +/// oder als bloßes Flag `key` (z. B. `soft`, `exec`). +fn has_option(options: &[String], key: &str) -> bool { + options + .iter() + .any(|o| o == key || o.starts_with(&format!("{key}="))) +} + +fn resolve_uid_gid(username: &str) -> Result<(u32, u32)> { + Ok((run_id(username, "-u")?, run_id(username, "-g")?)) +} + +fn run_id(username: &str, flag: &str) -> Result { + let output = std::process::Command::new("id") + .arg(flag) + .arg(username) + .output() + .map_err(|e| Error::Other(format!("could not run 'id': {e}")))?; + if !output.status.success() { + return Err(Error::Other(format!( + "user '{username}' not found ('id {flag} {username}' failed): {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .map_err(|e| { + Error::Other(format!( + "unexpected output from 'id {flag} {username}': {e}" + )) + }) +} + +pub fn local_source(local: &LocalSide, settings: &GlobalSettings) -> String { + let ip = address::resolve_ip(&local.address, settings) + .map(|ip| ip.to_string()) + .unwrap_or_else(|_| "unresolved".to_string()); + format_source(local.kind, &ip, &local.share) +} + +pub fn cloud_source(cloud: &CloudSide) -> String { + match cloud.kind { + MountKind::WebDav => cloud.host_or_url.clone(), + MountKind::Smb | MountKind::Nfs => { + format_source(cloud.kind, &cloud.host_or_url, &cloud.share) + } + } +} + +fn format_source(kind: MountKind, host: &str, share: &str) -> String { + let share = if share.starts_with('/') { + share.to_string() + } else { + format!("/{share}") + }; + match kind { + MountKind::WebDav => format!("http://{host}{share}"), + MountKind::Smb => format!("//{host}{share}"), + MountKind::Nfs => format!("{host}:{share}"), + } +} + +/// `extra_options` sind bereits einzelne Tokens (kein komma-getrennter String) - einfach +/// übernehmen. Frühere Versionen filterten hier auf `key=value`-Paare, wodurch bloße Flags +/// wie `soft`/`exec`/`ro` in `extra_options` still verworfen wurden - siehe Testfall unten. +fn parse_options(extra: &[String]) -> Vec { + extra.to_vec() +} + +/// Welche Seite aktuell aktiv ist: `pair.mount_point` muss auf das Backing-Verzeichnis dieser +/// Seite zeigen UND dieses Verzeichnis muss tatsächlich gemountet sein (Schutz gegen einen +/// veralteten Symlink, dessen Backing-Verzeichnis extern ausgehängt wurde). +pub fn active_side(pair: &DrivePair) -> Option { + let link_target = std::fs::read_link(&pair.mount_point).ok()?; + let side = if link_target == backing_dir(pair, Side::Local) { + Side::Local + } else if link_target == backing_dir(pair, Side::Cloud) { + Side::Cloud + } else { + return None; + }; + + match mount::state::is_mounted(&backing_dir(pair, side)) { + Ok(true) => Some(side), + _ => None, + } +} + +/// Setzt `pair.mount_point` atomar als Symlink auf das Backing-Verzeichnis von `side`. +/// +/// Atomar über `symlink` auf einen Temp-Pfad + `rename()` (POSIX-garantiert atomar auf +/// demselben Dateisystem) - es gibt also kein Zeitfenster, in dem der Pfad fehlt oder auf ein +/// veraltetes Ziel zeigt. Schlägt kontrolliert fehl (statt zu überschreiben), falls an +/// `pair.mount_point` bereits ein echtes Verzeichnis (kein Symlink) existiert. +pub fn activate_symlink(pair: &DrivePair, side: Side) -> Result<()> { + let link_path = &pair.mount_point; + + if let Ok(meta) = std::fs::symlink_metadata(link_path) + && !meta.file_type().is_symlink() + { + return Err(Error::Other(format!( + "'{}' already exists as a real directory (not as a symlink managed by smart-mount) - \ + please remove/rename it manually before activating this pair.", + link_path.display() + ))); + } + + if let Some(parent) = link_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?; + } + + let target = backing_dir(pair, side); + let tmp_name = format!( + ".{}.smart-mount-tmp", + link_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + ); + let tmp_path = link_path.with_file_name(tmp_name); + + let _ = std::fs::remove_file(&tmp_path); + std::os::unix::fs::symlink(&target, &tmp_path).map_err(|e| Error::io(&tmp_path, e))?; + std::fs::rename(&tmp_path, link_path).map_err(|e| Error::io(link_path, e))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + fn sample_pair() -> DrivePair { + DrivePair { + id: "pair-1".into(), + name: "Test".into(), + enabled: true, + context: MountContext::System, + owner_user: None, + mount_point: "/media/smart-mount/pair-1".into(), + local: LocalSide { + kind: MountKind::Smb, + address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)), + share: "share".into(), + username: None, + extra_options: vec!["vers=3.0".into()], + }, + cloud: CloudSide { + kind: MountKind::WebDav, + host_or_url: "https://cloud.example.com/dav".into(), + share: "share".into(), + username: None, + extra_options: vec![], + }, + } + } + + #[test] + fn local_source_formats_smb_unc_path() { + let pair = sample_pair(); + assert_eq!( + local_source(&pair.local, &GlobalSettings::default()), + "//192.168.1.10/share" + ); + } + + #[test] + fn cloud_source_uses_full_url_for_webdav() { + let pair = sample_pair(); + assert_eq!(cloud_source(&pair.cloud), "https://cloud.example.com/dav"); + } + + #[test] + fn parse_options_passes_flags_and_key_value_pairs_through_unchanged() { + let opts = parse_options(&["vers=3.0".to_string(), "soft".to_string()]); + assert_eq!(opts, vec!["vers=3.0".to_string(), "soft".to_string()]); + } + + #[test] + fn apply_owner_permissions_injects_uid_gid_and_owner_only_modes_for_cifs() { + let user = std::env::var("USER").expect("USER env var set in test environment"); + let mut options = vec!["vers=3.0".to_string()]; + apply_owner_permissions(MountKind::Smb, Some(&user), &mut options).expect("apply"); + + assert!(options.contains(&"file_mode=0700".to_string())); + assert!(options.contains(&"dir_mode=0700".to_string())); + assert!(options.iter().any(|o| o.starts_with("uid="))); + assert!(options.iter().any(|o| o.starts_with("gid="))); + // vorhandene Option bleibt unangetastet + assert!(options.contains(&"vers=3.0".to_string())); + } + + #[test] + fn apply_owner_permissions_respects_explicit_overrides() { + let user = std::env::var("USER").expect("USER env var set in test environment"); + let mut options = vec!["file_mode=0755".to_string()]; + apply_owner_permissions(MountKind::WebDav, Some(&user), &mut options).expect("apply"); + + assert!(options.contains(&"file_mode=0755".to_string())); + assert!(!options.contains(&"file_mode=0700".to_string())); + assert!(options.contains(&"dir_mode=0700".to_string())); + } + + #[test] + fn apply_owner_permissions_is_noop_for_nfs() { + let mut options = vec![]; + apply_owner_permissions(MountKind::Nfs, Some("root"), &mut options).expect("apply"); + assert!(options.is_empty()); + } + + #[test] + fn apply_owner_permissions_is_noop_without_owner_user() { + let mut options = vec![]; + apply_owner_permissions(MountKind::Smb, None, &mut options).expect("apply"); + assert!(options.is_empty()); + } + + #[test] + fn apply_owner_permissions_fails_clearly_for_unknown_user() { + let mut options = vec![]; + let err = apply_owner_permissions(MountKind::Smb, Some("no-such-user-xyz"), &mut options) + .unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[test] + fn apply_nfs_resilience_defaults_adds_soft_when_unset() { + let mut options = vec![]; + apply_nfs_resilience_defaults(MountKind::Nfs, &mut options); + assert_eq!(options, vec!["soft".to_string()]); + } + + #[test] + fn apply_nfs_resilience_defaults_respects_explicit_hard() { + let mut options = vec!["hard".to_string()]; + apply_nfs_resilience_defaults(MountKind::Nfs, &mut options); + assert_eq!(options, vec!["hard".to_string()]); + } + + #[test] + fn apply_nfs_resilience_defaults_respects_explicit_softerr() { + let mut options = vec!["softerr".to_string()]; + apply_nfs_resilience_defaults(MountKind::Nfs, &mut options); + assert_eq!(options, vec!["softerr".to_string()]); + } + + #[test] + fn apply_nfs_resilience_defaults_is_noop_for_other_kinds() { + let mut options = vec![]; + apply_nfs_resilience_defaults(MountKind::Smb, &mut options); + assert!(options.is_empty()); + } + + #[test] + fn build_target_injects_soft_for_plain_nfs_pair() { + let mut pair = sample_pair(); + pair.local.kind = MountKind::Nfs; + pair.cloud.kind = MountKind::Nfs; + let t = build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build"); + assert!(t.options.contains(&"soft".to_string())); + } + + #[test] + fn backing_dirs_are_unique_per_side_and_live_next_to_mount_point() { + let pair = sample_pair(); + let local = backing_dir(&pair, Side::Local); + let cloud = backing_dir(&pair, Side::Cloud); + assert_ne!(local, cloud); + assert_eq!(local.parent(), pair.mount_point.parent()); + assert_eq!(cloud.parent(), pair.mount_point.parent()); + } + + #[test] + fn activate_symlink_points_at_the_right_backing_dir_and_is_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pair = sample_pair(); + pair.mount_point = dir.path().join("pair-1"); + + activate_symlink(&pair, Side::Local).expect("activate local"); + assert_eq!( + std::fs::read_link(&pair.mount_point).unwrap(), + backing_dir(&pair, Side::Local) + ); + + // Erneutes Aktivieren derselben Seite darf nicht fehlschlagen (Symlink wird ersetzt, + // kein "existiert bereits als echtes Verzeichnis"-Fehler für einen eigenen Symlink). + activate_symlink(&pair, Side::Local).expect("re-activate local"); + + activate_symlink(&pair, Side::Cloud).expect("activate cloud"); + assert_eq!( + std::fs::read_link(&pair.mount_point).unwrap(), + backing_dir(&pair, Side::Cloud) + ); + } + + #[test] + fn activate_symlink_refuses_to_clobber_a_real_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pair = sample_pair(); + pair.mount_point = dir.path().join("pair-1"); + std::fs::create_dir_all(&pair.mount_point).expect("create real dir"); + + let err = activate_symlink(&pair, Side::Local).unwrap_err(); + assert!(err.to_string().contains("real directory")); + } +} diff --git a/src/mount/webdav.rs b/src/mount/webdav.rs new file mode 100644 index 0000000..e9c47fc --- /dev/null +++ b/src/mount/webdav.rs @@ -0,0 +1,356 @@ +//! WebDAV-Backend (davfs2). + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::db::credentials::Credential; +use crate::error::{Error, Result}; +use crate::mount::{ + MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done, +}; + +pub struct WebDavBackend; + +const GUI_OPTIMIZE_COMMENT: &str = + "# smart-mount: gui_optimize enabled (batches PROPFIND requests for a directory)"; + +/// Deutlich über dem Standardwert (16), siehe [`ensure_buf_size`] für die Begründung. +const BUF_SIZE_KIB: &str = "16384"; +const BUF_SIZE_COMMENT: &str = "# smart-mount: buf_size increased so directory contents are listed reliably even with many files"; + +impl MountBackend for WebDavBackend { + fn name(&self) -> &'static str { + "davfs2" + } + + fn check_available(&self) -> Result<()> { + if binary_available("mount.davfs") { + Ok(()) + } else { + Err(Error::BackendUnavailable { + backend: "davfs2", + reason: "'mount.davfs' not found - install package 'davfs2'".to_string(), + }) + } + } + + fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> { + let conf_path = davfs2_conf_path(); + ensure_gui_optimize(&conf_path)?; + ensure_buf_size(&conf_path)?; + if let Some(cred) = cred + && let Some(username) = &cred.username + { + write_secrets_entry( + &davfs2_secrets_path(), + &target.source, + username, + &cred.password, + )?; + } + Ok(()) + } + + fn mount(&self, target: &MountTarget) -> Result<()> { + let mut cmd = Command::new("mount"); + match target.invocation { + MountInvocation::Direct => { + cmd.arg("-t") + .arg("davfs") + .arg(&target.source) + .arg(&target.mount_point); + if !target.options.is_empty() { + cmd.arg("-o").arg(target.options.join(",")); + } + } + MountInvocation::ViaFstab => { + cmd.arg(&target.mount_point); + } + } + run_tolerating_already_done(cmd, "davfs2 mount", false) + } + + fn unmount(&self, target: &MountTarget) -> Result<()> { + let mut cmd = Command::new("umount"); + cmd.arg(&target.mount_point); + run_tolerating_already_done(cmd, "davfs2 umount", true) + } +} + +fn davfs2_conf_path() -> PathBuf { + if sudo_ctdra::is_run_as_root() { + PathBuf::from("/etc/davfs2/davfs2.conf") + } else { + home_dir().join(".davfs2/davfs2.conf") + } +} + +pub(crate) fn davfs2_secrets_path() -> PathBuf { + if sudo_ctdra::is_run_as_root() { + PathBuf::from("/etc/davfs2/secrets") + } else { + home_dir().join(".davfs2/secrets") + } +} + +fn home_dir() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen +/// Dateimanagern (die dazu neigen, jede Datei zu öffnen) die Reaktionszeit bei großen +/// Verzeichnissen, indem die "ist eine neuere Version vorhanden?"-Abfrage für ein ganzes +/// Verzeichnis in einem PROPFIND-Request gebündelt wird. Siehe `man davfs2.conf`. +fn ensure_gui_optimize(path: &Path) -> Result<()> { + ensure_config_line(path, "gui_optimize", "1", GUI_OPTIMIZE_COMMENT) +} + +/// Setzt `buf_size` in `davfs2.conf` idempotent auf einen deutlich über dem Standard (16 +/// KiB) liegenden Wert. Bekanntes Praxisproblem bei davfs2 (unabhängig davon, ob die +/// `man davfs2.conf`-Beschreibung - reiner I/O-Geschwindigkeits-Tuningparameter - das exakt +/// so vorsieht): bei zu kleinem `buf_size` liefert `ls` in Verzeichnissen mit vielen Dateien +/// einen leeren/unvollständigen Inhalt zurück, obwohl einzelne Dateien direkt geöffnet werden +/// können (der FUSE-readdir-Puffer wird dabei stillschweigend abgeschnitten). Ein deutlich +/// größerer Puffer behebt das bei vertretbarem Speicher-Mehrverbrauch für einen einzelnen +/// Mount. +fn ensure_buf_size(path: &Path) -> Result<()> { + ensure_config_line(path, "buf_size", BUF_SIZE_KIB, BUF_SIZE_COMMENT) +} + +/// Setzt ` ` in einer davfs2-Konfigurationsdatei idempotent: ersetzt eine +/// bestehende (unkommentierte) Zeile mit demselben Schlüssel (unabhängig vom bisherigen +/// Wert) statt sie zu duplizieren, und lässt alle anderen Zeilen unangetastet. +fn ensure_config_line(path: &Path, key: &str, value: &str, comment: &str) -> Result<()> { + let existing = fs::read_to_string(path).unwrap_or_default(); + + let is_key_line = |line: &str| { + let trimmed = line.trim(); + !trimmed.starts_with('#') && trimmed.split_whitespace().next() == Some(key) + }; + + if existing + .lines() + .any(|l| is_key_line(l) && l.split_whitespace().nth(1) == Some(value)) + { + return Ok(()); + } + + let mut new_lines: Vec = existing + .lines() + .filter(|l| !is_key_line(l)) + .map(str::to_string) + .collect(); + + new_lines.push(comment.to_string()); + new_lines.push(format!("{key} {value}")); + + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; + } + fs::write(path, format!("{}\n", new_lines.join("\n"))).map_err(|e| Error::io(path, e)) +} + +/// Schreibt/aktualisiert eine Zeile in davfs2s `secrets`-Datei (` `, +/// muss chmod 600 sein). Ersetzt eine bestehende Zeile für dieselbe URL statt sie zu duplizieren. +fn write_secrets_entry(path: &PathBuf, url: &str, username: &str, password: &str) -> Result<()> { + let existing = fs::read_to_string(path).unwrap_or_default(); + let mut lines: Vec = existing + .lines() + .filter(|l| !l.trim_start().starts_with(url)) + .map(str::to_string) + .collect(); + lines.push(format!("{url} {username} {password}")); + + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); + } + } + + #[cfg(unix)] + let mut opts = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + #[cfg(not(unix))] + let mut opts = OpenOptions::new(); + + let mut file = opts + .write(true) + .create(true) + .truncate(true) + .open(path) + .map_err(|e| Error::io(path, e))?; + file.write_all(format!("{}\n", lines.join("\n")).as_bytes()) + .map_err(|e| Error::io(path, e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +/// Entfernt (best-effort) die Zeile für `url` aus der `secrets`-Datei, falls vorhanden - für +/// `smart-mount drive remove`, damit kein Klartext-Passwort für ein gelöschtes Paar zurückbleibt. +/// +/// **Bekannte Grenze:** die Zeile ist über die URL indiziert (inkl. IP bei MAC-adressierten +/// lokalen Seiten). Hat sich die IP seit dem letzten Mount geändert, berechnet der Aufrufer +/// eine andere, aktuelle URL als die tatsächlich gespeicherte - die eigentliche Alt-Zeile +/// bleibt dann zurück. Kein Fehler, falls die Datei nicht existiert oder `url` nicht enthält. +pub(crate) fn remove_secrets_entry(path: &Path, url: &str) -> Result<()> { + let Ok(existing) = fs::read_to_string(path) else { + return Ok(()); + }; + let remaining: Vec<&str> = existing + .lines() + .filter(|l| !l.trim_start().starts_with(url)) + .collect(); + if remaining.len() == existing.lines().count() { + return Ok(()); + } + fs::write(path, format!("{}\n", remaining.join("\n"))).map_err(|e| Error::io(path, e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ensure_gui_optimize_is_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("davfs2.conf"); + + ensure_gui_optimize(&path).expect("first call"); + let first = fs::read_to_string(&path).expect("read"); + ensure_gui_optimize(&path).expect("second call"); + let second = fs::read_to_string(&path).expect("read"); + + assert_eq!(first, second); + assert_eq!( + first + .lines() + .filter(|l| l.trim() == "gui_optimize 1") + .count(), + 1 + ); + } + + #[test] + fn ensure_gui_optimize_replaces_disabled_value() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("davfs2.conf"); + fs::write(&path, "use_locks 1\ngui_optimize 0\n").expect("write"); + + ensure_gui_optimize(&path).expect("patch"); + let contents = fs::read_to_string(&path).expect("read"); + + assert!(contents.contains("use_locks 1")); + assert!(contents.contains("gui_optimize 1")); + assert!(!contents.contains("gui_optimize 0")); + } + + #[test] + fn ensure_buf_size_raises_the_default_and_is_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("davfs2.conf"); + fs::write(&path, "buf_size 16\n").expect("write default"); + + ensure_buf_size(&path).expect("first call"); + let first = fs::read_to_string(&path).expect("read"); + ensure_buf_size(&path).expect("second call"); + let second = fs::read_to_string(&path).expect("read"); + + assert_eq!(first, second); + assert!( + !first.contains("buf_size 16\n") && !first.lines().any(|l| l.trim() == "buf_size 16") + ); + assert!( + first + .lines() + .any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}")) + ); + assert_eq!( + first + .lines() + .filter(|l| l.trim().starts_with("buf_size ")) + .count(), + 1 + ); + } + + #[test] + fn gui_optimize_and_buf_size_coexist_without_clobbering_each_other() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("davfs2.conf"); + + ensure_gui_optimize(&path).expect("gui_optimize"); + ensure_buf_size(&path).expect("buf_size"); + + let contents = fs::read_to_string(&path).expect("read"); + assert!(contents.lines().any(|l| l.trim() == "gui_optimize 1")); + assert!( + contents + .lines() + .any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}")) + ); + } + + #[test] + fn write_secrets_entry_replaces_existing_line_for_same_url() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secrets"); + + write_secrets_entry(&path, "https://cloud/dav", "user", "old-pass").expect("write 1"); + write_secrets_entry(&path, "https://cloud/dav", "user", "new-pass").expect("write 2"); + + let contents = fs::read_to_string(&path).expect("read"); + assert_eq!( + contents + .lines() + .filter(|l| l.contains("https://cloud/dav")) + .count(), + 1 + ); + assert!(contents.contains("new-pass")); + assert!(!contents.contains("old-pass")); + } + + #[test] + fn remove_secrets_entry_deletes_only_the_matching_url() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secrets"); + + write_secrets_entry(&path, "https://cloud/dav", "user1", "pass1").expect("write 1"); + write_secrets_entry(&path, "http://192.168.1.5/dav", "user2", "pass2").expect("write 2"); + + remove_secrets_entry(&path, "https://cloud/dav").expect("remove"); + let contents = fs::read_to_string(&path).expect("read"); + + assert!(!contents.contains("https://cloud/dav")); + assert!(!contents.contains("pass1")); + assert!(contents.contains("http://192.168.1.5/dav")); + assert!(contents.contains("pass2")); + } + + #[test] + fn remove_secrets_entry_is_a_noop_for_missing_file_or_unmatched_url() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist"); + assert!(remove_secrets_entry(&path, "https://cloud/dav").is_ok()); + + write_secrets_entry(&path, "https://cloud/dav", "user", "pass").expect("write"); + assert!(remove_secrets_entry(&path, "https://other/dav").is_ok()); + let contents = fs::read_to_string(&path).expect("read"); + assert!(contents.contains("https://cloud/dav")); + } +} diff --git a/src/network/address.rs b/src/network/address.rs new file mode 100644 index 0000000..2ab534e --- /dev/null +++ b/src/network/address.rs @@ -0,0 +1,18 @@ +//! Auflösung der konfigurierten lokalen Adresse (IP oder MAC) zu einer konkreten IPv4-Adresse. + +use std::net::Ipv4Addr; + +use crate::config::{GlobalSettings, LocalAddress}; +use crate::error::Result; +use crate::network::mac2ip; + +/// Löst eine [`LocalAddress`] zu einer konkreten IPv4-Adresse auf. +/// +/// `Ip`-Adressen werden direkt durchgereicht, `Mac`-Adressen über das externe `mac2ip`-Tool +/// aufgelöst (siehe [`mac2ip::resolve`]). +pub fn resolve_ip(address: &LocalAddress, settings: &GlobalSettings) -> Result { + match address { + LocalAddress::Ip(ip) => Ok(*ip), + LocalAddress::Mac(mac) => mac2ip::resolve(mac, &settings.mac2ip_binary), + } +} diff --git a/src/network/mac2ip.rs b/src/network/mac2ip.rs new file mode 100644 index 0000000..3185e87 --- /dev/null +++ b/src/network/mac2ip.rs @@ -0,0 +1,107 @@ +//! Isolierter Wrapper um das externe, private `mac2ip`-CLI-Tool. +//! +//! `mac2ip` ist kein Rust-Crate, sondern ein eigenständiges, bereits vorhandenes CLI-Tool +//! des Nutzers (siehe Projekt "Mac2Ip"). smart-mount ruft es ausschließlich als Subprozess +//! auf; die JSON-Ausgabeform (`{"status":"ok","mac":..,"ip":..,"source":..}` bzw. +//! `{"status":"error","mac":..,"error":..}`) wurde gegen den tatsächlichen Quellcode +//! (`src/output.rs`) verifiziert. +//! +//! `--auto-trust-networks` wird immer mitgegeben: mac2ip beantwortet damit seine eigene +//! "nmap-Scan in diesem Netzwerk erlauben?"-Rückfrage automatisch mit Ja und merkt sich das +//! Netzwerk dauerhaft in seiner eigenen Cache-Datenbank - funktional identisch zum manuellen +//! Eintragen in mac2ips Config, aber ohne dass smart-mount das Config-Schema eines fremden +//! Tools kennen oder dort hineinschreiben muss. + +use std::net::Ipv4Addr; +use std::process::Command; + +use serde::Deserialize; + +use crate::error::{Error, Result}; + +#[derive(Deserialize)] +#[serde(untagged)] +enum Mac2IpOutput { + Success { ip: Ipv4Addr }, + Failure { error: String }, +} + +/// Löst eine MAC-Adresse über das externe `mac2ip`-Tool zu einer IPv4-Adresse auf. +/// +/// `binary` ist der konfigurierte Binary-Name/-Pfad (`GlobalSettings::mac2ip_binary`, +/// standardmäßig `"mac2ip"`, per PATH aufgelöst). +pub fn resolve(mac: &str, binary: &str) -> Result { + let output = Command::new(binary) + .args(["--json", "--auto-trust-networks", mac]) + .output() + .map_err(|e| Error::Mac2Ip { + mac: mac.to_string(), + reason: format!("could not run '{binary}': {e}"), + })?; + + parse_output(&output.stdout, mac) +} + +/// Prüft, ob das konfigurierte `mac2ip`-Binary über `PATH` auffindbar ist. +pub fn is_installed(binary: &str) -> bool { + Command::new("which") + .arg(binary) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn parse_output(stdout: &[u8], mac: &str) -> Result { + let text = String::from_utf8_lossy(stdout); + let line = text.lines().next().unwrap_or("").trim(); + + if line.is_empty() { + return Err(Error::Mac2Ip { + mac: mac.to_string(), + reason: "no output received from mac2ip".to_string(), + }); + } + + match serde_json::from_str::(line) { + Ok(Mac2IpOutput::Success { ip }) => Ok(ip), + Ok(Mac2IpOutput::Failure { error }) => Err(Error::Mac2Ip { + mac: mac.to_string(), + reason: error, + }), + Err(e) => Err(Error::Mac2Ip { + mac: mac.to_string(), + reason: format!("could not parse output as JSON: {e} (output: '{line}')"), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_success_output() { + let stdout = + br#"{"status":"ok","mac":"aa:bb:cc:dd:ee:ff","ip":"192.168.1.42","source":"cache"}"#; + let ip = parse_output(stdout, "aa:bb:cc:dd:ee:ff").expect("should parse"); + assert_eq!(ip, Ipv4Addr::new(192, 168, 1, 42)); + } + + #[test] + fn parses_failure_output_as_error() { + let stdout = + br#"{"status":"error","mac":"aa:bb:cc:dd:ee:ff","error":"keine IP-Adresse gefunden"}"#; + let err = parse_output(stdout, "aa:bb:cc:dd:ee:ff").unwrap_err(); + assert!(matches!(err, Error::Mac2Ip { .. })); + } + + #[test] + fn empty_output_is_an_error_not_a_panic() { + assert!(parse_output(b"", "aa:bb:cc:dd:ee:ff").is_err()); + } + + #[test] + fn garbage_output_is_an_error_not_a_panic() { + assert!(parse_output(b"not json at all", "aa:bb:cc:dd:ee:ff").is_err()); + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs new file mode 100644 index 0000000..d5151b3 --- /dev/null +++ b/src/network/mod.rs @@ -0,0 +1,30 @@ +//! Erreichbarkeitsprüfungen und lokale Adressauflösung. + +pub mod address; +pub mod mac2ip; + +use std::process::{Command, Stdio}; + +/// Prüft, ob `addr` erreichbar ist. Erkennt anhand des Präfixes, ob es sich um eine URL +/// (HTTP HEAD via `curl`) oder eine reine Host-/IP-Adresse (ICMP-Ping) handelt. +/// +/// Portiert aus dem alten `src/network/utils.rs` (v0.2.0). +pub fn is_reachable(addr: &str) -> bool { + if addr.starts_with("http://") || addr.starts_with("https://") { + Command::new("curl") + .args(["--head", "--silent", "--fail", "--max-time", "5", addr]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } else { + Command::new("ping") + .args(["-c", "1", "-W", "2", addr]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } +} diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs new file mode 100644 index 0000000..ae7a59a --- /dev/null +++ b/src/reconcile/mod.rs @@ -0,0 +1,221 @@ +//! Watchdog-Reconciling: entscheidet pro Laufwerkspaar, ob lokal oder Cloud eingebunden +//! sein sollte, und schaltet bei Bedarf um. +//! +//! `watch_once` ist bewusst ein einzelner, synchroner Durchlauf ohne internen Sleep-Loop - +//! dieselbe Kommandozeile (`smart-mount watch`) funktioniert dadurch sowohl unter einem +//! systemd-Timer als auch als Crontab-Zeile. +//! +//! **Zu jedem Zeitpunkt ist im Ruhezustand genau eine Seite gemountet** (wie ursprünglich +//! vorgesehen) - der sichtbare `pair.mount_point` ist ein Symlink auf das jeweils aktive +//! Backing-Verzeichnis (siehe [`crate::mount::target`]). Nur *während* eines aktiven +//! Umschaltens sind kurz beide Backing-Verzeichnisse gemountet: [`switch_to`] mountet die +//! neue Seite zuerst, flippt dann den Symlink, und hängt erst danach die alte Seite aus - so +//! gibt es nie ein Zeitfenster, in dem der sichtbare Pfad auf nichts Gemountetes zeigt. + +use crate::config::{AppConfig, DrivePair, GlobalSettings, LocalSide}; +use crate::db::credentials::{CredentialStore, Side}; +use crate::error::Result; +use crate::mount::{self, lock, target}; +use crate::network::{self, address}; + +/// Ergebnis eines Reconcile-Durchlaufs für ein Paar. +#[derive(Debug)] +pub enum Action { + NoOp, + MountedLocal, + MountedCloud, + SwitchedToLocal, + SwitchedToCloud, + Failed(String), +} + +#[derive(Debug)] +pub struct ReconcileOutcome { + pub pair_id: String, + pub pair_name: String, + pub action: Action, +} + +/// Führt `reconcile_pair` für jedes aktivierte Paar in `cfg` aus. +pub async fn watch_once(cfg: &AppConfig, creds: &CredentialStore) -> Vec { + let mut outcomes = Vec::with_capacity(cfg.pairs.len()); + for pair in cfg.pairs.iter().filter(|p| p.enabled) { + outcomes.push(reconcile_pair(pair, &cfg.settings, creds).await); + } + outcomes +} + +/// Entscheidungstabelle (siehe Moduldoku): prüft Erreichbarkeit beider Seiten, vergleicht +/// mit der aktuell aktiven Seite, und schaltet bei Bedarf um. +pub async fn reconcile_pair( + pair: &DrivePair, + settings: &GlobalSettings, + creds: &CredentialStore, +) -> ReconcileOutcome { + let pair_id = pair.id.clone(); + let pair_name = pair.name.clone(); + + match reconcile_pair_inner(pair, settings, creds).await { + Ok(action) => ReconcileOutcome { + pair_id, + pair_name, + action, + }, + Err(e) => ReconcileOutcome { + pair_id, + pair_name, + action: Action::Failed(e.to_string()), + }, + } +} + +async fn reconcile_pair_inner( + pair: &DrivePair, + settings: &GlobalSettings, + creds: &CredentialStore, +) -> Result { + let _guard = lock::acquire(&pair.id).await; + + let local_reachable = check_local_reachable(&pair.local, settings); + let active = target::active_side(pair); + + if local_reachable { + if active == Some(Side::Local) { + return Ok(Action::NoOp); + } + switch_to(pair, settings, Side::Local, creds, active).await?; + return Ok(if active.is_some() { + Action::SwitchedToLocal + } else { + Action::MountedLocal + }); + } + + let cloud_reachable = network::is_reachable(&pair.cloud.host_or_url); + if cloud_reachable { + if active == Some(Side::Cloud) { + return Ok(Action::NoOp); + } + switch_to(pair, settings, Side::Cloud, creds, active).await?; + return Ok(if active.is_some() { + Action::SwitchedToCloud + } else { + Action::MountedCloud + }); + } + + Ok(Action::NoOp) +} + +fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> bool { + match address::resolve_ip(&local.address, settings) { + Ok(ip) => network::is_reachable(&ip.to_string()), + Err(_) => false, + } +} + +/// Mountet `new_side` zuerst, flippt danach den sichtbaren Symlink, und hängt erst zum +/// Schluss `old_active` (falls vorhanden und verschieden) aus. Diese Reihenfolge stellt +/// sicher, dass der sichtbare `pair.mount_point` nie auf ein gerade ausgehängtes oder noch +/// nicht bereites Backing-Verzeichnis zeigt. +async fn switch_to( + pair: &DrivePair, + settings: &GlobalSettings, + new_side: Side, + creds: &CredentialStore, + old_active: Option, +) -> Result<()> { + mount_side(pair, settings, new_side, creds).await?; + target::activate_symlink(pair, new_side)?; + + if let Some(old_side) = old_active + && old_side != new_side + { + unmount_side(pair, settings, old_side).await?; + } + Ok(()) +} + +async fn mount_side( + pair: &DrivePair, + settings: &GlobalSettings, + side: Side, + creds: &CredentialStore, +) -> Result<()> { + let mount_target = target::build_target(pair, settings, side)?; + std::fs::create_dir_all(&mount_target.mount_point) + .map_err(|e| crate::error::Error::io(&mount_target.mount_point, e))?; + + let backend = mount::backend_for(target::side_kind(pair, side)); + backend.check_available()?; + let cred = creds.get(&pair.id, side).await?; + backend.prepare(&mount_target, cred.as_ref())?; + backend.mount(&mount_target) +} + +/// Hängt aus, was aktuell aktiv ist (siehe [`target::active_side`]), unabhängig von der +/// Erreichbarkeit - für `smart-mount unmount`. Der Symlink bleibt bestehen (zeigt danach auf +/// ein leeres, ausgehängtes Backing-Verzeichnis) - der nächste `mount`/`watch`-Lauf räumt das +/// beim erneuten Aktivieren automatisch auf. +pub async fn unmount_pair(pair: &DrivePair, settings: &GlobalSettings) -> Result { + let _guard = lock::acquire(&pair.id).await; + + let Some(side) = target::active_side(pair) else { + return Ok(Action::NoOp); + }; + unmount_side(pair, settings, side).await?; + Ok(Action::NoOp) +} + +async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -> Result<()> { + let mount_target = target::build_target(pair, settings, side)?; + mount::backend_for(target::side_kind(pair, side)).unmount(&mount_target) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CloudSide, MountContext, MountKind}; + use std::net::Ipv4Addr; + + fn sample_pair(local_kind: MountKind, cloud_kind: MountKind) -> DrivePair { + DrivePair { + id: "pair-1".into(), + name: "Test".into(), + enabled: true, + context: MountContext::System, + owner_user: None, + mount_point: "/media/smart-mount/pair-1".into(), + local: LocalSide { + kind: local_kind, + address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)), + share: "share".into(), + username: None, + extra_options: vec!["vers=3.0".into()], + }, + cloud: CloudSide { + kind: cloud_kind, + host_or_url: "https://cloud.example.com/dav".into(), + share: "share".into(), + username: None, + extra_options: vec![], + }, + } + } + + #[test] + fn local_source_formats_smb_unc_path() { + let pair = sample_pair(MountKind::Smb, MountKind::WebDav); + let source = target::local_source(&pair.local, &GlobalSettings::default()); + assert_eq!(source, "//192.168.1.10/share"); + } + + #[test] + fn build_target_uses_backing_dir_not_visible_mount_point() { + let pair = sample_pair(MountKind::Smb, MountKind::WebDav); + let t = + target::build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build"); + assert_ne!(t.mount_point, pair.mount_point); + assert_eq!(t.mount_point, target::backing_dir(&pair, Side::Local)); + } +} diff --git a/src/systemd/mod.rs b/src/systemd/mod.rs new file mode 100644 index 0000000..ed265a6 --- /dev/null +++ b/src/systemd/mod.rs @@ -0,0 +1,392 @@ +//! Generiert/installiert systemd-Units (System- und User-Kontext) sowie das Crontab-Äquivalent +//! für Systeme ohne systemd. +//! +//! Die systemd-Units rufen ausschließlich einfache `smart-mount`-Subcommands auf, damit +//! dieselben Zeilen 1:1 als Crontab-Einträge funktionieren. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::error::{Error, Result}; + +const MOUNT_SERVICE: &str = "smart-mount-mount.service"; +const WATCH_SERVICE: &str = "smart-mount-watch.service"; +const WATCH_TIMER: &str = "smart-mount-watch.timer"; + +const CRON_D_PATH: &str = "/etc/cron.d/smart-mount"; +const CRON_BEGIN_MARKER: &str = "# BEGIN smart-mount managed block"; +const CRON_END_MARKER: &str = "# END smart-mount managed block"; + +/// System (root) oder User-Kontext für die Unit-Installation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + System, + User, +} + +fn binary_path() -> String { + std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| "/usr/bin/smart-mount".to_string()) +} + +fn mount_service_unit() -> String { + format!( + "[Unit]\nDescription=smart-mount: mount configured drive pairs at boot\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart={} mount --all\n\n[Install]\nWantedBy=multi-user.target\n", + binary_path() + ) +} + +fn watch_service_unit() -> String { + format!( + "[Unit]\nDescription=smart-mount: check reachability and switch local/cloud if needed\n\n[Service]\nType=oneshot\nExecStart={} watch\n", + binary_path() + ) +} + +fn watch_timer_unit(interval_secs: u64) -> String { + format!( + "[Unit]\nDescription=smart-mount: periodic reconciling\n\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec={interval_secs}s\nPersistent=true\nUnit={WATCH_SERVICE}\n\n[Install]\nWantedBy=timers.target\n" + ) +} + +fn unit_dir(scope: Scope) -> Result { + match scope { + Scope::System => { + if !sudo_ctdra::is_run_as_root() { + return Err(Error::RequiresRoot("service install --system")); + } + Ok(PathBuf::from("/etc/systemd/system")) + } + Scope::User => { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or(Error::Other("HOME not set".to_string()))?; + Ok(home.join(".config/systemd/user")) + } + } +} + +fn systemctl(scope: Scope, args: &[&str]) -> Result<()> { + let mut cmd = Command::new("systemctl"); + if scope == Scope::User { + cmd.arg("--user"); + } + cmd.args(args); + let output = cmd + .output() + .map_err(|e| Error::Other(format!("could not run systemctl: {e}")))?; + if !output.status.success() { + return Err(Error::Other(format!( + "systemctl {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(()) +} + +/// Schreibt die Unit-Dateien, lädt systemd neu und aktiviert Mount- und Watch-Timer-Unit. +pub fn install(scope: Scope, watch_interval_secs: u64) -> Result<()> { + let dir = unit_dir(scope)?; + std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?; + + std::fs::write(dir.join(MOUNT_SERVICE), mount_service_unit()) + .map_err(|e| Error::io(&dir, e))?; + std::fs::write(dir.join(WATCH_SERVICE), watch_service_unit()) + .map_err(|e| Error::io(&dir, e))?; + std::fs::write(dir.join(WATCH_TIMER), watch_timer_unit(watch_interval_secs)) + .map_err(|e| Error::io(&dir, e))?; + + systemctl(scope, &["daemon-reload"])?; + systemctl(scope, &["enable", "--now", MOUNT_SERVICE, WATCH_TIMER])?; + + if scope == Scope::User { + logger_ctdra::info( + "systemd", + "For boot-time operation without an active login session: run 'loginctl enable-linger '. \ + Note: in that case the OS keyring may not yet be available at boot - \ + smart-mount then automatically falls back to the key file.", + ); + } + + Ok(()) +} + +/// Ob `systemctl` auf diesem System überhaupt vorhanden ist - Voraussetzung, bevor +/// [`install`]/[`uninstall`] sinnvoll aufgerufen werden können. +pub fn is_available() -> bool { + crate::mount::binary_available("systemctl") +} + +/// Ergebnis von [`uninstall`]. +pub enum SystemdUninstallOutcome { + /// Mindestens eine Unit-Datei war vorhanden und wurde entfernt. + Removed, + /// Keine der Unit-Dateien war vorhanden - nichts zu tun. + NotPresent, +} + +/// Deaktiviert und entfernt die Unit-Dateien, falls vorhanden. +pub fn uninstall(scope: Scope) -> Result { + let dir = unit_dir(scope)?; + let units = [MOUNT_SERVICE, WATCH_SERVICE, WATCH_TIMER]; + + if !units.iter().any(|unit| dir.join(unit).exists()) { + return Ok(SystemdUninstallOutcome::NotPresent); + } + + let _ = systemctl(scope, &["disable", "--now", MOUNT_SERVICE, WATCH_TIMER]); + + for unit in units { + let path = dir.join(unit); + if path.exists() { + std::fs::remove_file(&path).map_err(|e| Error::io(&path, e))?; + } + } + + systemctl(scope, &["daemon-reload"])?; + Ok(SystemdUninstallOutcome::Removed) +} + +/// Erzeugt die Crontab-Äquivalente zu den generierten Units, für Systeme ohne systemd. +/// `watch_interval_secs` ist derselbe Wert wie `settings.watch_interval_secs`, der auch die +/// `OnUnitActiveSec`-Periode des systemd-Timers steuert - beide Wege sollen dieselbe Kadenz +/// ergeben, statt dass die Crontab-Variante einen unabhängigen, fest eingebauten Wert hat. +pub fn crontab_equivalent(watch_interval_secs: u64) -> String { + let bin = binary_path(); + let schedule = cron_schedule_for_interval(watch_interval_secs); + format!("@reboot {bin} mount --all\n{schedule} {bin} watch\n") +} + +/// Ergebnis von [`install_cron`]. +pub enum CronInstallOutcome { + /// Systemweiter Eintrag geschrieben (`/etc/cron.d/smart-mount`). + SystemFile(PathBuf), + /// Persönliche Crontab des aufrufenden Nutzers aktualisiert. + UserCrontab, + /// Kein Cron-Mechanismus auf diesem System gefunden - nichts geschrieben, der Aufrufer + /// sollte stattdessen [`crontab_equivalent`] anzeigen. + Unavailable, +} + +/// Richtet die periodische Ausführung direkt über Cron ein (Alternative zu [`install`] für +/// Systeme ohne systemd), sofern ein Cron-Mechanismus gefunden wird - sonst [`CronInstallOutcome::Unavailable`] +/// statt eines Fehlers, der Aufrufer zeigt dann [`crontab_equivalent`] zur manuellen Einrichtung. +/// +/// `Scope::System` schreibt `/etc/cron.d/smart-mount` (Standard-Konvention für +/// paketverwaltete Cron-Einträge, läuft als root; erfordert Root-Rechte, kein +/// Self-Elevate - analog zu `install(Scope::System, ...)`). `Scope::User` aktualisiert die +/// persönliche Crontab des aufrufenden Nutzers über `crontab -l`/`crontab -`, mit demselben +/// verwalteten-Block-Muster wie `fstab::setup` für `/etc/fstab` - bestehende, unabhängige +/// Cron-Einträge bleiben unangetastet. +pub fn install_cron(scope: Scope, watch_interval_secs: u64) -> Result { + match scope { + Scope::System => install_cron_system(watch_interval_secs), + Scope::User => install_cron_user(watch_interval_secs), + } +} + +fn managed_cron_block(watch_interval_secs: u64, user_field: Option<&str>) -> String { + let bin = binary_path(); + let schedule = cron_schedule_for_interval(watch_interval_secs); + let user_prefix = user_field.map(|u| format!("{u} ")).unwrap_or_default(); + format!( + "{CRON_BEGIN_MARKER}\n@reboot {user_prefix}{bin} mount --all\n{schedule} {user_prefix}{bin} watch\n{CRON_END_MARKER}\n" + ) +} + +fn install_cron_system(watch_interval_secs: u64) -> Result { + if !sudo_ctdra::is_run_as_root() { + return Err(Error::RequiresRoot("service crontab (system context)")); + } + if !Path::new("/etc/cron.d").is_dir() { + return Ok(CronInstallOutcome::Unavailable); + } + + // /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root, + // passend dazu, dass System-Kontext-Paare auch sonst als root gemountet werden. + let contents = managed_cron_block(watch_interval_secs, Some("root")); + std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(CRON_D_PATH, std::fs::Permissions::from_mode(0o644)) + .map_err(|e| Error::io(CRON_D_PATH, e))?; + } + Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH))) +} + +fn install_cron_user(watch_interval_secs: u64) -> Result { + if !crate::mount::binary_available("crontab") { + return Ok(CronInstallOutcome::Unavailable); + } + + let existing = read_current_user_crontab(); + let without_block = + crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER); + let block = managed_cron_block(watch_interval_secs, None); + let new_contents = format!("{}\n{block}", without_block.trim_end()); + + write_user_crontab(&new_contents)?; + Ok(CronInstallOutcome::UserCrontab) +} + +/// Ergebnis von [`uninstall_cron`]. +pub enum CronUninstallOutcome { + /// Ein verwalteter Cron-Eintrag wurde gefunden und entfernt. + Removed, + /// Kein von smart-mount verwalteter Cron-Eintrag vorhanden - nichts zu tun. + NotPresent, +} + +/// Gegenstück zu [`install_cron`]: entfernt einen zuvor über `install_cron` angelegten +/// Cron-Eintrag wieder, sofern vorhanden. `Scope::User` rührt dabei - wie `install_cron` - +/// nur den von smart-mount verwalteten Block in der persönlichen Crontab an, keine +/// unabhängigen, bereits vorhandenen Einträge. +pub fn uninstall_cron(scope: Scope) -> Result { + match scope { + Scope::System => uninstall_cron_system(), + Scope::User => uninstall_cron_user(), + } +} + +fn uninstall_cron_system() -> Result { + if !sudo_ctdra::is_run_as_root() { + return Err(Error::RequiresRoot( + "service uninstall (system context, cron)", + )); + } + let path = Path::new(CRON_D_PATH); + if !path.exists() { + return Ok(CronUninstallOutcome::NotPresent); + } + std::fs::remove_file(path).map_err(|e| Error::io(CRON_D_PATH, e))?; + Ok(CronUninstallOutcome::Removed) +} + +fn uninstall_cron_user() -> Result { + if !crate::mount::binary_available("crontab") { + return Ok(CronUninstallOutcome::NotPresent); + } + let existing = read_current_user_crontab(); + if !existing.contains(CRON_BEGIN_MARKER) { + return Ok(CronUninstallOutcome::NotPresent); + } + let without_block = + crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER); + write_user_crontab(without_block.trim_end())?; + Ok(CronUninstallOutcome::Removed) +} + +/// `crontab -l` meldet für einen Nutzer ohne bestehende Crontab einen Fehler ("no crontab for +/// ...") - das ist der Normalfall bei der ersten Einrichtung, kein echter Fehler. +fn read_current_user_crontab() -> String { + Command::new("crontab") + .arg("-l") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default() +} + +fn write_user_crontab(contents: &str) -> Result<()> { + let mut child = Command::new("crontab") + .arg("-") + .stdin(Stdio::piped()) + .spawn() + .map_err(|e| Error::Other(format!("could not start 'crontab': {e}")))?; + child + .stdin + .take() + .ok_or_else(|| Error::Other("stdin of 'crontab -' not available".to_string()))? + .write_all(contents.as_bytes()) + .map_err(|e| Error::Other(format!("writing to 'crontab -' failed: {e}")))?; + let status = child + .wait() + .map_err(|e| Error::Other(format!("'crontab -' failed: {e}")))?; + if !status.success() { + return Err(Error::Other("'crontab -' reported an error".to_string())); + } + Ok(()) +} + +/// Rechnet ein Sekunden-Intervall in einen `*/N`-artigen Cron-Ausdruck um. Crons Granularität +/// ist Minuten (keine Sekunden) - es wird auf die nächste Minute gerundet, mindestens 1 +/// (Cron kann nicht häufiger als minütlich auslösen). Ab 60 Minuten wird auf Stunden +/// umgestellt (`0 */N * * *`); Intervalle über 24h werden grob als "täglich um Mitternacht" +/// angenähert, da ein reiner `*/N`-Ausdruck das nicht mehr sauber abbilden kann. +fn cron_schedule_for_interval(interval_secs: u64) -> String { + let minutes = ((interval_secs as f64 / 60.0).round() as u64).max(1); + if minutes <= 59 { + format!("*/{minutes} * * * *") + } else { + let hours = ((minutes as f64 / 60.0).round() as u64).max(1); + if hours <= 23 { + format!("0 */{hours} * * *") + } else { + "0 0 * * *".to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn managed_cron_block_for_user_crontab_has_no_user_field() { + let block = managed_cron_block(120, None); + assert!(block.starts_with(CRON_BEGIN_MARKER)); + assert!(block.trim_end().ends_with(CRON_END_MARKER)); + assert!(block.contains("@reboot") && !block.contains("@reboot root")); + assert!(block.contains("mount --all")); + assert!(block.contains("*/2 * * * *")); + } + + #[test] + fn managed_cron_block_for_system_cron_d_includes_user_field() { + let block = managed_cron_block(120, Some("root")); + assert!(block.contains("@reboot root ")); + assert!(block.contains("*/2 * * * * root ")); + } + + #[test] + fn default_watch_interval_maps_to_every_two_minutes() { + assert_eq!(cron_schedule_for_interval(120), "*/2 * * * *"); + } + + #[test] + fn rounds_to_the_nearest_minute() { + assert_eq!(cron_schedule_for_interval(90), "*/2 * * * *"); + assert_eq!(cron_schedule_for_interval(80), "*/1 * * * *"); + } + + #[test] + fn sub_minute_intervals_clamp_to_one_minute() { + assert_eq!(cron_schedule_for_interval(30), "*/1 * * * *"); + assert_eq!(cron_schedule_for_interval(1), "*/1 * * * *"); + } + + #[test] + fn switches_to_hourly_expression_above_59_minutes() { + assert_eq!(cron_schedule_for_interval(60 * 90), "0 */2 * * *"); + } + + #[test] + fn very_long_intervals_fall_back_to_daily_at_midnight() { + assert_eq!(cron_schedule_for_interval(60 * 60 * 30), "0 0 * * *"); + } + + #[test] + fn crontab_equivalent_includes_both_reboot_and_watch_lines() { + let output = crontab_equivalent(120); + assert!(output.contains("@reboot")); + assert!(output.contains("mount --all")); + assert!(output.contains("*/2 * * * *")); + assert!(output.contains("watch")); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..cf93ea9 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,56 @@ +//! Kleine, modulübergreifend geteilte Hilfsfunktionen. + +/// Entfernt einen durch `begin_marker`/`end_marker` abgegrenzten Abschnitt aus `contents` +/// (Marker-Zeilen selbst eingeschlossen). Für das "verwalteter Block"-Muster, mit dem +/// smart-mount eigene Zeilen in einer fremden Datei (`/etc/fstab`, Crontab) aktualisiert, +/// ohne bestehende, unabhängige Einträge anzurühren - siehe [`crate::fstab`] und +/// [`crate::systemd`]. +pub(crate) fn strip_managed_block(contents: &str, begin_marker: &str, end_marker: &str) -> String { + let mut out = String::new(); + let mut inside = false; + for line in contents.lines() { + if line.trim() == begin_marker { + inside = true; + continue; + } + if line.trim() == end_marker { + inside = false; + continue; + } + if !inside { + out.push_str(line); + out.push('\n'); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn removes_only_the_marked_section() { + let contents = "line1\n# BEGIN test\nfoo\nbar\n# END test\nline2\n"; + let stripped = strip_managed_block(contents, "# BEGIN test", "# END test"); + assert_eq!(stripped, "line1\nline2\n"); + } + + #[test] + fn is_a_noop_when_markers_are_absent() { + let contents = "line1\nline2\n"; + assert_eq!( + strip_managed_block(contents, "# BEGIN test", "# END test"), + contents + ); + } + + #[test] + fn handles_content_before_the_first_marker_and_no_trailing_content() { + let contents = "keep-me\n# BEGIN x\ndrop-me\n# END x\n"; + assert_eq!( + strip_managed_block(contents, "# BEGIN x", "# END x"), + "keep-me\n" + ); + } +}