diff --git a/Cargo.lock b/Cargo.lock index 67586bf..8678232 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android-activity" version = "0.6.1" @@ -749,6 +755,50 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cap-primitives" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix 1.1.4", +] + +[[package]] +name = "cap-tempfile" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f22d5c0b1147e8bd9105fa004450f673c139d7e632f7edc3980f94a51f20f3" +dependencies = [ + "cap-std", + "rand 0.9.5", + "rustix 1.1.4", + "rustix-linux-procfs", + "uuid", + "windows-sys 0.61.2", +] + [[package]] name = "cargo-platform" version = "0.3.3" @@ -1172,7 +1222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -1184,7 +1234,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -1258,7 +1308,7 @@ dependencies = [ "crypto-bigint", "elliptic-curve", "pkcs8", - "rand_core", + "rand_core 0.6.4", "serdect 0.3.0", "sha3", "signature", @@ -1566,7 +1616,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -1595,7 +1645,7 @@ dependencies = [ "hkdf", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "serde_json", "serdect 0.2.0", @@ -1721,7 +1771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ "bitvec", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1875,6 +1925,17 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "funty" version = "2.0.0" @@ -2160,7 +2221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2617,15 +2678,46 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "ironstorage" version = "0.1.0" dependencies = [ + "cap-std", + "cap-tempfile", "clap", "flate2", "hex", "pgp", - "rand_chacha", + "rand_chacha 0.3.1", + "rustix 1.1.4", "serde", "sha1", "sha2", @@ -2634,6 +2726,7 @@ dependencies = [ "tempfile", "toml 0.9.12+spec-1.1.0", "url", + "zeroize", ] [[package]] @@ -2976,6 +3069,12 @@ dependencies = [ "libc", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "md-5" version = "0.10.6" @@ -3183,7 +3282,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "serde", "smallvec", "zeroize", @@ -3736,7 +3835,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "rand_core", + "rand_core 0.6.4", "sha2", ] @@ -3800,7 +3899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -3919,7 +4018,7 @@ dependencies = [ "p256", "p384", "p521", - "rand", + "rand 0.8.7", "replace_with", "ripemd", "rsa", @@ -3963,7 +4062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.7", ] [[package]] @@ -4211,8 +4310,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "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]] @@ -4222,7 +4331,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "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]] @@ -4234,6 +4353,15 @@ 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 = "range-alloc" version = "0.1.5" @@ -4481,7 +4609,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -4535,6 +4663,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4808,7 +4946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -6632,6 +6770,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.1", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -6692,7 +6840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 50147c4..556373e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ edition = "2024" rust-version = "1.92" [workspace.dependencies] +cap-std = "4.0" +cap-tempfile = "4.0" clap = { version = "4.6", features = ["derive"] } crossterm = "0.29" iced = "0.14" @@ -24,3 +26,4 @@ shlex = "1.3" toml = "0.9" uniffi = "0.32" url = { version = "2.5", default-features = false } +zeroize = "1.8" diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index b46311c..87b4cfd 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -12,11 +12,14 @@ The current direct dependencies are: | Crate | Purpose | License | | --- | --- | --- | +| [cap-std 4.0](https://crates.io/crates/cap-std/4.0.2), [cap-tempfile 4.0](https://crates.io/crates/cap-tempfile/4.0.2) | Capability-scoped filesystem access and atomic temporary files | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | | [clap 4.6](https://crates.io/crates/clap/4.6.4) | CLI parsing | MIT OR Apache-2.0 | | [crossterm 0.29](https://crates.io/crates/crossterm/0.29.0) | Terminal I/O | MIT | | [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT | | [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT | +| [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 | | [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 | +| [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 | The activated transitive graph has no dependency that forces a GPL or LGPL license choice. UniFFI and its support crates are the only mandatory copyleft @@ -38,11 +41,11 @@ decision. | Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Preferred with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only and supply credentials directly. | | Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. | | Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store`](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store`](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store`](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Preferred per-platform stores. The Apple protected store supports iOS/macOS protected data and biometric access. Use the Linux store's Rust crypto feature. | -| Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | Preferred wrappers; still avoid unnecessary copies and logging. | +| Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. | | Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. | | TOTP and HOTP | [`hmac`](https://crates.io/crates/hmac), [`sha1`](https://crates.io/crates/sha1), [`sha2`](https://crates.io/crates/sha2), [`data-encoding`](https://crates.io/crates/data-encoding), [`url`](https://crates.io/crates/url) | MIT or MIT OR Apache-2.0 | Preferred small implementation with RFC test vectors. `totp-rs` is MIT but rejects HOTP URIs, so it cannot cover all of `pass-otp`. | | QR output and desktop image input | [`qrcode` 0.14](https://crates.io/crates/qrcode/0.14.1), [`rqrr` 0.10](https://crates.io/crates/rqrr/0.10.1) | MIT OR Apache-2.0; second crate also includes ISC | Suitable. Apple camera scanning should use AVFoundation and pass only the decoded URI to Rust. | -| Atomic file replacement | standard library, then [`tempfile`](https://crates.io/crates/tempfile) if needed | MIT OR Apache-2.0 | Start with the standard library; add `tempfile` only when the first safe-write implementation needs it. | +| Atomic file replacement and scoped filesystem access | [`cap-std` 4.0](https://crates.io/crates/cap-std/4.0.2), [`cap-tempfile` 4.0](https://crates.io/crates/cap-tempfile/4.0.2) | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | Selected. Directory capabilities prevent vault escape; temporary files are anonymous where supported and are synced before atomic replacement. | With this path, the central crate needs no third-party native GPG, Git, OTP, or QR library. Apple Security/LocalAuthentication, Windows Credential Manager, diff --git a/README.md b/README.md index 2bfc67f..9cae7c6 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Candidate libraries and the pending project-license decision are tracked in The shared TOML schema, path rules, editor precedence, and HTTPS remote format are documented in [`docs/configuration.md`](docs/configuration.md). +The capability-scoped password-store layout and atomic mutation guarantees are +documented in [`docs/repository-core.md`](docs/repository-core.md). ## Project layout diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 16319f3..7457bf6 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -7,17 +7,21 @@ rust-version.workspace = true publish = false [dependencies] +cap-std.workspace = true +cap-tempfile.workspace = true clap.workspace = true serde.workspace = true shlex.workspace = true toml.workspace = true url.workspace = true +zeroize.workspace = true [dev-dependencies] flate2 = "1.1" hex = "0.4" pgp = { version = "0.20", default-features = false } rand_chacha = "0.3" +rustix = { version = "1.1", features = ["fs"] } sha1 = "0.10" sha2 = "0.10" smallvec = "1.15" diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 6740bd1..c7eca59 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -7,6 +7,7 @@ pub mod command; pub mod config; +pub mod repository; /// Product name shared by the presentation adapters. pub const PRODUCT_NAME: &str = "IronStorage"; diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs new file mode 100644 index 0000000..f989d85 --- /dev/null +++ b/crates/storage/src/repository.rs @@ -0,0 +1,1217 @@ +//! Capability-scoped password-store repository discovery and atomic file access. + +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, + ffi::{OsStr, OsString}, + fmt, fs, + io::{self, Read as _, Write as _}, + path::{Component, Path, PathBuf}, +}; + +use cap_std::{ambient_authority, fs::Dir}; +use cap_tempfile::TempFile; +use zeroize::Zeroize; + +const ENTRY_EXTENSION: &str = "gpg"; +const RECIPIENT_FILE: &str = ".gpg-id"; +const RECIPIENT_SIGNATURE_FILE: &str = ".gpg-id.sig"; +const GIT_DIRECTORY: &str = ".git"; + +/// A validated logical password-store entry path, without the `.gpg` suffix. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct EntryPath(PathBuf); + +impl EntryPath { + pub fn parse(path: impl AsRef) -> Result { + let path = path.as_ref(); + if has_trailing_separator(path) { + return Err(RepositoryError::InvalidPath { + path: path.to_owned(), + }); + } + Ok(Self(normalize_relative_path(path, false)?)) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + fn parent(&self) -> DirectoryPath { + DirectoryPath(self.0.parent().map_or_else(PathBuf::new, Path::to_path_buf)) + } + + fn encrypted_relative_path(&self) -> PathBuf { + let mut path = self.0.clone(); + let mut file_name = path + .file_name() + .expect("validated entry path has a file name") + .to_os_string(); + file_name.push(".gpg"); + path.set_file_name(file_name); + path + } +} + +impl fmt::Display for EntryPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.display().fmt(formatter) + } +} + +/// A validated logical directory path. The empty path denotes the store root. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct DirectoryPath(PathBuf); + +impl DirectoryPath { + pub fn root() -> Self { + Self(PathBuf::new()) + } + + pub fn parse(path: impl AsRef) -> Result { + let path = path.as_ref(); + Ok(Self(normalize_relative_path(path, true)?)) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + fn parent(&self) -> Option { + if self.0.as_os_str().is_empty() { + None + } else { + Some(Self( + self.0.parent().map_or_else(PathBuf::new, Path::to_path_buf), + )) + } + } +} + +impl fmt::Display for DirectoryPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0.as_os_str().is_empty() { + formatter.write_str(".") + } else { + self.0.display().fmt(formatter) + } + } +} + +/// Encrypted bytes read from or ready to be written to a `.gpg` entry. +#[derive(Clone, Eq, PartialEq)] +pub struct EncryptedEntry(Vec); + +impl EncryptedEntry { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + pub fn into_bytes(self) -> Vec { + self.0 + } +} + +impl fmt::Debug for EncryptedEntry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EncryptedEntry") + .field("length", &self.0.len()) + .finish() + } +} + +/// Decrypted bytes that are redacted in diagnostics and zeroed when dropped. +pub struct SecretBytes(Vec); + +impl SecretBytes { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + pub fn expose(&self) -> &[u8] { + &self.0 + } + + pub fn expose_mut(&mut self) -> &mut [u8] { + &mut self.0 + } +} + +impl fmt::Debug for SecretBytes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretBytes([REDACTED])") + } +} + +impl Drop for SecretBytes { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EntryRecord { + path: EntryPath, + ciphertext_length: u64, +} + +impl EntryRecord { + pub fn path(&self) -> &EntryPath { + &self.path + } + + pub fn ciphertext_length(&self) -> u64 { + self.ciphertext_length + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DirectoryRecord { + path: DirectoryPath, + has_recipient_file: bool, + has_recipient_signature: bool, +} + +impl DirectoryRecord { + pub fn path(&self) -> &DirectoryPath { + &self.path + } + + pub fn has_recipient_file(&self) -> bool { + self.has_recipient_file + } + + pub fn has_recipient_signature(&self) -> bool { + self.has_recipient_signature + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecipientPolicy { + directory: DirectoryPath, + recipients: PathBuf, + signature: Option, +} + +impl RecipientPolicy { + pub fn directory(&self) -> &DirectoryPath { + &self.directory + } + + pub fn recipients_path(&self) -> &Path { + &self.recipients + } + + pub fn signature_path(&self) -> Option<&Path> { + self.signature.as_deref() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitRepository { + work_tree: DirectoryPath, + git_directory: PathBuf, +} + +impl GitRepository { + pub fn work_tree(&self) -> &DirectoryPath { + &self.work_tree + } + + pub fn git_directory(&self) -> &Path { + &self.git_directory + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuxiliaryFile { + path: PathBuf, + length: u64, +} + +impl AuxiliaryFile { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn length(&self) -> u64 { + self.length + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResolvedObject<'a> { + Entry(&'a EntryRecord), + Directory(&'a DirectoryRecord), +} + +/// A deterministic inventory of the ordinary pass tree. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct RepositorySnapshot { + entries: BTreeMap, + directories: BTreeMap, + recipients: BTreeMap, + git_repositories: BTreeMap, + auxiliary_files: BTreeMap, + collisions: BTreeSet, +} + +impl RepositorySnapshot { + pub fn entries(&self) -> impl ExactSizeIterator { + self.entries.values() + } + + pub fn directories(&self) -> impl ExactSizeIterator { + self.directories.values() + } + + pub fn recipient_policies(&self) -> impl ExactSizeIterator { + self.recipients.values() + } + + pub fn git_repositories(&self) -> impl ExactSizeIterator { + self.git_repositories.values() + } + + pub fn auxiliary_files(&self) -> impl ExactSizeIterator { + self.auxiliary_files.values() + } + + pub fn collisions(&self) -> impl ExactSizeIterator { + self.collisions.iter() + } + + /// Resolve an upstream-style display path. A trailing slash explicitly selects a directory. + pub fn resolve(&self, input: &str) -> Result, RepositoryError> { + let directory_only = input.ends_with('/') || (cfg!(windows) && input.ends_with('\\')); + let trimmed = if directory_only { + input.trim_end_matches(['/', '\\']) + } else { + input + }; + let directory = DirectoryPath::parse(trimmed)?; + let directory_record = self.directories.get(&directory); + if directory_only || trimmed.is_empty() { + return directory_record + .map(ResolvedObject::Directory) + .ok_or_else(|| RepositoryError::NotFound { + path: directory.0.clone(), + }); + } + let entry = EntryPath::parse(trimmed)?; + let entry_record = self.entries.get(&entry); + match (entry_record, directory_record) { + (Some(_), Some(_)) => Err(RepositoryError::AmbiguousPath { + path: entry.0.clone(), + }), + (Some(entry), None) => Ok(ResolvedObject::Entry(entry)), + (None, Some(directory)) => Ok(ResolvedObject::Directory(directory)), + (None, None) => Err(RepositoryError::NotFound { + path: entry.0.clone(), + }), + } + } + + pub fn recipient_policy(&self, entry: &EntryPath) -> Option<&RecipientPolicy> { + let mut directory = entry.parent(); + loop { + if let Some(policy) = self.recipients.get(&directory) { + return Some(policy); + } + directory = directory.parent()?; + } + } + + pub fn git_repository(&self, entry: &EntryPath) -> Option<&GitRepository> { + let mut directory = entry.parent(); + loop { + if let Some(repository) = self.git_repositories.get(&directory) { + return Some(repository); + } + directory = directory.parent()?; + } + } +} + +/// An open repository whose filesystem access remains scoped to its root handle. +pub struct Repository { + root_path: PathBuf, + root: Dir, +} + +impl fmt::Debug for Repository { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Repository") + .field("root_path", &self.root_path) + .finish_non_exhaustive() + } +} + +impl Repository { + pub fn open(root: impl AsRef) -> Result { + let requested = root.as_ref(); + let metadata = fs::symlink_metadata(requested) + .map_err(|error| io_error("inspect repository root", requested, error))?; + if metadata.file_type().is_symlink() { + return Err(RepositoryError::Symlink { + path: requested.to_owned(), + }); + } + if !metadata.is_dir() { + return Err(RepositoryError::InvalidRoot { + path: requested.to_owned(), + }); + } + let root_path = fs::canonicalize(requested) + .map_err(|error| io_error("canonicalize repository root", requested, error))?; + let root = Dir::open_ambient_dir(&root_path, ambient_authority()) + .map_err(|error| io_error("open repository root", &root_path, error))?; + Ok(Self { root_path, root }) + } + + pub fn root_path(&self) -> &Path { + &self.root_path + } + + pub fn snapshot(&self) -> Result { + let mut snapshot = RepositorySnapshot::default(); + scan_directory(&self.root, &DirectoryPath::root(), &mut snapshot)?; + for entry in snapshot.entries.keys() { + if snapshot + .directories + .contains_key(&DirectoryPath(entry.0.clone())) + { + snapshot.collisions.insert(entry.0.clone()); + } + } + Ok(snapshot) + } + + pub fn read_entry(&self, path: &EntryPath) -> Result { + let (parent, file_name) = self.open_entry_parent(path)?; + let metadata = child_metadata(&parent, &file_name, &path.encrypted_relative_path())? + .ok_or_else(|| RepositoryError::NotFound { + path: path.0.clone(), + })?; + require_regular_file(metadata, &path.encrypted_relative_path())?; + let mut file = parent + .open(&file_name) + .map_err(|error| io_error("open encrypted entry", &path.0, error))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|error| io_error("read encrypted entry", &path.0, error))?; + Ok(EncryptedEntry(bytes)) + } + + /// Atomically replace an encrypted entry and durably commit its containing directory. + pub fn write_entry( + &self, + path: &EntryPath, + ciphertext: &EncryptedEntry, + ) -> Result<(), RepositoryError> { + self.write_entry_with_checkpoint(path, ciphertext, |_| Ok(())) + } + + fn write_entry_with_checkpoint( + &self, + path: &EntryPath, + ciphertext: &EncryptedEntry, + mut checkpoint: F, + ) -> Result<(), RepositoryError> + where + F: FnMut(WriteStage) -> Result<(), RepositoryError>, + { + let (parent, file_name, created) = self.create_entry_parent(path)?; + let encrypted_path = path.encrypted_relative_path(); + if let Err(error) = validate_write_target(&parent, &file_name, &path.0, &encrypted_path) { + return self.rollback_created(created, error); + } + + let result = (|| { + let mut temporary = TempFile::new(&parent) + .map_err(|error| io_error("create atomic entry", &path.0, error))?; + set_private_permissions(&temporary, &path.0)?; + temporary + .write_all(ciphertext.as_bytes()) + .map_err(|error| io_error("write atomic entry", &path.0, error))?; + checkpoint(WriteStage::BeforeTemporarySync)?; + temporary + .as_file() + .sync_all() + .map_err(|error| io_error("sync atomic entry", &path.0, error))?; + checkpoint(WriteStage::AfterTemporarySync)?; + temporary + .replace(&file_name) + .map_err(|error| io_error("replace encrypted entry", &path.0, error))?; + if checkpoint(WriteStage::AfterRename).is_err() { + return Err(RepositoryError::DurabilityUncertain { + path: path.0.clone(), + }); + } + sync_directory(&parent, &path.0).map_err(|_| RepositoryError::DurabilityUncertain { + path: path.0.clone(), + })?; + Ok(()) + })(); + + match result { + Ok(()) => Ok(()), + Err(error) if error.committed() => Err(error), + Err(error) => self.rollback_created(created, error), + } + } + + /// Remove a directory and then its empty ancestors, stopping at the root or first nonempty + /// directory. Recipient policy files and all other ordinary files naturally preserve a + /// directory because removal is attempted only with the filesystem's empty-directory + /// operation. + pub fn cleanup_empty_directories( + &self, + start: &DirectoryPath, + ) -> Result, RepositoryError> { + let mut current = Some(start.clone()); + let mut removed = Vec::new(); + while let Some(directory) = current { + if directory.0.as_os_str().is_empty() { + break; + } + let parent = directory.parent().unwrap_or_else(DirectoryPath::root); + let parent_handle = self.open_directory(&parent.0)?; + let name = directory + .0 + .file_name() + .expect("non-root directory has a file name"); + reject_entry_directory_collision(&parent_handle, name, &parent.0)?; + let metadata = child_metadata(&parent_handle, name, &directory.0)?; + let Some(metadata) = metadata else { + current = directory.parent(); + continue; + }; + require_directory(metadata, &directory.0)?; + match parent_handle.remove_dir(name) { + Ok(()) => { + sync_directory(&parent_handle, &parent.0).map_err(|_| { + RepositoryError::DurabilityUncertain { + path: directory.0.clone(), + } + })?; + removed.push(directory.clone()); + current = Some(parent); + } + Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => break, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + current = directory.parent() + } + Err(error) => { + return Err(io_error( + "remove empty repository directory", + &directory.0, + error, + )); + } + } + } + Ok(removed) + } + + fn open_entry_parent(&self, path: &EntryPath) -> Result<(Dir, OsString), RepositoryError> { + let mut directory = self + .root + .try_clone() + .map_err(|error| io_error("clone repository root", Path::new("."), error))?; + let mut relative = PathBuf::new(); + let mut components = path.0.components().peekable(); + while let Some(component) = components.next() { + let Component::Normal(name) = component else { + unreachable!("EntryPath is validated") + }; + if components.peek().is_none() { + let mut file_name = name.to_os_string(); + file_name.push(".gpg"); + return Ok((directory, file_name)); + } + reject_entry_directory_collision(&directory, name, &relative)?; + relative.push(name); + let metadata = child_metadata(&directory, name, &relative)?; + let Some(metadata) = metadata else { + return Err(RepositoryError::NotFound { path: relative }); + }; + require_directory(metadata, &relative)?; + directory = directory + .open_dir(name) + .map_err(|error| io_error("open entry parent", &relative, error))?; + } + unreachable!("EntryPath has at least one component") + } + + fn create_entry_parent( + &self, + path: &EntryPath, + ) -> Result<(Dir, OsString, Vec), RepositoryError> { + let mut directory = self + .root + .try_clone() + .map_err(|error| io_error("clone repository root", Path::new("."), error))?; + let mut relative = PathBuf::new(); + let mut created = Vec::new(); + let mut components = path.0.components().peekable(); + while let Some(component) = components.next() { + let Component::Normal(name) = component else { + unreachable!("EntryPath is validated") + }; + if components.peek().is_none() { + let mut file_name = name.to_os_string(); + file_name.push(".gpg"); + return Ok((directory, file_name, created)); + } + if let Err(error) = reject_entry_directory_collision(&directory, name, &relative) { + return self.rollback_created(created, error); + } + relative.push(name); + match child_metadata(&directory, name, &relative) { + Ok(Some(metadata)) => { + if let Err(error) = require_directory(metadata, &relative) { + return self.rollback_created(created, error); + } + } + Ok(None) => { + if let Err(error) = create_private_directory(&directory, name, &relative) { + return self.rollback_created(created, error); + } + created.push(relative.clone()); + if let Err(error) = sync_directory(&directory, &relative) { + return self.rollback_created(created, error); + } + } + Err(error) => return self.rollback_created(created, error), + } + match directory.open_dir(name) { + Ok(opened) => directory = opened, + Err(error) => { + return self.rollback_created( + created, + io_error("open entry directory", &relative, error), + ); + } + } + } + unreachable!("EntryPath has at least one component") + } + + fn rollback_created( + &self, + mut created: Vec, + original: RepositoryError, + ) -> Result { + while let Some(path) = created.pop() { + let parent = path.parent().unwrap_or_else(|| Path::new("")); + let parent_dir = self.open_directory(parent)?; + let name = path + .file_name() + .expect("created directory path has a file name"); + match parent_dir.remove_dir(name) { + Ok(()) => { + sync_directory(&parent_dir, parent)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(RepositoryError::RollbackFailed { + path, + source: error.kind(), + }); + } + } + } + Err(original) + } + + fn open_directory(&self, path: &Path) -> Result { + let path = normalize_relative_path(path, true)?; + let mut directory = self + .root + .try_clone() + .map_err(|error| io_error("clone repository directory", &path, error))?; + let mut relative = PathBuf::new(); + for component in path.components() { + let Component::Normal(name) = component else { + unreachable!("normalized directory path has only normal components") + }; + reject_entry_directory_collision(&directory, name, &relative)?; + relative.push(name); + let metadata = child_metadata(&directory, name, &relative)?.ok_or_else(|| { + RepositoryError::NotFound { + path: relative.clone(), + } + })?; + require_directory(metadata, &relative)?; + directory = directory + .open_dir(name) + .map_err(|error| io_error("open repository directory", &relative, error))?; + } + Ok(directory) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WriteStage { + BeforeTemporarySync, + AfterTemporarySync, + AfterRename, +} + +#[cfg(test)] +impl WriteStage { + fn fixture_id(self) -> &'static str { + match self { + Self::BeforeTemporarySync => "before-temp-fsync", + Self::AfterTemporarySync => "after-temp-fsync-before-rename", + Self::AfterRename => "after-rename-before-directory-fsync", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RepositoryError { + InvalidRoot { + path: PathBuf, + }, + InvalidPath { + path: PathBuf, + }, + Symlink { + path: PathBuf, + }, + UnsupportedFileType { + path: PathBuf, + }, + InvalidGitBoundary { + path: PathBuf, + }, + Collision { + path: PathBuf, + }, + AmbiguousPath { + path: PathBuf, + }, + NotFound { + path: PathBuf, + }, + Io { + operation: &'static str, + path: PathBuf, + source: io::ErrorKind, + }, + RollbackFailed { + path: PathBuf, + source: io::ErrorKind, + }, + DurabilityUncertain { + path: PathBuf, + }, +} + +impl RepositoryError { + fn committed(&self) -> bool { + matches!(self, Self::DurabilityUncertain { .. }) + } +} + +impl fmt::Display for RepositoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidRoot { path } => { + write!( + formatter, + "password-store root is not a directory: {}", + path.display() + ) + } + Self::InvalidPath { path } => write!( + formatter, + "password-store path must be non-absolute and contain no traversal: {}", + path.display() + ), + Self::Symlink { path } => { + write!( + formatter, + "symbolic links are forbidden in a password store: {}", + path.display() + ) + } + Self::UnsupportedFileType { path } => write!( + formatter, + "unsupported password-store file type: {}", + path.display() + ), + Self::InvalidGitBoundary { path } => write!( + formatter, + "password-store Git metadata must be a directory: {}", + path.display() + ), + Self::Collision { path } => write!( + formatter, + "password-store entry collides with a directory: {}", + path.display() + ), + Self::AmbiguousPath { path } => write!( + formatter, + "password-store path is both an entry and directory; add a trailing slash for the directory: {}", + path.display() + ), + Self::NotFound { path } => { + write!( + formatter, + "password-store object not found: {}", + path.display() + ) + } + Self::Io { + operation, + path, + source, + } => write!(formatter, "cannot {operation} {}: {source}", path.display()), + Self::RollbackFailed { path, source } => write!( + formatter, + "failed to roll back empty password-store directory {}: {source}", + path.display() + ), + Self::DurabilityUncertain { path } => write!( + formatter, + "repository mutation completed but its directory sync was interrupted: {}", + path.display() + ), + } + } +} + +impl Error for RepositoryError {} + +fn normalize_relative_path(path: &Path, allow_empty: bool) -> Result { + if (!allow_empty && path.as_os_str().is_empty()) || path.is_absolute() { + return Err(RepositoryError::InvalidPath { + path: path.to_owned(), + }); + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(name) if !name.is_empty() => normalized.push(name), + _ => { + return Err(RepositoryError::InvalidPath { + path: path.to_owned(), + }); + } + } + } + if !allow_empty && normalized.as_os_str().is_empty() { + return Err(RepositoryError::InvalidPath { + path: path.to_owned(), + }); + } + Ok(normalized) +} + +fn has_trailing_separator(path: &Path) -> bool { + path.as_os_str() + .as_encoded_bytes() + .last() + .is_some_and(|byte| *byte == b'/' || (cfg!(windows) && *byte == b'\\')) +} + +fn scan_directory( + directory: &Dir, + relative: &DirectoryPath, + snapshot: &mut RepositorySnapshot, +) -> Result<(), RepositoryError> { + snapshot + .directories + .entry(relative.clone()) + .or_insert_with(|| DirectoryRecord { + path: relative.clone(), + has_recipient_file: false, + has_recipient_signature: false, + }); + let mut entries = directory + .read_dir(".") + .map_err(|error| io_error("read repository directory", &relative.0, error))? + .collect::, _>>() + .map_err(|error| io_error("read repository entry", &relative.0, error))?; + entries.sort_by_key(cap_std::fs::DirEntry::file_name); + + for entry in entries { + let name = entry.file_name(); + let path = relative.0.join(&name); + let file_type = entry + .file_type() + .map_err(|error| io_error("inspect repository entry", &path, error))?; + if file_type.is_symlink() { + return Err(RepositoryError::Symlink { path }); + } + if file_type.is_dir() { + if name == OsStr::new(GIT_DIRECTORY) { + snapshot.git_repositories.insert( + relative.clone(), + GitRepository { + work_tree: relative.clone(), + git_directory: path, + }, + ); + continue; + } + let opened = entry + .open_dir() + .map_err(|error| io_error("open repository directory", &path, error))?; + scan_directory(&opened, &DirectoryPath(path), snapshot)?; + continue; + } + if !file_type.is_file() { + return Err(RepositoryError::UnsupportedFileType { path }); + } + let length = entry + .metadata() + .map_err(|error| io_error("inspect repository file", &path, error))? + .len(); + if name == OsStr::new(GIT_DIRECTORY) { + return Err(RepositoryError::InvalidGitBoundary { path }); + } + if name == OsStr::new(RECIPIENT_FILE) { + let directory_record = snapshot + .directories + .get_mut(relative) + .expect("current directory record exists"); + directory_record.has_recipient_file = true; + snapshot.recipients.insert( + relative.clone(), + RecipientPolicy { + directory: relative.clone(), + recipients: path, + signature: None, + }, + ); + continue; + } + if name == OsStr::new(RECIPIENT_SIGNATURE_FILE) { + let directory_record = snapshot + .directories + .get_mut(relative) + .expect("current directory record exists"); + directory_record.has_recipient_signature = true; + if let Some(policy) = snapshot.recipients.get_mut(relative) { + policy.signature = Some(path); + } else { + snapshot + .auxiliary_files + .insert(path.clone(), AuxiliaryFile { path, length }); + } + continue; + } + if Path::new(&name).extension() == Some(OsStr::new(ENTRY_EXTENSION)) { + let Some(stem) = Path::new(&name).file_stem().filter(|stem| !stem.is_empty()) else { + return Err(RepositoryError::InvalidPath { path }); + }; + let entry_path = EntryPath(relative.0.join(stem)); + snapshot.entries.insert( + entry_path.clone(), + EntryRecord { + path: entry_path, + ciphertext_length: length, + }, + ); + } else { + snapshot + .auxiliary_files + .insert(path.clone(), AuxiliaryFile { path, length }); + } + } + + if let Some(policy) = snapshot.recipients.get_mut(relative) { + let signature = relative.0.join(RECIPIENT_SIGNATURE_FILE); + if snapshot + .directories + .get(relative) + .is_some_and(DirectoryRecord::has_recipient_signature) + { + policy.signature = Some(signature.clone()); + snapshot.auxiliary_files.remove(&signature); + } + } + Ok(()) +} + +fn child_metadata( + directory: &Dir, + name: impl AsRef, + path: &Path, +) -> Result, RepositoryError> { + match directory.symlink_metadata(name) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + Err(RepositoryError::Symlink { + path: path.to_owned(), + }) + } else { + Ok(Some(metadata)) + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io_error("inspect repository object", path, error)), + } +} + +fn require_directory(metadata: cap_std::fs::Metadata, path: &Path) -> Result<(), RepositoryError> { + if metadata.is_dir() { + Ok(()) + } else if metadata.is_file() { + Err(RepositoryError::Collision { + path: path.to_owned(), + }) + } else { + Err(RepositoryError::UnsupportedFileType { + path: path.to_owned(), + }) + } +} + +fn require_regular_file( + metadata: cap_std::fs::Metadata, + path: &Path, +) -> Result<(), RepositoryError> { + if metadata.is_file() { + Ok(()) + } else if metadata.is_dir() { + Err(RepositoryError::Collision { + path: path.to_owned(), + }) + } else { + Err(RepositoryError::UnsupportedFileType { + path: path.to_owned(), + }) + } +} + +fn reject_entry_directory_collision( + directory: &Dir, + name: &OsStr, + parent: &Path, +) -> Result<(), RepositoryError> { + let mut encrypted_name = name.to_os_string(); + encrypted_name.push(".gpg"); + let logical = parent.join(name); + if child_metadata(directory, &encrypted_name, &logical)?.is_some() { + return Err(RepositoryError::Collision { path: logical }); + } + Ok(()) +} + +fn validate_write_target( + parent: &Dir, + file_name: &OsStr, + logical: &Path, + encrypted: &Path, +) -> Result<(), RepositoryError> { + if let Some(metadata) = + child_metadata(parent, logical.file_name().unwrap_or_default(), logical)? + { + if metadata.is_dir() { + return Err(RepositoryError::Collision { + path: logical.to_owned(), + }); + } + return Err(RepositoryError::UnsupportedFileType { + path: logical.to_owned(), + }); + } + if let Some(metadata) = child_metadata(parent, file_name, encrypted)? { + require_regular_file(metadata, encrypted)?; + } + Ok(()) +} + +#[cfg(unix)] +fn create_private_directory( + parent: &Dir, + name: &OsStr, + path: &Path, +) -> Result<(), RepositoryError> { + use cap_std::fs::{DirBuilder, DirBuilderExt as _}; + + let mut builder = DirBuilder::new(); + builder.mode(0o700); + parent + .create_dir_with(name, &builder) + .map_err(|error| io_error("create entry directory", path, error)) +} + +#[cfg(not(unix))] +fn create_private_directory( + parent: &Dir, + name: &OsStr, + path: &Path, +) -> Result<(), RepositoryError> { + parent + .create_dir(name) + .map_err(|error| io_error("create entry directory", path, error)) +} + +#[cfg(unix)] +fn set_private_permissions(temporary: &TempFile<'_>, path: &Path) -> Result<(), RepositoryError> { + use cap_std::fs::{Permissions, PermissionsExt as _}; + + temporary + .as_file() + .set_permissions(Permissions::from_mode(0o600)) + .map_err(|error| io_error("set encrypted entry permissions", path, error)) +} + +#[cfg(not(unix))] +fn set_private_permissions(_temporary: &TempFile<'_>, _path: &Path) -> Result<(), RepositoryError> { + Ok(()) +} + +fn sync_directory(directory: &Dir, path: &Path) -> Result<(), RepositoryError> { + directory + .open(".") + .and_then(|file| file.sync_all()) + .map_err(|error| io_error("sync repository directory", path, error)) +} + +fn io_error(operation: &'static str, path: &Path, error: io::Error) -> RepositoryError { + RepositoryError::Io { + operation, + path: path.to_owned(), + source: error.kind(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interruption_boundaries_never_expose_partial_entries() -> Result<(), Box> { + let stages = [ + WriteStage::BeforeTemporarySync, + WriteStage::AfterTemporarySync, + WriteStage::AfterRename, + ]; + let layouts_source = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility/layouts.toml"), + )?; + let layouts = toml::from_str::(&layouts_source)?; + let fixture_ids = layouts["interruption"] + .as_array() + .expect("interruption recipes") + .iter() + .filter_map(|recipe| recipe["id"].as_str()) + .collect::>(); + assert_eq!( + fixture_ids, + stages.iter().map(|stage| stage.fixture_id()).collect() + ); + + for stage in stages { + let temporary = tempfile::tempdir()?; + fs::create_dir(temporary.path().join("vault"))?; + let repository = Repository::open(temporary.path().join("vault"))?; + let path = EntryPath::parse("nested/entry")?; + repository.write_entry(&path, &EncryptedEntry::new(b"original".to_vec()))?; + + let result = repository.write_entry_with_checkpoint( + &path, + &EncryptedEntry::new(b"replacement".to_vec()), + |current| { + if current == stage { + Err(RepositoryError::Io { + operation: "simulated interruption", + path: path.0.clone(), + source: io::ErrorKind::Interrupted, + }) + } else { + Ok(()) + } + }, + ); + if stage == WriteStage::AfterRename { + assert!(matches!( + result, + Err(RepositoryError::DurabilityUncertain { .. }) + )); + } else { + assert!(matches!(result, Err(RepositoryError::Io { .. }))); + } + let actual = repository.read_entry(&path)?; + let expected = if stage == WriteStage::AfterRename { + b"replacement".as_slice() + } else { + b"original".as_slice() + }; + assert_eq!(actual.as_bytes(), expected); + let files = fs::read_dir(temporary.path().join("vault/nested"))? + .collect::, _>>()?; + assert_eq!(files.len(), 1, "temporary file leaked at {stage:?}"); + } + Ok(()) + } + + #[test] + fn post_rename_interruption_preserves_a_complete_new_tree() -> Result<(), Box> { + let temporary = tempfile::tempdir()?; + fs::create_dir(temporary.path().join("vault"))?; + let repository = Repository::open(temporary.path().join("vault"))?; + let path = EntryPath::parse("one/two/entry")?; + let error = repository + .write_entry_with_checkpoint( + &path, + &EncryptedEntry::new(b"complete ciphertext".to_vec()), + |stage| { + if stage == WriteStage::AfterRename { + Err(RepositoryError::Io { + operation: "simulated interruption", + path: path.0.clone(), + source: io::ErrorKind::Interrupted, + }) + } else { + Ok(()) + } + }, + ) + .expect_err("directory sync interruption"); + assert!(matches!(error, RepositoryError::DurabilityUncertain { .. })); + assert_eq!( + repository.read_entry(&path)?.as_bytes(), + b"complete ciphertext" + ); + assert!(temporary.path().join("vault/one/two").is_dir()); + Ok(()) + } + + #[test] + fn failed_new_entry_removes_created_empty_directories() -> Result<(), Box> { + let temporary = tempfile::tempdir()?; + fs::create_dir(temporary.path().join("vault"))?; + let repository = Repository::open(temporary.path().join("vault"))?; + let path = EntryPath::parse("one/two/entry")?; + let error = repository + .write_entry_with_checkpoint( + &path, + &EncryptedEntry::new(b"ciphertext".to_vec()), + |_| { + Err(RepositoryError::Io { + operation: "simulated interruption", + path: path.0.clone(), + source: io::ErrorKind::Interrupted, + }) + }, + ) + .expect_err("interrupted write"); + assert!(matches!(error, RepositoryError::Io { .. })); + assert!(!temporary.path().join("vault/one").exists()); + Ok(()) + } +} diff --git a/crates/storage/tests/repository_core.rs b/crates/storage/tests/repository_core.rs new file mode 100644 index 0000000..af975b8 --- /dev/null +++ b/crates/storage/tests/repository_core.rs @@ -0,0 +1,298 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::{error::Error, fs, path::Path}; + +use ironstorage::repository::{ + DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, ResolvedObject, + SecretBytes, +}; +use support::compatibility::{FixtureSet, TestResult}; + +#[test] +fn ordinary_nested_and_unicode_pass_trees_are_discovered() -> TestResult { + let fixtures = FixtureSet::load()?; + let store = fixtures.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let snapshot = repository.snapshot()?; + + let paths = snapshot + .entries() + .map(|entry| entry.path().as_path().to_owned()) + .collect::>(); + assert_eq!(paths.len(), 6); + assert!(paths.contains(&Path::new("email/personal").to_owned())); + assert!(paths.contains(&Path::new("unicode/咖啡").to_owned())); + assert_eq!(snapshot.collisions().len(), 0); + assert_eq!(snapshot.auxiliary_files().len(), 0); + assert_eq!(snapshot.recipient_policies().len(), 3); + + let unicode = EntryPath::parse("unicode/咖啡")?; + let encrypted = repository.read_entry(&unicode)?; + assert_eq!( + encrypted.as_bytes(), + fs::read(store.path().join("unicode/咖啡.gpg"))? + ); + + let personal = EntryPath::parse("email/personal")?; + let root_policy = snapshot + .recipient_policy(&personal) + .expect("root recipient policy"); + assert_eq!(root_policy.directory(), &DirectoryPath::root()); + assert_eq!(root_policy.recipients_path(), Path::new(".gpg-id")); + assert_eq!(root_policy.signature_path(), Some(Path::new(".gpg-id.sig"))); + + let service = EntryPath::parse("team/service")?; + let team_policy = snapshot + .recipient_policy(&service) + .expect("nested recipient policy"); + assert_eq!(team_policy.directory().as_path(), Path::new("team")); + assert_eq!(team_policy.recipients_path(), Path::new("team/.gpg-id")); + Ok(()) +} + +#[test] +fn innermost_git_repository_is_selected_without_scanning_git_objects() -> TestResult { + let fixtures = FixtureSet::load()?; + let store = fixtures.materialize_store("nested/outer")?; + copy_tree( + &fixtures.path("repositories/outer.git"), + &store.path().join(".git"), + )?; + copy_tree( + &fixtures.path("repositories/inner.git"), + &store.path().join("inner/.git"), + )?; + + let repository = Repository::open(store.path())?; + let snapshot = repository.snapshot()?; + assert_eq!(snapshot.entries().len(), 2); + assert_eq!(snapshot.git_repositories().len(), 2); + assert_eq!(snapshot.auxiliary_files().len(), 0); + + let root = snapshot + .git_repository(&EntryPath::parse("root-entry")?) + .expect("outer repository"); + assert_eq!(root.work_tree(), &DirectoryPath::root()); + assert_eq!(root.git_directory(), Path::new(".git")); + + let inner = snapshot + .git_repository(&EntryPath::parse("inner/nested-entry")?) + .expect("inner repository"); + assert_eq!(inner.work_tree().as_path(), Path::new("inner")); + assert_eq!(inner.git_directory(), Path::new("inner/.git")); + Ok(()) +} + +#[test] +fn entry_and_directory_ambiguity_requires_explicit_directory_syntax() -> TestResult { + let temporary = tempfile::tempdir()?; + fs::create_dir(temporary.path().join("ambiguous"))?; + fs::write(temporary.path().join("ambiguous.gpg"), b"ciphertext")?; + let repository = Repository::open(temporary.path())?; + let snapshot = repository.snapshot()?; + + assert_eq!( + snapshot.resolve("ambiguous").expect_err("ambiguous path"), + RepositoryError::AmbiguousPath { + path: Path::new("ambiguous").to_owned() + } + ); + assert!(matches!( + snapshot.resolve("ambiguous/")?, + ResolvedObject::Directory(directory) + if directory.path().as_path() == Path::new("ambiguous") + )); + assert_eq!( + snapshot.collisions().collect::>(), + [&Path::new("ambiguous").to_owned()] + ); + + let original = fs::read(temporary.path().join("ambiguous.gpg"))?; + assert!(matches!( + repository.write_entry( + &EntryPath::parse("ambiguous")?, + &EncryptedEntry::new(b"replacement".to_vec()) + ), + Err(RepositoryError::Collision { .. }) + )); + assert_eq!(fs::read(temporary.path().join("ambiguous.gpg"))?, original); + Ok(()) +} + +#[test] +fn hostile_logical_paths_are_rejected_before_filesystem_access() { + for path in ["", ".", "..", "../escape", "safe/../../escape", "/absolute"] { + assert!( + matches!( + EntryPath::parse(path), + Err(RepositoryError::InvalidPath { .. }) + ), + "hostile path was accepted: {path}" + ); + } + assert!(matches!( + EntryPath::parse("directory/"), + Err(RepositoryError::InvalidPath { .. }) + )); + assert_eq!( + EntryPath::parse("unicode/咖啡") + .expect("valid Unicode path") + .as_path(), + Path::new("unicode/咖啡") + ); + assert_eq!( + DirectoryPath::parse("nested//directory/") + .expect("normalized directory") + .as_path(), + Path::new("nested/directory") + ); +} + +#[cfg(unix)] +#[test] +fn symlinks_and_unsupported_file_types_are_rejected() -> TestResult { + use std::os::unix::fs::symlink; + + use rustix::fs::{CWD, Mode, mkfifoat}; + + let temporary = tempfile::tempdir()?; + let vault = temporary.path().join("vault"); + fs::create_dir(&vault)?; + fs::write(temporary.path().join("outside.gpg"), b"outside")?; + symlink("../outside.gpg", vault.join("escape.gpg"))?; + let repository = Repository::open(&vault)?; + assert!(matches!( + repository.snapshot(), + Err(RepositoryError::Symlink { ref path }) if path == Path::new("escape.gpg") + )); + assert!(matches!( + repository.read_entry(&EntryPath::parse("escape")?), + Err(RepositoryError::Symlink { .. }) + )); + assert_eq!(fs::read(temporary.path().join("outside.gpg"))?, b"outside"); + + fs::remove_file(vault.join("escape.gpg"))?; + mkfifoat( + CWD, + vault.join("unsupported.gpg"), + Mode::from_raw_mode(0o600), + )?; + assert!(matches!( + repository.snapshot(), + Err(RepositoryError::UnsupportedFileType { ref path }) + if path == Path::new("unsupported.gpg") + )); + Ok(()) +} + +#[test] +fn atomic_entry_replacement_has_private_permissions_and_complete_contents() -> TestResult { + let temporary = tempfile::tempdir()?; + let repository = Repository::open(temporary.path())?; + let path = EntryPath::parse("new/deep/entry")?; + let first = EncryptedEntry::new(vec![0x11; 16 * 1024]); + let second = EncryptedEntry::new(vec![0x22; 31 * 1024]); + repository.write_entry(&path, &first)?; + assert_eq!(repository.read_entry(&path)?, first); + repository.write_entry(&path, &second)?; + assert_eq!(repository.read_entry(&path)?, second); + assert_eq!( + fs::read_dir(temporary.path().join("new/deep"))? + .collect::, _>>()? + .len(), + 1 + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + let mode = fs::metadata(temporary.path().join("new/deep/entry.gpg"))? + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + for directory in ["new", "new/deep"] { + let mode = fs::metadata(temporary.path().join(directory))? + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + } + } + Ok(()) +} + +#[test] +fn empty_directory_cleanup_stops_at_content_and_recipient_boundaries() -> TestResult { + let temporary = tempfile::tempdir()?; + fs::create_dir_all(temporary.path().join("empty/one/two"))?; + fs::create_dir_all(temporary.path().join("policy/empty"))?; + fs::write(temporary.path().join("policy/.gpg-id"), b"ALICE\n")?; + let repository = Repository::open(temporary.path())?; + + let removed = repository.cleanup_empty_directories(&DirectoryPath::parse("empty/one/two")?)?; + assert_eq!( + removed + .iter() + .map(DirectoryPath::as_path) + .collect::>(), + [ + Path::new("empty/one/two"), + Path::new("empty/one"), + Path::new("empty") + ] + ); + assert!(!temporary.path().join("empty").exists()); + + let removed = repository.cleanup_empty_directories(&DirectoryPath::parse("policy/empty")?)?; + assert_eq!(removed.len(), 1); + assert!(temporary.path().join("policy/.gpg-id").is_file()); + Ok(()) +} + +#[test] +fn auxiliary_regular_files_are_inventory_objects_but_git_files_are_rejected() -> TestResult { + let temporary = tempfile::tempdir()?; + fs::write(temporary.path().join("README"), b"ordinary file")?; + let repository = Repository::open(temporary.path())?; + let snapshot = repository.snapshot()?; + let auxiliary = snapshot.auxiliary_files().collect::>(); + assert_eq!(auxiliary.len(), 1); + assert_eq!(auxiliary[0].path(), Path::new("README")); + assert_eq!(auxiliary[0].length(), 13); + + fs::write(temporary.path().join(".git"), b"gitdir: ../outside")?; + assert!(matches!( + repository.snapshot(), + Err(RepositoryError::InvalidGitBoundary { .. }) + )); + Ok(()) +} + +#[test] +fn secret_bytes_never_render_their_contents() { + let mut secret = SecretBytes::new(b"fixture secret".to_vec()); + assert_eq!(secret.expose(), b"fixture secret"); + secret.expose_mut()[0] = b'F'; + let rendered = format!("{secret:?}"); + assert_eq!(rendered, "SecretBytes([REDACTED])"); + assert!(!rendered.contains("fixture")); +} + +fn copy_tree(source: &Path, destination: &Path) -> Result<(), Box> { + fs::create_dir_all(destination)?; + let mut entries = fs::read_dir(source)?.collect::, _>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let target = destination.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_tree(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), target)?; + } + } + Ok(()) +} diff --git a/docs/repository-core.md b/docs/repository-core.md new file mode 100644 index 0000000..b34be15 --- /dev/null +++ b/docs/repository-core.md @@ -0,0 +1,35 @@ +# Password-store repository core + +`crates/storage::repository` maps logical entry names directly to the upstream +password-store layout: `team/service` is stored as `team/service.gpg` and no +IronStorage-specific metadata is added. Discovery inventories ordinary +directories, encrypted entries, `.gpg-id`, `.gpg-id.sig`, auxiliary regular +files, and `.git` directory boundaries. Git contents are not mistaken for +password-store objects, and the innermost boundary applies to a nested entry. + +Logical paths are relative component sequences. Absolute paths, `.` and `..`, +empty entry paths, and entry paths with a trailing slash are rejected before +filesystem access. A trailing slash in display-path resolution explicitly +selects a directory. If both `name.gpg` and `name/` exist, the unadorned name is +reported as ambiguous and mutations through the collision are rejected. + +The repository is opened as a capability-scoped directory. All subsequent +discovery, reads, temporary files, renames, and cleanup remain relative to that +handle. Symlinks are rejected, and the capability layer prevents a raced link +from escaping the selected vault. FIFOs, sockets, devices, and other special +file types are also rejected; unrelated regular files remain visible as +auxiliary repository objects. + +Encrypted replacements are written to a temporary file in the destination +directory, assigned private permissions on Unix, flushed to stable storage, +atomically renamed over the destination, and followed by a directory sync. +Before the rename, any error leaves the old entry intact and removes newly +created empty directories. After the rename, the only possible observable +state is the complete new entry; an interrupted directory sync is reported as +durability-uncertain rather than pretending that rollback occurred. Empty +ancestor cleanup uses directory-only removal and therefore stops at the store +root, content, or a recipient-policy boundary. + +Decrypted bytes use `SecretBytes`, which takes ownership without an additional +copy, redacts `Debug`, exposes data only through explicit methods, and zeroes +its allocation on drop.