//! 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) -> Result { 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) -> Result { let path = path.as_ref(); Ok(Self(normalize_relative_path(path, true)?)) } pub fn as_path(&self) -> &Path { &self.0 } fn parent(&self) -> Option { 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); impl EncryptedEntry { pub fn new(bytes: Vec) -> Self { Self(bytes) } pub fn as_bytes(&self) -> &[u8] { &self.0 } pub fn into_bytes(self) -> Vec { 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); impl SecretBytes { pub fn new(bytes: Vec) -> 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, } 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, directories: BTreeMap, recipients: BTreeMap, git_repositories: BTreeMap, auxiliary_files: BTreeMap, collisions: BTreeSet, } impl RepositorySnapshot { pub fn entries(&self) -> impl ExactSizeIterator { self.entries.values() } pub fn directories(&self) -> impl ExactSizeIterator { self.directories.values() } pub fn recipient_policies(&self) -> impl ExactSizeIterator { self.recipients.values() } pub fn git_repositories(&self) -> impl ExactSizeIterator { self.git_repositories.values() } pub fn auxiliary_files(&self) -> impl ExactSizeIterator { self.auxiliary_files.values() } pub fn collisions(&self) -> impl ExactSizeIterator { self.collisions.iter() } /// Resolve an upstream-style display path. A trailing slash explicitly selects a directory. pub fn resolve(&self, input: &str) -> Result, 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) -> Result { 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 { 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 { 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>, 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 { 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( &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, 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, 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 { 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), 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), 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( &self, mut created: Vec, original: RepositoryError, ) -> Result { 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 { 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 { 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::, _>>() .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, ) -> Result, 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> { 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::(&layouts_source)?; let fixture_ids = layouts["interruption"] .as_array() .expect("interruption recipes") .iter() .filter_map(|recipe| recipe["id"].as_str()) .collect::>(); 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::, _>>()?; assert_eq!(files.len(), 1, "temporary file leaked at {stage:?}"); } Ok(()) } #[test] fn post_rename_interruption_preserves_a_complete_new_tree() -> Result<(), Box> { 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> { 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(()) } }