Files
IronStorage/crates/storage/src/repository.rs
2026-08-09 23:54:10 +00:00

1421 lines
47 KiB
Rust

//! 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<Path>) -> Result<Self, RepositoryError> {
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
}
pub fn parent_directory(&self) -> DirectoryPath {
self.parent()
}
fn parent(&self) -> DirectoryPath {
DirectoryPath(self.0.parent().map_or_else(PathBuf::new, Path::to_path_buf))
}
pub 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<Path>) -> Result<Self, RepositoryError> {
let path = path.as_ref();
Ok(Self(normalize_relative_path(path, true)?))
}
pub fn as_path(&self) -> &Path {
&self.0
}
fn parent(&self) -> Option<Self> {
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<u8>);
impl EncryptedEntry {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn into_bytes(self) -> Vec<u8> {
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<u8>);
impl SecretBytes {
pub fn new(bytes: Vec<u8>) -> 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<PathBuf>,
}
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<EntryPath, EntryRecord>,
directories: BTreeMap<DirectoryPath, DirectoryRecord>,
recipients: BTreeMap<DirectoryPath, RecipientPolicy>,
git_repositories: BTreeMap<DirectoryPath, GitRepository>,
auxiliary_files: BTreeMap<PathBuf, AuxiliaryFile>,
collisions: BTreeSet<PathBuf>,
}
impl RepositorySnapshot {
pub fn entries(&self) -> impl ExactSizeIterator<Item = &EntryRecord> {
self.entries.values()
}
pub fn directories(&self) -> impl ExactSizeIterator<Item = &DirectoryRecord> {
self.directories.values()
}
pub fn recipient_policies(&self) -> impl ExactSizeIterator<Item = &RecipientPolicy> {
self.recipients.values()
}
pub fn git_repositories(&self) -> impl ExactSizeIterator<Item = &GitRepository> {
self.git_repositories.values()
}
pub fn auxiliary_files(&self) -> impl ExactSizeIterator<Item = &AuxiliaryFile> {
self.auxiliary_files.values()
}
pub fn collisions(&self) -> impl ExactSizeIterator<Item = &PathBuf> {
self.collisions.iter()
}
/// Resolve an upstream-style display path. A trailing slash explicitly selects a directory.
pub fn resolve(&self, input: &str) -> Result<ResolvedObject<'_>, 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 recipient_policy_at(&self, directory: &DirectoryPath) -> Option<&RecipientPolicy> {
self.recipients.get(directory)
}
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<Path>) -> Result<Self, RepositoryError> {
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<RepositorySnapshot, RepositoryError> {
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<EncryptedEntry, RepositoryError> {
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))
}
pub(crate) fn read_policy_file(
&self,
directory: &DirectoryPath,
signature: bool,
) -> Result<Option<Vec<u8>>, RepositoryError> {
let opened = match self.open_directory(&directory.0) {
Ok(opened) => opened,
Err(RepositoryError::NotFound { .. }) => return Ok(None),
Err(error) => return Err(error),
};
let name = if signature {
RECIPIENT_SIGNATURE_FILE
} else {
RECIPIENT_FILE
};
let path = directory.0.join(name);
let Some(metadata) = child_metadata(&opened, name, &path)? else {
return Ok(None);
};
require_regular_file(metadata, &path)?;
let mut file = opened
.open(name)
.map_err(|error| io_error("open recipient policy", &path, error))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|error| io_error("read recipient policy", &path, error))?;
Ok(Some(bytes))
}
pub(crate) fn replace_policy_file(
&self,
directory: &DirectoryPath,
signature: bool,
contents: Option<&[u8]>,
) -> Result<(), RepositoryError> {
let name = if signature {
RECIPIENT_SIGNATURE_FILE
} else {
RECIPIENT_FILE
};
let path = directory.0.join(name);
let (opened, created) = if contents.is_some() {
self.create_directory_path(directory)?
} else {
match self.open_directory(&directory.0) {
Ok(opened) => (opened, Vec::new()),
Err(RepositoryError::NotFound { .. }) => return Ok(()),
Err(error) => return Err(error),
}
};
let result = (|| {
if let Some(contents) = contents {
if let Some(metadata) = child_metadata(&opened, name, &path)? {
require_regular_file(metadata, &path)?;
}
let mut temporary = TempFile::new(&opened)
.map_err(|error| io_error("create atomic recipient policy", &path, error))?;
set_private_permissions(&temporary, &path)?;
temporary
.write_all(contents)
.map_err(|error| io_error("write atomic recipient policy", &path, error))?;
temporary
.as_file()
.sync_all()
.map_err(|error| io_error("sync atomic recipient policy", &path, error))?;
temporary
.replace(name)
.map_err(|error| io_error("replace recipient policy", &path, error))?;
sync_directory(&opened, &directory.0)
.map_err(|_| RepositoryError::DurabilityUncertain { path: path.clone() })
} else {
let Some(metadata) = child_metadata(&opened, name, &path)? else {
return Ok(());
};
require_regular_file(metadata, &path)?;
opened
.remove_file(name)
.map_err(|error| io_error("remove recipient policy", &path, error))?;
sync_directory(&opened, &directory.0)
.map_err(|_| RepositoryError::DurabilityUncertain { path: path.clone() })
}
})();
match result {
Ok(()) => Ok(()),
Err(error) if error.committed() => Err(error),
Err(error) => self.rollback_created(created, error),
}
}
/// 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(()))
}
pub fn remove_entry(&self, path: &EntryPath) -> Result<EncryptedEntry, RepositoryError> {
let original = self.read_entry(path)?;
let (parent, file_name) = self.open_entry_parent(path)?;
parent
.remove_file(&file_name)
.map_err(|error| io_error("remove encrypted entry", &path.0, error))?;
sync_directory(&parent, &path.parent().0).map_err(|_| {
RepositoryError::DurabilityUncertain {
path: path.0.clone(),
}
})?;
Ok(original)
}
fn write_entry_with_checkpoint<F>(
&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<Vec<DirectoryPath>, 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)
}
pub(crate) fn ensure_directory(
&self,
path: &DirectoryPath,
) -> Result<Vec<DirectoryPath>, RepositoryError> {
let (_, created) = self.create_directory_path(path)?;
Ok(created.into_iter().map(DirectoryPath).collect())
}
pub(crate) fn remove_empty_directory(
&self,
directory: &DirectoryPath,
) -> Result<bool, RepositoryError> {
let Some(parent) = directory.parent() else {
return Ok(false);
};
let parent_handle = self.open_directory(parent.as_path())?;
let name = directory
.as_path()
.file_name()
.expect("non-root directory has a file name");
reject_entry_directory_collision(&parent_handle, name, parent.as_path())?;
let Some(metadata) = child_metadata(&parent_handle, name, directory.as_path())? else {
return Ok(false);
};
require_directory(metadata, directory.as_path())?;
match parent_handle.remove_dir(name) {
Ok(()) => {
sync_directory(&parent_handle, parent.as_path()).map_err(|_| {
RepositoryError::DurabilityUncertain {
path: directory.as_path().to_owned(),
}
})?;
Ok(true)
}
Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => Ok(false),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(io_error(
"remove empty repository directory",
directory.as_path(),
error,
)),
}
}
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<PathBuf>), 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 create_directory_path(
&self,
path: &DirectoryPath,
) -> Result<(Dir, Vec<PathBuf>), 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();
for component in path.0.components() {
let Component::Normal(name) = component else {
unreachable!("DirectoryPath is validated")
};
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 recipient policy directory", &relative, error),
);
}
}
}
Ok((directory, created))
}
fn rollback_created<T>(
&self,
mut created: Vec<PathBuf>,
original: RepositoryError,
) -> Result<T, RepositoryError> {
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<Dir, RepositoryError> {
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<PathBuf, RepositoryError> {
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::<Result<Vec<_>, _>>()
.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: &Path,
) -> Result<Option<cap_std::fs::Metadata>, 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<dyn Error>> {
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::<toml::Value>(&layouts_source)?;
let fixture_ids = layouts["interruption"]
.as_array()
.expect("interruption recipes")
.iter()
.filter_map(|recipe| recipe["id"].as_str())
.collect::<BTreeSet<_>>();
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::<Result<Vec<_>, _>>()?;
assert_eq!(files.len(), 1, "temporary file leaked at {stage:?}");
}
Ok(())
}
#[test]
fn post_rename_interruption_preserves_a_complete_new_tree() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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(())
}
}