Implement audited findings
Some checks failed
Dependency security audit / rustsec (push) Failing after 3s
Some checks failed
Dependency security audit / rustsec (push) Failing after 3s
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -5415,6 +5415,7 @@ dependencies = [
|
|||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
"rqrr",
|
"rqrr",
|
||||||
"russh",
|
"russh",
|
||||||
|
"rustls",
|
||||||
"secret-service",
|
"secret-service",
|
||||||
"security-framework 3.7.0",
|
"security-framework 3.7.0",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ qrcode = { version = "0.14", default-features = false }
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
regex = "1.13"
|
regex = "1.13"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
|
||||||
|
rustls = { version = "0.23", default-features = false }
|
||||||
rfd = { version = "0.17", default-features = false }
|
rfd = { version = "0.17", default-features = false }
|
||||||
rqrr = { version = "0.10", default-features = false }
|
rqrr = { version = "0.10", default-features = false }
|
||||||
rpassword = "7.5"
|
rpassword = "7.5"
|
||||||
|
|||||||
91
FINDINGS.md
91
FINDINGS.md
@@ -1,91 +0,0 @@
|
|||||||
# Implementation Findings
|
|
||||||
|
|
||||||
Confirmed, high-impact work only. Keep changes inside `crates/storage` unless a
|
|
||||||
finding explicitly names another crate. Prefer the smallest shared fix and add
|
|
||||||
one focused regression test per behavior change.
|
|
||||||
|
|
||||||
## 1. Preserve operation and rollback failures
|
|
||||||
|
|
||||||
Rollback code can replace the original failure with a restoration failure:
|
|
||||||
|
|
||||||
- `Repository::rollback_created` in `crates/storage/src/repository.rs`
|
|
||||||
- `TreeMutator::remove` and `TreeMutator::transfer` in
|
|
||||||
`crates/storage/src/mutation.rs`
|
|
||||||
|
|
||||||
Return an error containing both failures, following the existing
|
|
||||||
`WriteError::RollbackFailed { operation, rollback }` pattern. Cover failures
|
|
||||||
from reopening/syncing directories and from all source/destination restoration
|
|
||||||
steps. Never report successful rollback if any restoration step failed.
|
|
||||||
|
|
||||||
Acceptance: focused tests prove that the original operation error and rollback
|
|
||||||
error are both retained.
|
|
||||||
|
|
||||||
## 2. Use one snapshot per tree mutation
|
|
||||||
|
|
||||||
Directory remove/move/copy currently scan the repository up to five times via
|
|
||||||
`snapshot`, `reject_unmanaged`, `source_entries`, `source_policies`, and
|
|
||||||
`source_directories` in `crates/storage/src/mutation.rs`.
|
|
||||||
|
|
||||||
Take one snapshot at operation start and pass it to those helpers. All
|
|
||||||
resolution, validation, and source selection for the transaction must use that
|
|
||||||
same snapshot.
|
|
||||||
|
|
||||||
Acceptance: existing mutation tests pass and a focused test or instrumentation
|
|
||||||
proves one snapshot is taken for a directory operation.
|
|
||||||
|
|
||||||
## 3. Remove string-based HTTPS Git error classification
|
|
||||||
|
|
||||||
`map_reqwest_error` and `fetch_embedded_https` in
|
|
||||||
`crates/storage/src/git.rs` classify authentication, TLS, and network failures
|
|
||||||
using substrings from third-party error messages.
|
|
||||||
|
|
||||||
Use structured `reqwest` status/connect/timeout information, error sources, and
|
|
||||||
matchable `gix` variants wherever available. Classify TLS only when a structured
|
|
||||||
source identifies it. When `gix` has erased the underlying status or cause,
|
|
||||||
return a generic error with a bounded diagnostic instead of guessing a specific
|
|
||||||
class from rendered text.
|
|
||||||
|
|
||||||
Acceptance: tests classify direct HTTP 401/403 and structured connection,
|
|
||||||
timeout, and TLS failures without depending on English error text. A `gix`
|
|
||||||
failure without a structured cause remains generic and its diagnostic is
|
|
||||||
bounded.
|
|
||||||
|
|
||||||
## 4. Make config replacement durable
|
|
||||||
|
|
||||||
`Config::persist` and `persist_new` in `crates/storage/src/config.rs` sync the
|
|
||||||
file but not the parent directory after replacement/creation.
|
|
||||||
|
|
||||||
Sync the parent directory after the atomic rename, reusing the repository's
|
|
||||||
existing durability pattern where practical. Return a distinct durability error
|
|
||||||
if the rename succeeds but the directory sync fails.
|
|
||||||
|
|
||||||
Acceptance: focused tests cover the post-rename sync failure boundary. Do not
|
|
||||||
add config locking unless concurrent writers are an explicit supported use case.
|
|
||||||
|
|
||||||
## 5. Reap completed TUI task handles
|
|
||||||
|
|
||||||
`AsyncExecutor::submit` in `apps/tui/src/runtime.rs` retains every `JoinHandle`
|
|
||||||
until executor drop.
|
|
||||||
|
|
||||||
Remove and join finished handles during normal executor activity. Keep the
|
|
||||||
current thread-per-task design; do not add a thread pool without measured need.
|
|
||||||
|
|
||||||
Acceptance: a focused test submits and completes repeated tasks and verifies
|
|
||||||
the retained handle count does not grow without bound.
|
|
||||||
|
|
||||||
## Required checks
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo fmt --all -- --check
|
|
||||||
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo test --workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
## Explicitly deferred
|
|
||||||
|
|
||||||
Do not spend this pass on `thiserror`, broad error-enum redesign, Git options
|
|
||||||
structs, frontend file splitting, generic generation tracking, public struct
|
|
||||||
fields, UniFFI mirror removal, Git performance caching, config locking, or a
|
|
||||||
thread pool. Revisit only with a concrete bug, compatibility need, or measured
|
|
||||||
performance problem.
|
|
||||||
@@ -210,6 +210,7 @@ impl AsyncExecutor {
|
|||||||
where
|
where
|
||||||
F: FnOnce() -> Result<AsyncPayload, String> + Send + 'static,
|
F: FnOnce() -> Result<AsyncPayload, String> + Send + 'static,
|
||||||
{
|
{
|
||||||
|
self.reap_finished();
|
||||||
let sender = self.sender.clone();
|
let sender = self.sender.clone();
|
||||||
let task = thread::spawn(move || {
|
let task = thread::spawn(move || {
|
||||||
let _ignored = sender.send(AsyncResult {
|
let _ignored = sender.send(AsyncResult {
|
||||||
@@ -223,6 +224,34 @@ impl AsyncExecutor {
|
|||||||
.push(task);
|
.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reap_finished(&self) {
|
||||||
|
let mut tasks = self
|
||||||
|
.tasks
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let mut completed = Vec::new();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < tasks.len() {
|
||||||
|
if tasks[index].is_finished() {
|
||||||
|
completed.push(tasks.swap_remove(index));
|
||||||
|
} else {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(tasks);
|
||||||
|
for task in completed {
|
||||||
|
let _ignored = task.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn retained_task_count(&self) -> usize {
|
||||||
|
self.tasks
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.len()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn progress_reporter(
|
pub fn progress_reporter(
|
||||||
&self,
|
&self,
|
||||||
token: RequestToken,
|
token: RequestToken,
|
||||||
@@ -253,6 +282,7 @@ impl AsyncExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
|
pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
|
||||||
|
self.reap_finished();
|
||||||
self.receiver.try_iter()
|
self.receiver.try_iter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,4 +343,28 @@ mod tests {
|
|||||||
.expect("worker result");
|
.expect("worker result");
|
||||||
assert_eq!(app.apply_result(result), ResultDisposition::Applied);
|
assert_eq!(app.apply_result(result), ResultDisposition::Applied);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_task_handles_are_reaped_during_normal_activity() {
|
||||||
|
let executor = AsyncExecutor::new();
|
||||||
|
let mut app = App::new();
|
||||||
|
let submitted = 256;
|
||||||
|
for _ in 0..submitted {
|
||||||
|
let token = app.begin_request();
|
||||||
|
executor.submit(token, || Err("complete".to_owned()));
|
||||||
|
}
|
||||||
|
for _ in 0..submitted {
|
||||||
|
executor
|
||||||
|
.receiver
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.expect("worker result");
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||||
|
while executor.retained_task_count() != 0 && std::time::Instant::now() < deadline {
|
||||||
|
let _ = executor.drain().count();
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
assert_eq!(executor.retained_task_count(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ full = [
|
|||||||
"dep:rand",
|
"dep:rand",
|
||||||
"dep:regex",
|
"dep:regex",
|
||||||
"dep:reqwest",
|
"dep:reqwest",
|
||||||
|
"dep:rustls",
|
||||||
"dep:rqrr",
|
"dep:rqrr",
|
||||||
"dep:secret-service",
|
"dep:secret-service",
|
||||||
"dep:security-framework",
|
"dep:security-framework",
|
||||||
@@ -61,6 +62,7 @@ qrcode = { workspace = true, optional = true }
|
|||||||
rand = { workspace = true, optional = true }
|
rand = { workspace = true, optional = true }
|
||||||
regex = { workspace = true, optional = true }
|
regex = { workspace = true, optional = true }
|
||||||
reqwest = { workspace = true, optional = true }
|
reqwest = { workspace = true, optional = true }
|
||||||
|
rustls = { workspace = true, optional = true }
|
||||||
rqrr = { workspace = true, optional = true }
|
rqrr = { workspace = true, optional = true }
|
||||||
russh = { workspace = true, optional = true }
|
russh = { workspace = true, optional = true }
|
||||||
serde = { workspace = true, optional = true }
|
serde = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::{
|
|||||||
error::Error,
|
error::Error,
|
||||||
fmt, fs,
|
fmt, fs,
|
||||||
fs::OpenOptions,
|
fs::OpenOptions,
|
||||||
io::Write,
|
io::{self, Write},
|
||||||
path::{Component, Path, PathBuf},
|
path::{Component, Path, PathBuf},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
@@ -638,6 +638,13 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn persist(&self) -> Result<(), ConfigError> {
|
pub(crate) fn persist(&self) -> Result<(), ConfigError> {
|
||||||
|
self.persist_with_directory_sync(sync_config_directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_with_directory_sync(
|
||||||
|
&self,
|
||||||
|
sync_parent: impl FnOnce(&Dir) -> io::Result<()>,
|
||||||
|
) -> Result<(), ConfigError> {
|
||||||
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
|
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
|
||||||
path: self.source.clone(),
|
path: self.source.clone(),
|
||||||
})?;
|
})?;
|
||||||
@@ -668,10 +675,20 @@ impl Config {
|
|||||||
.and_then(|()| temporary.replace(name))
|
.and_then(|()| temporary.replace(name))
|
||||||
.map_err(|_| ConfigError::Write {
|
.map_err(|_| ConfigError::Write {
|
||||||
path: self.source.clone(),
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
sync_parent(&directory).map_err(|_| ConfigError::DurabilityUncertain {
|
||||||
|
path: self.source.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_new(&self) -> Result<(), ConfigError> {
|
fn persist_new(&self) -> Result<(), ConfigError> {
|
||||||
|
self.persist_new_with_directory_sync(sync_config_directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_new_with_directory_sync(
|
||||||
|
&self,
|
||||||
|
sync_parent: impl FnOnce(&Dir) -> io::Result<()>,
|
||||||
|
) -> Result<(), ConfigError> {
|
||||||
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
|
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
|
||||||
path: self.source.clone(),
|
path: self.source.clone(),
|
||||||
})?;
|
})?;
|
||||||
@@ -689,6 +706,10 @@ impl Config {
|
|||||||
set_private_directory(parent).map_err(|_| ConfigError::Write {
|
set_private_directory(parent).map_err(|_| ConfigError::Write {
|
||||||
path: self.source.clone(),
|
path: self.source.clone(),
|
||||||
})?;
|
})?;
|
||||||
|
let directory =
|
||||||
|
Dir::open_ambient_dir(parent, ambient_authority()).map_err(|_| ConfigError::Write {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
|
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
|
||||||
path: self.source.clone(),
|
path: self.source.clone(),
|
||||||
})?;
|
})?;
|
||||||
@@ -725,7 +746,9 @@ impl Config {
|
|||||||
drop(temporary_file);
|
drop(temporary_file);
|
||||||
let _ = fs::remove_file(&temporary_path);
|
let _ = fs::remove_file(&temporary_path);
|
||||||
match installed {
|
match installed {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => sync_parent(&directory).map_err(|_| ConfigError::DurabilityUncertain {
|
||||||
|
path: self.source.clone(),
|
||||||
|
}),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
Err(ConfigError::AlreadyConfigured {
|
Err(ConfigError::AlreadyConfigured {
|
||||||
path: self.source.clone(),
|
path: self.source.clone(),
|
||||||
@@ -738,6 +761,10 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_config_directory(directory: &Dir) -> io::Result<()> {
|
||||||
|
directory.open(".").and_then(|file| file.sync_all())
|
||||||
|
}
|
||||||
|
|
||||||
fn git_remote_document(remote: &GitRemote) -> Result<toml::Table, ConfigError> {
|
fn git_remote_document(remote: &GitRemote) -> Result<toml::Table, ConfigError> {
|
||||||
let mut configured = toml::Table::new();
|
let mut configured = toml::Table::new();
|
||||||
configured.insert(
|
configured.insert(
|
||||||
@@ -1412,6 +1439,7 @@ pub enum ConfigError {
|
|||||||
VaultUnavailable { path: PathBuf },
|
VaultUnavailable { path: PathBuf },
|
||||||
VaultIsNotDirectory { path: PathBuf },
|
VaultIsNotDirectory { path: PathBuf },
|
||||||
Write { path: PathBuf },
|
Write { path: PathBuf },
|
||||||
|
DurabilityUncertain { path: PathBuf },
|
||||||
AlreadyConfigured { path: PathBuf },
|
AlreadyConfigured { path: PathBuf },
|
||||||
KeyMaterialNotFound { path: PathBuf },
|
KeyMaterialNotFound { path: PathBuf },
|
||||||
InvalidKeyMaterial { path: PathBuf },
|
InvalidKeyMaterial { path: PathBuf },
|
||||||
@@ -1483,6 +1511,11 @@ impl fmt::Display for ConfigError {
|
|||||||
path.display()
|
path.display()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Self::DurabilityUncertain { path } => write!(
|
||||||
|
formatter,
|
||||||
|
"configuration replacement completed but its directory sync failed: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
Self::AlreadyConfigured { path } => write!(
|
Self::AlreadyConfigured { path } => write!(
|
||||||
formatter,
|
formatter,
|
||||||
"configuration already exists and was not replaced: {}",
|
"configuration already exists and was not replaced: {}",
|
||||||
@@ -2315,7 +2348,7 @@ fn native_config_directory() -> Option<PathBuf> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::fs;
|
use std::{fs, io};
|
||||||
|
|
||||||
use crate::mobile::MobileTab;
|
use crate::mobile::MobileTab;
|
||||||
|
|
||||||
@@ -2401,4 +2434,48 @@ key_material = "keys"
|
|||||||
assert!(contents.contains("vault = \"vault\""));
|
assert!(contents.contains("vault = \"vault\""));
|
||||||
assert!(!contents.contains("OLD-CONTAINER"));
|
assert!(!contents.contains("OLD-CONTAINER"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_install_directory_sync_failure_is_distinct_and_keeps_complete_config() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let vault = temporary.path().join("vault");
|
||||||
|
let keys = temporary.path().join("keys");
|
||||||
|
let source = temporary.path().join("config.toml");
|
||||||
|
fs::create_dir(&vault).expect("vault");
|
||||||
|
fs::create_dir(&keys).expect("keys");
|
||||||
|
let remote = GitRemote::https(
|
||||||
|
"origin",
|
||||||
|
"https://example.test/team/passwords.git",
|
||||||
|
"server-example",
|
||||||
|
"repository-example",
|
||||||
|
)
|
||||||
|
.expect("remote");
|
||||||
|
let config = Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
|
||||||
|
.expect("initial config");
|
||||||
|
|
||||||
|
let error = config
|
||||||
|
.persist_with_directory_sync(|_| Err(io::Error::other("simulated sync failure")))
|
||||||
|
.expect_err("replacement directory sync must fail");
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ConfigError::DurabilityUncertain {
|
||||||
|
path: source.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Config::load(Some(&source)).expect("replacement is complete");
|
||||||
|
|
||||||
|
let created = temporary.path().join("created.toml");
|
||||||
|
let mut new_config = config;
|
||||||
|
new_config.source = created.clone();
|
||||||
|
let error = new_config
|
||||||
|
.persist_new_with_directory_sync(|_| Err(io::Error::other("simulated sync failure")))
|
||||||
|
.expect_err("creation directory sync must fail");
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ConfigError::DurabilityUncertain {
|
||||||
|
path: created.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Config::load(Some(&created)).expect("creation is complete");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1022,8 +1022,8 @@ impl ReqwestGitTransport {
|
|||||||
|
|
||||||
fn response(response: reqwest::blocking::Response) -> Result<Vec<u8>, GitError> {
|
fn response(response: reqwest::blocking::Response) -> Result<Vec<u8>, GitError> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if let Some(error) = classify_http_status(status) {
|
||||||
return Err(GitError::AuthenticationFailed);
|
return Err(error);
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(GitError::InvalidRepository(format!(
|
return Err(GitError::InvalidRepository(format!(
|
||||||
@@ -1273,13 +1273,103 @@ fn map_ambiguous_push_error(error: GitError) -> GitError {
|
|||||||
|
|
||||||
fn map_reqwest_error(error: reqwest::Error) -> GitError {
|
fn map_reqwest_error(error: reqwest::Error) -> GitError {
|
||||||
if error.is_builder() {
|
if error.is_builder() {
|
||||||
return invalid(error);
|
return generic_https_error(&error);
|
||||||
}
|
}
|
||||||
let message = error.to_string().to_ascii_lowercase();
|
classify_structured_https_error(&error).unwrap_or_else(|| generic_https_error(&error))
|
||||||
if message.contains("certificate") || message.contains("tls") {
|
}
|
||||||
GitError::TlsFailed
|
|
||||||
|
fn classify_http_status(status: reqwest::StatusCode) -> Option<GitError> {
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||||
|
)
|
||||||
|
.then_some(GitError::AuthenticationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_structured_https_error(error: &(dyn Error + 'static)) -> Option<GitError> {
|
||||||
|
let mut current = Some(error);
|
||||||
|
let mut tls = false;
|
||||||
|
let mut network = false;
|
||||||
|
while let Some(error) = current {
|
||||||
|
if let Some(error) = error.downcast_ref::<reqwest::Error>() {
|
||||||
|
if error.status().and_then(classify_http_status).is_some() {
|
||||||
|
return Some(GitError::AuthenticationFailed);
|
||||||
|
}
|
||||||
|
network |= error.is_timeout() || error.is_connect();
|
||||||
|
}
|
||||||
|
if error.is::<rustls::Error>() {
|
||||||
|
tls = true;
|
||||||
|
}
|
||||||
|
if let Some(error) = error.downcast_ref::<std::io::Error>() {
|
||||||
|
if error.kind() == std::io::ErrorKind::PermissionDenied {
|
||||||
|
return Some(GitError::AuthenticationFailed);
|
||||||
|
}
|
||||||
|
network |= matches!(
|
||||||
|
error.kind(),
|
||||||
|
std::io::ErrorKind::ConnectionRefused
|
||||||
|
| std::io::ErrorKind::ConnectionReset
|
||||||
|
| std::io::ErrorKind::ConnectionAborted
|
||||||
|
| std::io::ErrorKind::NotConnected
|
||||||
|
| std::io::ErrorKind::TimedOut
|
||||||
|
| std::io::ErrorKind::AddrNotAvailable
|
||||||
|
| std::io::ErrorKind::NetworkUnreachable
|
||||||
|
| std::io::ErrorKind::HostUnreachable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
current = error.source();
|
||||||
|
}
|
||||||
|
if tls {
|
||||||
|
Some(GitError::TlsFailed)
|
||||||
|
} else if network {
|
||||||
|
Some(GitError::NetworkUnavailable)
|
||||||
} else {
|
} else {
|
||||||
GitError::NetworkUnavailable
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generic_https_error(error: &dyn fmt::Display) -> GitError {
|
||||||
|
GitError::InvalidRepository(protocol_diagnostic(error.to_string().as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_https_connect_error(error: gix::remote::connect::Error) -> GitError {
|
||||||
|
match error {
|
||||||
|
error @ gix::remote::connect::Error::Connect(_) => {
|
||||||
|
classify_structured_https_error(&error).unwrap_or_else(|| generic_https_error(&error))
|
||||||
|
}
|
||||||
|
error => generic_https_error(&error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_https_prepare_error(error: gix::remote::fetch::prepare::Error) -> GitError {
|
||||||
|
match error {
|
||||||
|
error @ gix::remote::fetch::prepare::Error::RefMap(
|
||||||
|
gix::remote::ref_map::Error::Transport(_) | gix::remote::ref_map::Error::Handshake(_),
|
||||||
|
) => classify_structured_https_error(&error).unwrap_or_else(|| generic_https_error(&error)),
|
||||||
|
error => generic_https_error(&error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_https_fetch_error(
|
||||||
|
error: gix::remote::fetch::Error,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> GitError {
|
||||||
|
if control.is_cancelled() {
|
||||||
|
return GitError::Cancelled;
|
||||||
|
}
|
||||||
|
match error {
|
||||||
|
gix::remote::fetch::Error::Fetch(gix::protocol::fetch::Error::ConsumePack(_)) => {
|
||||||
|
GitError::MalformedGitPack
|
||||||
|
}
|
||||||
|
gix::remote::fetch::Error::Fetch(
|
||||||
|
gix::protocol::fetch::Error::FetchResponse(_)
|
||||||
|
| gix::protocol::fetch::Error::Negotiate(_)
|
||||||
|
| gix::protocol::fetch::Error::MissingServerFeature { .. },
|
||||||
|
) => GitError::GitProtocolFailed,
|
||||||
|
error @ (gix::remote::fetch::Error::Fetch(gix::protocol::fetch::Error::Client(_))
|
||||||
|
| gix::remote::fetch::Error::Client(_)) => {
|
||||||
|
classify_structured_https_error(&error).unwrap_or_else(|| generic_https_error(&error))
|
||||||
|
}
|
||||||
|
error => generic_https_error(&error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1905,7 +1995,7 @@ impl GitRepository {
|
|||||||
.map_err(invalid)?;
|
.map_err(invalid)?;
|
||||||
let mut connection = remote
|
let mut connection = remote
|
||||||
.connect(gix::remote::Direction::Fetch)
|
.connect(gix::remote::Direction::Fetch)
|
||||||
.map_err(invalid)?;
|
.map_err(map_https_connect_error)?;
|
||||||
connection.set_credentials(move |action| match action {
|
connection.set_credentials(move |action| match action {
|
||||||
gix::credentials::helper::Action::Get(context) => {
|
gix::credentials::helper::Action::Get(context) => {
|
||||||
let same_origin = context
|
let same_origin = context
|
||||||
@@ -1931,32 +2021,10 @@ impl GitRepository {
|
|||||||
});
|
});
|
||||||
let prepared = connection
|
let prepared = connection
|
||||||
.prepare_fetch(gix::progress::Discard, Default::default())
|
.prepare_fetch(gix::progress::Discard, Default::default())
|
||||||
.map_err(invalid)?;
|
.map_err(map_https_prepare_error)?;
|
||||||
let outcome = prepared
|
let outcome = prepared
|
||||||
.receive(gix::progress::Discard, control.cancelled.as_ref())
|
.receive(gix::progress::Discard, control.cancelled.as_ref())
|
||||||
.map_err(|error| {
|
.map_err(|error| map_https_fetch_error(error, control))?;
|
||||||
if control.is_cancelled() {
|
|
||||||
return GitError::Cancelled;
|
|
||||||
}
|
|
||||||
let text = error.to_string();
|
|
||||||
let lower = text.to_ascii_lowercase();
|
|
||||||
if text.contains("401")
|
|
||||||
|| text.contains("403")
|
|
||||||
|| lower.contains("authentication")
|
|
||||||
|| (lower.contains("credential") && lower.contains("not accepted"))
|
|
||||||
{
|
|
||||||
GitError::AuthenticationFailed
|
|
||||||
} else if lower.contains("certificate") || lower.contains("tls") {
|
|
||||||
GitError::TlsFailed
|
|
||||||
} else if lower.contains("network")
|
|
||||||
|| lower.contains("connect")
|
|
||||||
|| lower.contains("dns")
|
|
||||||
{
|
|
||||||
GitError::NetworkUnavailable
|
|
||||||
} else {
|
|
||||||
invalid(error)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(matches!(
|
Ok(matches!(
|
||||||
outcome.status,
|
outcome.status,
|
||||||
gix::remote::fetch::Status::Change { .. }
|
gix::remote::fetch::Status::Change { .. }
|
||||||
@@ -4331,8 +4399,12 @@ mod ssh_push_tests;
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::{error::Error, fmt, io};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
GitError, GitIdentity, GitRemoteCredentialOverride, GitRepository, SshPassphraseProvider,
|
GitError, GitIdentity, GitOperationControl, GitRemoteCredentialOverride, GitRepository,
|
||||||
|
SshPassphraseProvider, classify_http_status, classify_structured_https_error,
|
||||||
|
generic_https_error, map_https_fetch_error,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
config::SshFingerprint,
|
config::SshFingerprint,
|
||||||
@@ -4341,6 +4413,17 @@ mod tests {
|
|||||||
|
|
||||||
struct Passphrases;
|
struct Passphrases;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Diagnostic(String);
|
||||||
|
|
||||||
|
impl fmt::Display for Diagnostic {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for Diagnostic {}
|
||||||
|
|
||||||
impl SshPassphraseProvider for Passphrases {
|
impl SshPassphraseProvider for Passphrases {
|
||||||
fn ssh_key_passphrase(
|
fn ssh_key_passphrase(
|
||||||
&self,
|
&self,
|
||||||
@@ -4362,6 +4445,57 @@ mod tests {
|
|||||||
assert!(repository.repository.committer().is_some());
|
assert!(repository.repository.committer().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn https_failures_use_only_structured_status_and_sources() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_http_status(reqwest::StatusCode::UNAUTHORIZED),
|
||||||
|
Some(GitError::AuthenticationFailed)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_http_status(reqwest::StatusCode::FORBIDDEN),
|
||||||
|
Some(GitError::AuthenticationFailed)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_structured_https_error(&io::Error::from(io::ErrorKind::ConnectionRefused)),
|
||||||
|
Some(GitError::NetworkUnavailable)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_structured_https_error(&io::Error::from(io::ErrorKind::TimedOut)),
|
||||||
|
Some(GitError::NetworkUnavailable)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_structured_https_error(&rustls::Error::General("opaque".to_owned())),
|
||||||
|
Some(GitError::TlsFailed)
|
||||||
|
);
|
||||||
|
|
||||||
|
let rendered_only = Diagnostic(
|
||||||
|
"401 403 authentication credential certificate tls network connect dns".to_owned(),
|
||||||
|
);
|
||||||
|
assert_eq!(classify_structured_https_error(&rendered_only), None);
|
||||||
|
assert!(matches!(
|
||||||
|
generic_https_error(&rendered_only),
|
||||||
|
GitError::InvalidRepository(_)
|
||||||
|
));
|
||||||
|
|
||||||
|
let local_permission = gix::remote::fetch::Error::RemovePackKeepFile {
|
||||||
|
path: "pack.keep".into(),
|
||||||
|
source: io::Error::from(io::ErrorKind::PermissionDenied),
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
map_https_fetch_error(local_permission, &GitOperationControl::default()),
|
||||||
|
GitError::InvalidRepository(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_https_diagnostic_is_bounded() {
|
||||||
|
let error = generic_https_error(&Diagnostic("x".repeat(32 * 1024)));
|
||||||
|
let GitError::InvalidRepository(diagnostic) = error else {
|
||||||
|
panic!("unstructured HTTPS error must stay generic");
|
||||||
|
};
|
||||||
|
assert!(diagnostic.len() <= 8 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_ssh_passphrase_override_is_bound_to_its_fingerprint() {
|
fn one_ssh_passphrase_override_is_bound_to_its_fingerprint() {
|
||||||
let selected = SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU")
|
let selected = SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU")
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ use crate::{
|
|||||||
crypto::{KeyStore, SecretProvider},
|
crypto::{KeyStore, SecretProvider},
|
||||||
recipient::{RecipientPolicyManager, SigningPolicy},
|
recipient::{RecipientPolicyManager, SigningPolicy},
|
||||||
repository::{
|
repository::{
|
||||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, ResolvedObject,
|
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, RepositorySnapshot,
|
||||||
|
ResolvedObject,
|
||||||
},
|
},
|
||||||
write::OverwriteDecision,
|
write::OverwriteDecision,
|
||||||
};
|
};
|
||||||
@@ -164,19 +165,21 @@ impl<'a> TreeMutator<'a> {
|
|||||||
if !request.recursive {
|
if !request.recursive {
|
||||||
return Err(MutationError::RecursiveRequired);
|
return Err(MutationError::RecursiveRequired);
|
||||||
}
|
}
|
||||||
self.reject_unmanaged(record.path())?;
|
self.reject_unmanaged(&snapshot, record.path())?;
|
||||||
(
|
(
|
||||||
self.source_entries(record.path())?,
|
self.source_entries(&snapshot, record.path())?,
|
||||||
self.source_policies(record.path())?,
|
self.source_policies(&snapshot, record.path())?,
|
||||||
self.source_directories(record.path())?,
|
self.source_directories(&snapshot, record.path()),
|
||||||
record.path().clone(),
|
record.path().clone(),
|
||||||
format!("{}/", record.path()),
|
format!("{}/", record.path()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(operation) = self.remove_sources(&entries, &policies, &directories) {
|
if let Err(operation) = self.remove_sources(&entries, &policies, &directories) {
|
||||||
self.restore_sources(&entries, &policies, &directories)?;
|
return Err(with_rollback(
|
||||||
return Err(operation);
|
operation,
|
||||||
|
self.restore_sources(&entries, &policies, &directories),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let change = TreeCommit {
|
let change = TreeCommit {
|
||||||
action: MutationAction::Remove,
|
action: MutationAction::Remove,
|
||||||
@@ -195,8 +198,10 @@ impl<'a> TreeMutator<'a> {
|
|||||||
message: format!("Remove {display} from store."),
|
message: format!("Remove {display} from store."),
|
||||||
};
|
};
|
||||||
if let Err(error) = committer.commit(&change) {
|
if let Err(error) = committer.commit(&change) {
|
||||||
self.restore_sources(&entries, &policies, &directories)?;
|
return Err(with_rollback(
|
||||||
return Err(MutationError::Commit(error));
|
MutationError::Commit(error),
|
||||||
|
self.restore_sources(&entries, &policies, &directories),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
self.repository.cleanup_empty_directories(&cleanup)?;
|
self.repository.cleanup_empty_directories(&cleanup)?;
|
||||||
Ok(MutationOutcome {
|
Ok(MutationOutcome {
|
||||||
@@ -331,11 +336,11 @@ impl<'a> TreeMutator<'a> {
|
|||||||
path: destination.as_path().to_owned(),
|
path: destination.as_path().to_owned(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.reject_unmanaged(record.path())?;
|
self.reject_unmanaged(&snapshot, record.path())?;
|
||||||
(
|
(
|
||||||
self.source_entries(record.path())?,
|
self.source_entries(&snapshot, record.path())?,
|
||||||
self.source_policies(record.path())?,
|
self.source_policies(&snapshot, record.path())?,
|
||||||
self.source_directories(record.path())?,
|
self.source_directories(&snapshot, record.path()),
|
||||||
record.path().clone(),
|
record.path().clone(),
|
||||||
destination,
|
destination,
|
||||||
None,
|
None,
|
||||||
@@ -386,10 +391,10 @@ impl<'a> TreeMutator<'a> {
|
|||||||
.starts_with(source_root.as_path())
|
.starts_with(source_root.as_path())
|
||||||
}) {
|
}) {
|
||||||
RecipientPolicyManager::new(self.repository, self.keys)
|
RecipientPolicyManager::new(self.repository, self.keys)
|
||||||
.resolve_for_entry(&source_entry.source, signing)?
|
.resolve_for_entry_in_snapshot(&snapshot, &source_entry.source, signing)?
|
||||||
} else {
|
} else {
|
||||||
RecipientPolicyManager::new(self.repository, self.keys)
|
RecipientPolicyManager::new(self.repository, self.keys)
|
||||||
.resolve_for_entry(&destination_path, signing)?
|
.resolve_for_entry_in_snapshot(&snapshot, &destination_path, signing)?
|
||||||
};
|
};
|
||||||
let replacement = if self
|
let replacement = if self
|
||||||
.keys
|
.keys
|
||||||
@@ -484,12 +489,18 @@ impl<'a> TreeMutator<'a> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
if let Err(operation) = applied {
|
if let Err(operation) = applied {
|
||||||
if moving {
|
return Err(with_rollback(
|
||||||
self.restore_sources(&source_entries, &source_policies, &source_directories)?;
|
operation,
|
||||||
}
|
self.rollback_transfer(
|
||||||
self.restore_destinations(&entries, &policies)?;
|
moving,
|
||||||
self.remove_created_directories(&created_directories)?;
|
&source_entries,
|
||||||
return Err(operation);
|
&source_policies,
|
||||||
|
&source_directories,
|
||||||
|
&entries,
|
||||||
|
&policies,
|
||||||
|
&created_directories,
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let action = if moving {
|
let action = if moving {
|
||||||
MutationAction::Move
|
MutationAction::Move
|
||||||
@@ -528,12 +539,18 @@ impl<'a> TreeMutator<'a> {
|
|||||||
message: format!("{verb} {source} to {destination}."),
|
message: format!("{verb} {source} to {destination}."),
|
||||||
};
|
};
|
||||||
if let Err(error) = committer.commit(&change) {
|
if let Err(error) = committer.commit(&change) {
|
||||||
if moving {
|
return Err(with_rollback(
|
||||||
self.restore_sources(&source_entries, &source_policies, &source_directories)?;
|
MutationError::Commit(error),
|
||||||
}
|
self.rollback_transfer(
|
||||||
self.restore_destinations(&entries, &policies)?;
|
moving,
|
||||||
self.remove_created_directories(&created_directories)?;
|
&source_entries,
|
||||||
return Err(MutationError::Commit(error));
|
&source_policies,
|
||||||
|
&source_directories,
|
||||||
|
&entries,
|
||||||
|
&policies,
|
||||||
|
&created_directories,
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if moving {
|
if moving {
|
||||||
self.repository.cleanup_empty_directories(&source_root)?;
|
self.repository.cleanup_empty_directories(&source_root)?;
|
||||||
@@ -549,9 +566,12 @@ impl<'a> TreeMutator<'a> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_entries(&self, root: &DirectoryPath) -> Result<Vec<EntryState>, MutationError> {
|
fn source_entries(
|
||||||
self.repository
|
&self,
|
||||||
.snapshot()?
|
snapshot: &RepositorySnapshot,
|
||||||
|
root: &DirectoryPath,
|
||||||
|
) -> Result<Vec<EntryState>, MutationError> {
|
||||||
|
snapshot
|
||||||
.entries()
|
.entries()
|
||||||
.filter(|entry| entry.path().as_path().starts_with(root.as_path()))
|
.filter(|entry| entry.path().as_path().starts_with(root.as_path()))
|
||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
@@ -563,9 +583,12 @@ impl<'a> TreeMutator<'a> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_policies(&self, root: &DirectoryPath) -> Result<Vec<PolicyState>, MutationError> {
|
fn source_policies(
|
||||||
self.repository
|
&self,
|
||||||
.snapshot()?
|
snapshot: &RepositorySnapshot,
|
||||||
|
root: &DirectoryPath,
|
||||||
|
) -> Result<Vec<PolicyState>, MutationError> {
|
||||||
|
snapshot
|
||||||
.recipient_policies()
|
.recipient_policies()
|
||||||
.filter(|policy| policy.directory().as_path().starts_with(root.as_path()))
|
.filter(|policy| policy.directory().as_path().starts_with(root.as_path()))
|
||||||
.map(|policy| {
|
.map(|policy| {
|
||||||
@@ -583,19 +606,21 @@ impl<'a> TreeMutator<'a> {
|
|||||||
|
|
||||||
fn source_directories(
|
fn source_directories(
|
||||||
&self,
|
&self,
|
||||||
|
snapshot: &RepositorySnapshot,
|
||||||
root: &DirectoryPath,
|
root: &DirectoryPath,
|
||||||
) -> Result<Vec<DirectoryPath>, MutationError> {
|
) -> Vec<DirectoryPath> {
|
||||||
Ok(self
|
snapshot
|
||||||
.repository
|
|
||||||
.snapshot()?
|
|
||||||
.directories()
|
.directories()
|
||||||
.filter(|directory| directory.path().as_path().starts_with(root.as_path()))
|
.filter(|directory| directory.path().as_path().starts_with(root.as_path()))
|
||||||
.map(|directory| directory.path().clone())
|
.map(|directory| directory.path().clone())
|
||||||
.collect())
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reject_unmanaged(&self, root: &DirectoryPath) -> Result<(), MutationError> {
|
fn reject_unmanaged(
|
||||||
let snapshot = self.repository.snapshot()?;
|
&self,
|
||||||
|
snapshot: &RepositorySnapshot,
|
||||||
|
root: &DirectoryPath,
|
||||||
|
) -> Result<(), MutationError> {
|
||||||
if let Some(file) = snapshot
|
if let Some(file) = snapshot
|
||||||
.auxiliary_files()
|
.auxiliary_files()
|
||||||
.find(|file| file.path().starts_with(root.as_path()))
|
.find(|file| file.path().starts_with(root.as_path()))
|
||||||
@@ -648,39 +673,65 @@ impl<'a> TreeMutator<'a> {
|
|||||||
policies: &[PolicyState],
|
policies: &[PolicyState],
|
||||||
directories: &[DirectoryPath],
|
directories: &[DirectoryPath],
|
||||||
) -> Result<(), MutationError> {
|
) -> Result<(), MutationError> {
|
||||||
|
let mut failure = None;
|
||||||
for directory in directories {
|
for directory in directories {
|
||||||
self.repository.ensure_directory(directory)?;
|
remember_rollback_failure(
|
||||||
|
&mut failure,
|
||||||
|
self.repository
|
||||||
|
.ensure_directory(directory)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(MutationError::from),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for policy in policies {
|
for policy in policies {
|
||||||
self.repository.replace_policy_file(
|
remember_rollback_failure(
|
||||||
&policy.directory,
|
&mut failure,
|
||||||
false,
|
|
||||||
Some(&policy.recipients),
|
|
||||||
)?;
|
|
||||||
if let Some(signature) = &policy.signature {
|
|
||||||
self.repository
|
self.repository
|
||||||
.replace_policy_file(&policy.directory, true, Some(signature))?;
|
.replace_policy_file(&policy.directory, false, Some(&policy.recipients))
|
||||||
|
.map_err(MutationError::from),
|
||||||
|
);
|
||||||
|
if let Some(signature) = &policy.signature {
|
||||||
|
remember_rollback_failure(
|
||||||
|
&mut failure,
|
||||||
|
self.repository
|
||||||
|
.replace_policy_file(&policy.directory, true, Some(signature))
|
||||||
|
.map_err(MutationError::from),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
remember_rollback_failure(
|
||||||
|
&mut failure,
|
||||||
self.repository
|
self.repository
|
||||||
.write_entry(&entry.source, &entry.ciphertext)?;
|
.write_entry(&entry.source, &entry.ciphertext)
|
||||||
|
.map_err(MutationError::from),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
failure.map_or(Ok(()), Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_created_directories(
|
fn remove_created_directories(
|
||||||
&self,
|
&self,
|
||||||
directories: &[DirectoryPath],
|
directories: &[DirectoryPath],
|
||||||
) -> Result<(), MutationError> {
|
) -> Result<(), MutationError> {
|
||||||
|
let mut failure = None;
|
||||||
for directory in directories.iter().rev() {
|
for directory in directories.iter().rev() {
|
||||||
if !self.repository.remove_empty_directory(directory)? {
|
let result = self
|
||||||
return Err(MutationError::TreeChanged {
|
.repository
|
||||||
path: directory.as_path().to_owned(),
|
.remove_empty_directory(directory)
|
||||||
});
|
.map_err(MutationError::from)
|
||||||
}
|
.and_then(|removed| {
|
||||||
}
|
if removed {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(MutationError::TreeChanged {
|
||||||
|
path: directory.as_path().to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
remember_rollback_failure(&mut failure, result);
|
||||||
|
}
|
||||||
|
failure.map_or(Ok(()), Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_destinations(
|
fn restore_destinations(
|
||||||
@@ -688,25 +739,63 @@ impl<'a> TreeMutator<'a> {
|
|||||||
entries: &[TransferEntry],
|
entries: &[TransferEntry],
|
||||||
policies: &[TransferPolicy],
|
policies: &[TransferPolicy],
|
||||||
) -> Result<(), MutationError> {
|
) -> Result<(), MutationError> {
|
||||||
|
let mut failure = None;
|
||||||
for policy in policies.iter().rev() {
|
for policy in policies.iter().rev() {
|
||||||
if policy.source.signature.is_some() {
|
if policy.source.signature.is_some() {
|
||||||
|
remember_rollback_failure(
|
||||||
|
&mut failure,
|
||||||
self.repository
|
self.repository
|
||||||
.replace_policy_file(&policy.destination, true, None)?;
|
.replace_policy_file(&policy.destination, true, None)
|
||||||
|
.map_err(MutationError::from),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
remember_rollback_failure(
|
||||||
|
&mut failure,
|
||||||
self.repository
|
self.repository
|
||||||
.replace_policy_file(&policy.destination, false, None)?;
|
.replace_policy_file(&policy.destination, false, None)
|
||||||
|
.map_err(MutationError::from),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for entry in entries.iter().rev() {
|
for entry in entries.iter().rev() {
|
||||||
if let Some(original) = &entry.old_destination {
|
let result = if let Some(original) = &entry.old_destination {
|
||||||
self.repository.write_entry(&entry.destination, original)?;
|
self.repository
|
||||||
|
.write_entry(&entry.destination, original)
|
||||||
|
.map_err(MutationError::from)
|
||||||
} else {
|
} else {
|
||||||
match self.repository.remove_entry(&entry.destination) {
|
match self.repository.remove_entry(&entry.destination) {
|
||||||
Ok(_) | Err(RepositoryError::NotFound { .. }) => {}
|
Ok(_) | Err(RepositoryError::NotFound { .. }) => Ok(()),
|
||||||
Err(error) => return Err(error.into()),
|
Err(error) => Err(error.into()),
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
remember_rollback_failure(&mut failure, result);
|
||||||
}
|
}
|
||||||
|
failure.map_or(Ok(()), Err)
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn rollback_transfer(
|
||||||
|
&self,
|
||||||
|
moving: bool,
|
||||||
|
source_entries: &[EntryState],
|
||||||
|
source_policies: &[PolicyState],
|
||||||
|
source_directories: &[DirectoryPath],
|
||||||
|
entries: &[TransferEntry],
|
||||||
|
policies: &[TransferPolicy],
|
||||||
|
created_directories: &[DirectoryPath],
|
||||||
|
) -> Result<(), MutationError> {
|
||||||
|
let mut failure = None;
|
||||||
|
if moving {
|
||||||
|
remember_rollback_failure(
|
||||||
|
&mut failure,
|
||||||
|
self.restore_sources(source_entries, source_policies, source_directories),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
remember_rollback_failure(&mut failure, self.restore_destinations(entries, policies));
|
||||||
|
remember_rollback_failure(
|
||||||
|
&mut failure,
|
||||||
|
self.remove_created_directories(created_directories),
|
||||||
|
);
|
||||||
|
failure.map_or(Ok(()), Err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,6 +830,25 @@ struct TransferPolicy {
|
|||||||
destination: DirectoryPath,
|
destination: DirectoryPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remember_rollback_failure(
|
||||||
|
failure: &mut Option<MutationError>,
|
||||||
|
result: Result<(), MutationError>,
|
||||||
|
) {
|
||||||
|
if failure.is_none() {
|
||||||
|
*failure = result.err();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_rollback(operation: MutationError, rollback: Result<(), MutationError>) -> MutationError {
|
||||||
|
match rollback {
|
||||||
|
Ok(()) => operation,
|
||||||
|
Err(rollback) => MutationError::RollbackFailed {
|
||||||
|
operation: Box::new(operation),
|
||||||
|
rollback: Box::new(rollback),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum MutationError {
|
pub enum MutationError {
|
||||||
Repository(RepositoryError),
|
Repository(RepositoryError),
|
||||||
@@ -751,13 +859,29 @@ pub enum MutationError {
|
|||||||
RootMutation,
|
RootMutation,
|
||||||
SameObject,
|
SameObject,
|
||||||
DestinationInsideSource,
|
DestinationInsideSource,
|
||||||
DestinationDirectoryMissing { directory: DirectoryPath },
|
DestinationDirectoryMissing {
|
||||||
DestinationTypeCollision { path: std::path::PathBuf },
|
directory: DirectoryPath,
|
||||||
UnsafePolicyOverwrite { directory: DirectoryPath },
|
},
|
||||||
UnsupportedAuxiliary { path: std::path::PathBuf },
|
DestinationTypeCollision {
|
||||||
UnsupportedGitRepository { path: std::path::PathBuf },
|
path: std::path::PathBuf,
|
||||||
TreeChanged { path: std::path::PathBuf },
|
},
|
||||||
|
UnsafePolicyOverwrite {
|
||||||
|
directory: DirectoryPath,
|
||||||
|
},
|
||||||
|
UnsupportedAuxiliary {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
},
|
||||||
|
UnsupportedGitRepository {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
},
|
||||||
|
TreeChanged {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
},
|
||||||
Commit(TreeCommitError),
|
Commit(TreeCommitError),
|
||||||
|
RollbackFailed {
|
||||||
|
operation: Box<MutationError>,
|
||||||
|
rollback: Box<MutationError>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for MutationError {
|
impl fmt::Display for MutationError {
|
||||||
@@ -804,6 +928,13 @@ impl fmt::Display for MutationError {
|
|||||||
path.display()
|
path.display()
|
||||||
),
|
),
|
||||||
Self::Commit(error) => write!(formatter, "cannot commit tree mutation: {error}"),
|
Self::Commit(error) => write!(formatter, "cannot commit tree mutation: {error}"),
|
||||||
|
Self::RollbackFailed {
|
||||||
|
operation,
|
||||||
|
rollback,
|
||||||
|
} => write!(
|
||||||
|
formatter,
|
||||||
|
"tree mutation failed ({operation}) and rollback failed ({rollback})"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -829,3 +960,57 @@ impl From<crate::crypto::CryptoError> for MutationError {
|
|||||||
Self::Crypto(error)
|
Self::Crypto(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_mutation_uses_one_repository_snapshot() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary store");
|
||||||
|
fs::create_dir_all(temporary.path().join("folder/nested")).expect("tree");
|
||||||
|
fs::write(
|
||||||
|
temporary.path().join("folder/nested/entry.gpg"),
|
||||||
|
b"ciphertext",
|
||||||
|
)
|
||||||
|
.expect("entry");
|
||||||
|
let repository = Repository::open(temporary.path()).expect("repository");
|
||||||
|
let keys = KeyStore::new();
|
||||||
|
let mut committer = NoGitTreeCommitter;
|
||||||
|
|
||||||
|
TreeMutator::new(&repository, &keys)
|
||||||
|
.remove(
|
||||||
|
&RemoveRequest {
|
||||||
|
entry: "folder/".to_owned(),
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
},
|
||||||
|
OverwriteDecision::Decline,
|
||||||
|
&mut committer,
|
||||||
|
)
|
||||||
|
.expect("remove directory");
|
||||||
|
|
||||||
|
assert_eq!(repository.snapshot_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rollback_error_retains_both_failures() {
|
||||||
|
let operation = MutationError::Commit(TreeCommitError::new("operation"));
|
||||||
|
let rollback = MutationError::TreeChanged {
|
||||||
|
path: "rollback".into(),
|
||||||
|
};
|
||||||
|
let combined = with_rollback(operation, Err(rollback));
|
||||||
|
|
||||||
|
let MutationError::RollbackFailed {
|
||||||
|
operation,
|
||||||
|
rollback,
|
||||||
|
} = combined
|
||||||
|
else {
|
||||||
|
panic!("both failures must be retained");
|
||||||
|
};
|
||||||
|
assert!(matches!(*operation, MutationError::Commit(_)));
|
||||||
|
assert!(matches!(*rollback, MutationError::TreeChanged { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -205,6 +205,15 @@ impl<'a> RecipientPolicyManager<'a> {
|
|||||||
signing: Option<&SigningPolicy>,
|
signing: Option<&SigningPolicy>,
|
||||||
) -> Result<EffectiveRecipients, RecipientPolicyError> {
|
) -> Result<EffectiveRecipients, RecipientPolicyError> {
|
||||||
let snapshot = self.repository.snapshot()?;
|
let snapshot = self.repository.snapshot()?;
|
||||||
|
self.resolve_for_entry_in_snapshot(&snapshot, entry, signing)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_for_entry_in_snapshot(
|
||||||
|
&self,
|
||||||
|
snapshot: &RepositorySnapshot,
|
||||||
|
entry: &EntryPath,
|
||||||
|
signing: Option<&SigningPolicy>,
|
||||||
|
) -> Result<EffectiveRecipients, RecipientPolicyError> {
|
||||||
let policy = snapshot.recipient_policy(entry).ok_or_else(|| {
|
let policy = snapshot.recipient_policy(entry).ok_or_else(|| {
|
||||||
RecipientPolicyError::MissingRecipientPolicy {
|
RecipientPolicyError::MissingRecipientPolicy {
|
||||||
entry: entry.clone(),
|
entry: entry.clone(),
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use std::{
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
pub use crate::secret::SecretBytes;
|
pub use crate::secret::SecretBytes;
|
||||||
use cap_std::{ambient_authority, fs::Dir};
|
use cap_std::{ambient_authority, fs::Dir};
|
||||||
@@ -316,6 +318,8 @@ impl RepositorySnapshot {
|
|||||||
pub struct Repository {
|
pub struct Repository {
|
||||||
root_path: PathBuf,
|
root_path: PathBuf,
|
||||||
root: Dir,
|
root: Dir,
|
||||||
|
#[cfg(test)]
|
||||||
|
snapshot_count: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for Repository {
|
impl fmt::Debug for Repository {
|
||||||
@@ -346,7 +350,12 @@ impl Repository {
|
|||||||
.map_err(|error| io_error("canonicalize repository root", requested, error))?;
|
.map_err(|error| io_error("canonicalize repository root", requested, error))?;
|
||||||
let root = Dir::open_ambient_dir(&root_path, ambient_authority())
|
let root = Dir::open_ambient_dir(&root_path, ambient_authority())
|
||||||
.map_err(|error| io_error("open repository root", &root_path, error))?;
|
.map_err(|error| io_error("open repository root", &root_path, error))?;
|
||||||
Ok(Self { root_path, root })
|
Ok(Self {
|
||||||
|
root_path,
|
||||||
|
root,
|
||||||
|
#[cfg(test)]
|
||||||
|
snapshot_count: AtomicUsize::new(0),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn root_path(&self) -> &Path {
|
pub fn root_path(&self) -> &Path {
|
||||||
@@ -354,11 +363,18 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn snapshot(&self) -> Result<RepositorySnapshot, RepositoryError> {
|
pub fn snapshot(&self) -> Result<RepositorySnapshot, RepositoryError> {
|
||||||
|
#[cfg(test)]
|
||||||
|
self.snapshot_count.fetch_add(1, Ordering::Relaxed);
|
||||||
let mut snapshot = RepositorySnapshot::default();
|
let mut snapshot = RepositorySnapshot::default();
|
||||||
scan_directory(&self.root, &DirectoryPath::root(), &mut snapshot)?;
|
scan_directory(&self.root, &DirectoryPath::root(), &mut snapshot)?;
|
||||||
Ok(snapshot)
|
Ok(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn snapshot_count(&self) -> usize {
|
||||||
|
self.snapshot_count.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn read_entry(&self, path: &EntryPath) -> Result<EncryptedEntry, RepositoryError> {
|
pub fn read_entry(&self, path: &EntryPath) -> Result<EncryptedEntry, RepositoryError> {
|
||||||
let (parent, file_name) = self.open_entry_parent(path)?;
|
let (parent, file_name) = self.open_entry_parent(path)?;
|
||||||
let metadata = child_metadata(&parent, &file_name, &path.encrypted_relative_path())?
|
let metadata = child_metadata(&parent, &file_name, &path.encrypted_relative_path())?
|
||||||
@@ -757,29 +773,43 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rollback_created<T>(
|
fn rollback_created<T>(
|
||||||
|
&self,
|
||||||
|
created: Vec<PathBuf>,
|
||||||
|
original: RepositoryError,
|
||||||
|
) -> Result<T, RepositoryError> {
|
||||||
|
self.rollback_created_with_checkpoint(created, original, |_| Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback_created_with_checkpoint<T>(
|
||||||
&self,
|
&self,
|
||||||
mut created: Vec<PathBuf>,
|
mut created: Vec<PathBuf>,
|
||||||
original: RepositoryError,
|
original: RepositoryError,
|
||||||
|
mut checkpoint: impl FnMut(RollbackStage) -> Result<(), RepositoryError>,
|
||||||
) -> Result<T, RepositoryError> {
|
) -> Result<T, RepositoryError> {
|
||||||
while let Some(path) = created.pop() {
|
while let Some(path) = created.pop() {
|
||||||
let parent = path.parent().unwrap_or_else(|| Path::new(""));
|
let parent = path.parent().unwrap_or_else(|| Path::new(""));
|
||||||
|
let rollback = (|| {
|
||||||
|
checkpoint(RollbackStage::BeforeReopen)?;
|
||||||
let parent_dir = self.open_directory(parent)?;
|
let parent_dir = self.open_directory(parent)?;
|
||||||
let name = path
|
let name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.expect("created directory path has a file name");
|
.expect("created directory path has a file name");
|
||||||
match parent_dir.remove_dir(name) {
|
match parent_dir.remove_dir(name) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
sync_directory(&parent_dir, parent)?;
|
checkpoint(RollbackStage::BeforeSync)?;
|
||||||
|
sync_directory(&parent_dir, parent)
|
||||||
}
|
}
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
Err(error) => {
|
Err(error) => Err(io_error("remove rollback directory", &path, error)),
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if let Err(rollback) = rollback {
|
||||||
return Err(RepositoryError::RollbackFailed {
|
return Err(RepositoryError::RollbackFailed {
|
||||||
path,
|
operation: Box::new(original),
|
||||||
source: error.kind(),
|
rollback: Box::new(rollback),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Err(original)
|
Err(original)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,6 +846,12 @@ enum WriteStage {
|
|||||||
AfterRename,
|
AfterRename,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum RollbackStage {
|
||||||
|
BeforeReopen,
|
||||||
|
BeforeSync,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
impl WriteStage {
|
impl WriteStage {
|
||||||
fn fixture_id(self) -> &'static str {
|
fn fixture_id(self) -> &'static str {
|
||||||
@@ -856,8 +892,8 @@ pub enum RepositoryError {
|
|||||||
source: io::ErrorKind,
|
source: io::ErrorKind,
|
||||||
},
|
},
|
||||||
RollbackFailed {
|
RollbackFailed {
|
||||||
path: PathBuf,
|
operation: Box<RepositoryError>,
|
||||||
source: io::ErrorKind,
|
rollback: Box<RepositoryError>,
|
||||||
},
|
},
|
||||||
DurabilityUncertain {
|
DurabilityUncertain {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -919,10 +955,12 @@ impl fmt::Display for RepositoryError {
|
|||||||
path,
|
path,
|
||||||
source,
|
source,
|
||||||
} => write!(formatter, "cannot {operation} {}: {source}", path.display()),
|
} => write!(formatter, "cannot {operation} {}: {source}", path.display()),
|
||||||
Self::RollbackFailed { path, source } => write!(
|
Self::RollbackFailed {
|
||||||
|
operation,
|
||||||
|
rollback,
|
||||||
|
} => write!(
|
||||||
formatter,
|
formatter,
|
||||||
"failed to roll back empty password-store directory {}: {source}",
|
"repository operation failed ({operation}) and directory rollback failed ({rollback})"
|
||||||
path.display()
|
|
||||||
),
|
),
|
||||||
Self::DurabilityUncertain { path } => write!(
|
Self::DurabilityUncertain { path } => write!(
|
||||||
formatter,
|
formatter,
|
||||||
@@ -1330,4 +1368,47 @@ mod tests {
|
|||||||
assert!(!temporary.path().join("vault/one").exists());
|
assert!(!temporary.path().join("vault/one").exists());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rollback_retains_operation_and_directory_failure() -> Result<(), Box<dyn Error>> {
|
||||||
|
for failed_stage in [RollbackStage::BeforeReopen, RollbackStage::BeforeSync] {
|
||||||
|
let temporary = tempfile::tempdir()?;
|
||||||
|
let vault = temporary.path().join("vault");
|
||||||
|
fs::create_dir_all(vault.join("created"))?;
|
||||||
|
let repository = Repository::open(&vault)?;
|
||||||
|
let operation = RepositoryError::Io {
|
||||||
|
operation: "simulated operation",
|
||||||
|
path: PathBuf::from("entry"),
|
||||||
|
source: io::ErrorKind::Interrupted,
|
||||||
|
};
|
||||||
|
let rollback = RepositoryError::Io {
|
||||||
|
operation: "simulated rollback",
|
||||||
|
path: PathBuf::from("created"),
|
||||||
|
source: io::ErrorKind::Other,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = repository
|
||||||
|
.rollback_created_with_checkpoint::<()>(
|
||||||
|
vec![PathBuf::from("created")],
|
||||||
|
operation.clone(),
|
||||||
|
|stage| {
|
||||||
|
if stage == failed_stage {
|
||||||
|
Err(rollback.clone())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("rollback checkpoint must fail");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
RepositoryError::RollbackFailed {
|
||||||
|
operation: Box::new(operation),
|
||||||
|
rollback: Box::new(rollback),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ impl TreeCommitter for Committer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SabotagingCommitter(Box<dyn FnMut()>);
|
||||||
|
|
||||||
|
impl TreeCommitter for SabotagingCommitter {
|
||||||
|
fn commit(&mut self, _change: &TreeCommit) -> Result<(), TreeCommitError> {
|
||||||
|
(self.0)();
|
||||||
|
Err(TreeCommitError::new("simulated operation failure"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remove_entry_supports_confirmation_force_and_empty_cleanup() -> TestResult {
|
fn remove_entry_supports_confirmation_force_and_empty_cleanup() -> TestResult {
|
||||||
let fixture = FixtureSet::load()?;
|
let fixture = FixtureSet::load()?;
|
||||||
@@ -514,6 +523,97 @@ fn collisions_auxiliary_files_and_commit_failure_never_leave_partial_trees() ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rollback_failures_from_sources_destinations_and_cleanup_retain_the_operation() -> TestResult {
|
||||||
|
let fixture = FixtureSet::load()?;
|
||||||
|
|
||||||
|
let store = fixture.materialize_store("basic")?;
|
||||||
|
let repository = Repository::open(store.path())?;
|
||||||
|
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||||
|
let mut provider = Secrets::all(&fixture);
|
||||||
|
let source_collision = store.path().join("email/personal.gpg");
|
||||||
|
let mut committer = SabotagingCommitter(Box::new(move || {
|
||||||
|
fs::create_dir(&source_collision).expect("block source restoration");
|
||||||
|
}));
|
||||||
|
let error = TreeMutator::new(&repository, &keys)
|
||||||
|
.move_tree(
|
||||||
|
&MoveRequest {
|
||||||
|
source: "email/personal".into(),
|
||||||
|
destination: "archive/personal".into(),
|
||||||
|
force: false,
|
||||||
|
},
|
||||||
|
OverwriteDecision::Allow,
|
||||||
|
None,
|
||||||
|
&mut provider,
|
||||||
|
&mut committer,
|
||||||
|
)
|
||||||
|
.expect_err("source restoration must fail");
|
||||||
|
assert_operation_and_rollback(error);
|
||||||
|
|
||||||
|
let store = fixture.materialize_store("basic")?;
|
||||||
|
let repository = Repository::open(store.path())?;
|
||||||
|
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||||
|
let mut provider = Secrets::all(&fixture);
|
||||||
|
let destination = store.path().join("archive/personal.gpg");
|
||||||
|
let mut committer = SabotagingCommitter(Box::new(move || {
|
||||||
|
fs::remove_file(&destination).expect("remove destination entry");
|
||||||
|
fs::create_dir(&destination).expect("block destination restoration");
|
||||||
|
}));
|
||||||
|
let error = TreeMutator::new(&repository, &keys)
|
||||||
|
.copy(
|
||||||
|
&CopyRequest {
|
||||||
|
source: "email/personal".into(),
|
||||||
|
destination: "archive/personal".into(),
|
||||||
|
force: false,
|
||||||
|
},
|
||||||
|
OverwriteDecision::Allow,
|
||||||
|
None,
|
||||||
|
&mut provider,
|
||||||
|
&mut committer,
|
||||||
|
)
|
||||||
|
.expect_err("destination restoration must fail");
|
||||||
|
assert_operation_and_rollback(error);
|
||||||
|
|
||||||
|
let store = fixture.materialize_store("basic")?;
|
||||||
|
let repository = Repository::open(store.path())?;
|
||||||
|
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||||
|
let mut provider = Secrets::all(&fixture);
|
||||||
|
let blocker = store.path().join("archive/blocker");
|
||||||
|
let mut committer = SabotagingCommitter(Box::new(move || {
|
||||||
|
fs::write(&blocker, b"concurrent file").expect("block directory cleanup");
|
||||||
|
}));
|
||||||
|
let error = TreeMutator::new(&repository, &keys)
|
||||||
|
.copy(
|
||||||
|
&CopyRequest {
|
||||||
|
source: "email/personal".into(),
|
||||||
|
destination: "archive/personal".into(),
|
||||||
|
force: false,
|
||||||
|
},
|
||||||
|
OverwriteDecision::Allow,
|
||||||
|
None,
|
||||||
|
&mut provider,
|
||||||
|
&mut committer,
|
||||||
|
)
|
||||||
|
.expect_err("created-directory cleanup must fail");
|
||||||
|
assert_operation_and_rollback(error);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_operation_and_rollback(error: MutationError) {
|
||||||
|
let MutationError::RollbackFailed {
|
||||||
|
operation,
|
||||||
|
rollback,
|
||||||
|
} = error
|
||||||
|
else {
|
||||||
|
panic!("operation and rollback errors must both be retained");
|
||||||
|
};
|
||||||
|
assert!(matches!(*operation, MutationError::Commit(_)));
|
||||||
|
assert!(matches!(
|
||||||
|
*rollback,
|
||||||
|
MutationError::Repository(_) | MutationError::TreeChanged { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn remove(entry: &str, recursive: bool, force: bool) -> RemoveRequest {
|
fn remove(entry: &str, recursive: bool, force: bool) -> RemoveRequest {
|
||||||
RemoveRequest {
|
RemoveRequest {
|
||||||
entry: entry.into(),
|
entry: entry.into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user