Implement safe password-store repository core (#3)
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user