Implement safe password-store repository core (#3)

This commit is contained in:
Hermes Agent
2026-08-09 21:41:31 +00:00
parent 2dff2d5254
commit 8e4c9390a0
9 changed files with 1732 additions and 21 deletions

186
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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"

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

@@ -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";

File diff suppressed because it is too large Load Diff

View 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
View 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.