1421 lines
47 KiB
Rust
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(())
|
|
}
|
|
}
|