Implement safe password-store repository core (#3)
This commit is contained in:
186
Cargo.lock
generated
186
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
1217
crates/storage/src/repository.rs
Normal file
1217
crates/storage/src/repository.rs
Normal file
File diff suppressed because it is too large
Load Diff
298
crates/storage/tests/repository_core.rs
Normal file
298
crates/storage/tests/repository_core.rs
Normal file
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>(),
|
||||
[&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::<Result<Vec<_>, _>>()?
|
||||
.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::<Vec<_>>(),
|
||||
[
|
||||
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::<Vec<_>>();
|
||||
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<dyn Error>> {
|
||||
fs::create_dir_all(destination)?;
|
||||
let mut entries = fs::read_dir(source)?.collect::<Result<Vec<_>, _>>()?;
|
||||
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(())
|
||||
}
|
||||
35
docs/repository-core.md
Normal file
35
docs/repository-core.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user