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

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(())
}