From bf3ffe21c71b8de6fcbf60460594706f1727bfff Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 14:27:45 +0900 Subject: [PATCH 1/2] Keep a persistent carrier object cache The carrier backend used to re-clone the carrier repository for every operation. It now keeps a locked cache under $XDG_CACHE_HOME/git-remote-e2ee/carrier// (override with GIT_REMOTE_E2EE_CACHE_DIR), fetches only new objects into it, and publishes from it. --- docs/design.md | 20 +- docs/spec.md | 5 +- src/storage.rs | 391 +++++++++++++++++++++++++++++----- tests/anonymous_recipients.rs | 11 + tests/carrier_git.rs | 319 ++++++++++++++++++++++++++- 5 files changed, 689 insertions(+), 57 deletions(-) diff --git a/docs/design.md b/docs/design.md index ce8bbe5..b9001b8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -176,9 +176,23 @@ reader count, and total growth. Public keys, device IDs, roles, signer identity, and membership changes are encrypted; object-size patterns can still suggest that a membership transition occurred. Inner Git metadata remains encrypted. -The current carrier implementation clones a temporary checkout for each helper -process. Uploads are incremental, but a persistent partial-clone cache is -needed before multi-gigabyte repositories are practical. +Each helper process fetches the carrier branch into a persistent bare object +cache, then creates a disposable checkout that borrows the cache's objects. +Fetches transfer only objects missing from the cache. The cache is keyed by the +SHA-256 of a normalized remote URL; a file lock serializes updates across helper +processes, and automatic garbage collection is disabled so borrowed objects +remain available. The carrier branch is fetched again immediately before each +CAS, and the CAS remains a normal fast-forward push. A stale cache therefore +cannot authorize a write against an outdated branch tip. + +Recovery publishes a fresh cache generation instead of replacing objects that +an in-flight temporary checkout may still borrow. Obsolete generations are +retained so those checkouts remain valid; repeated cache corruption can therefore +consume additional local disk space. + +The cache contains carrier Git objects and metadata only. It never stores keys, +plaintext, or decrypted repository data. Its default location and the +`GIT_REMOTE_E2EE_CACHE_DIR` override are documented in the [user guide](guide.md#git-host-as-storage-carrier-git-backend). ## Concurrency diff --git a/docs/spec.md b/docs/spec.md index 2d95e93..f3861cd 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -498,6 +498,5 @@ Planned extensions: 1. checkpoint/compaction and garbage collection with explicit authority; 2. M-of-N administrator authorization and recovery; 3. automatic stale-push retry with explicit merge behavior; -4. persistent partial-clone carrier cache; -5. S3 backend with conditional-write compatibility tests; -6. optional gossip or transparency-log anchoring. +4. S3 backend with conditional-write compatibility tests; +5. optional gossip or transparency-log anchoring. diff --git a/src/storage.rs b/src/storage.rs index 1cb42a3..02dfff3 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -306,9 +306,12 @@ fn verify_file_id(path: &Path, id: &str) -> Result<()> { const CARRIER_BRANCH: &str = "git-remote-e2ee"; const CARRIER_CHUNK_SIZE: usize = 32 * 1024 * 1024; +const CARRIER_CACHE_ENV: &str = "GIT_REMOTE_E2EE_CACHE_DIR"; +const CARRIER_CACHE_FETCH_REF: &str = "refs/heads/git-remote-e2ee"; pub struct GitStorage { checkout: tempfile::TempDir, + remote: String, state: Mutex, } @@ -474,44 +477,32 @@ impl GitStorage { if remote.is_empty() { bail!("empty carrier Git remote") } - let checkout = tempfile::Builder::new() + + let mut cache = CarrierCache::lock(remote)?; + let mut base_commit = cache.refresh(remote)?; + let mut checkout = tempfile::Builder::new() .prefix("git-remote-e2ee-carrier-") .tempdir()?; - let checkout_path = checkout - .path() - .to_str() - .context("carrier checkout path is not UTF-8")?; - git_command( - checkout - .path() - .parent() - .context("carrier tempdir has no parent")?, - &["clone", "--quiet", "--no-checkout", remote, checkout_path], - )?; - git_command(checkout.path(), &["config", "user.name", "git-remote-e2ee"])?; - git_command( - checkout.path(), - &["config", "user.email", "git-remote-e2ee@invalid"], - )?; - - let remote_ref = format!("refs/remotes/origin/{CARRIER_BRANCH}"); - let base_commit = git_rev_parse(checkout.path(), &remote_ref)?; - match &base_commit { - Some(_) => { - git_command( - checkout.path(), - &["checkout", "--quiet", "-B", CARRIER_BRANCH, &remote_ref], - )?; - } - None => { - git_command( - checkout.path(), - &["checkout", "--quiet", "--orphan", CARRIER_BRANCH], - )?; + if let Err(error) = + create_carrier_checkout(checkout.path(), &cache.path, remote, &base_commit) + { + if !cache.is_corrupt()? { + return Err(error).context("create carrier checkout"); } + drop(checkout); + cache.rebuild()?; + base_commit = cache.refresh(remote)?; + checkout = tempfile::Builder::new() + .prefix("git-remote-e2ee-carrier-") + .tempdir()?; + create_carrier_checkout(checkout.path(), &cache.path, remote, &base_commit) + .context("create carrier checkout after rebuilding corrupt cache")?; } + drop(cache); + Ok(Self { checkout, + remote: remote.to_owned(), state: Mutex::new(GitStorageState { base_commit }), }) } @@ -543,21 +534,8 @@ impl GitStorage { } } - fn remote_tip(&self) -> Result> { - let output = carrier_git_command() - .arg("-C") - .arg(self.root()) - .args(["ls-remote", "--heads", "origin"]) - .arg(format!("refs/heads/{CARRIER_BRANCH}")) - .output()?; - if !output.status.success() { - bail!( - "git ls-remote failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ) - } - let text = String::from_utf8(output.stdout)?; - Ok(text.split_whitespace().next().map(ToOwned::to_owned)) + fn refresh_remote(&self) -> Result> { + CarrierCache::lock(&self.remote)?.refresh(&self.remote) } fn head_at_commit(&self, commit: Option<&str>) -> Result> { @@ -604,6 +582,8 @@ impl Storage for GitStorage { } fn read_head(&self) -> Result> { + // `open` refreshed the remote before constructing this checkout; CAS + // refreshes again before trusting this snapshot for a write. self.local_head() } @@ -613,7 +593,7 @@ impl Storage for GitStorage { .state .lock() .map_err(|_| anyhow::anyhow!("carrier state lock poisoned"))?; - let remote_tip = self.remote_tip()?; + let remote_tip = self.refresh_remote()?; if remote_tip != state.base_commit { return Err(CasConflict { expected: expected.map(ToOwned::to_owned), @@ -646,7 +626,7 @@ impl Storage for GitStorage { .arg(format!("HEAD:refs/heads/{CARRIER_BRANCH}")) .output()?; if !push.status.success() { - let latest = self.remote_tip()?; + let latest = self.refresh_remote()?; return Err(CasConflict { expected: expected.map(ToOwned::to_owned), actual: self.head_at_commit(latest.as_deref())?, @@ -658,6 +638,296 @@ impl Storage for GitStorage { } } +struct CarrierCache { + remote_dir: PathBuf, + path: PathBuf, + _lock: File, +} + +impl CarrierCache { + fn lock(remote: &str) -> Result { + let normalized = normalize_carrier_remote(remote)?; + let hash = hex::encode(Sha256::digest(normalized.as_bytes())); + let root = carrier_cache_root()?; + fs::create_dir_all(&root) + .with_context(|| format!("create carrier cache directory {}", root.display()))?; + let root = fs::canonicalize(&root)?; + let lock_path = root.join(format!(".{hash}.lock")); + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .with_context(|| format!("open carrier cache lock {}", lock_path.display()))?; + lock.lock_exclusive() + .with_context(|| format!("lock carrier cache {}", lock_path.display()))?; + let remote_dir = root.join(&hash); + Ok(Self { + remote_dir, + path: PathBuf::new(), + _lock: lock, + }) + } + + fn refresh(&mut self, remote: &str) -> Result> { + self.ensure_initialized()?; + match fetch_carrier_branch(&self.path, remote) { + Ok(tip) => Ok(tip), + Err(fetch_error) if self.is_corrupt()? => { + self.rebuild()?; + fetch_carrier_branch(&self.path, remote).with_context(|| { + format!( + "fetch carrier branch after rebuilding corrupt cache (initial fetch failed: {fetch_error:#})" + ) + }) + } + Err(fetch_error) => Err(fetch_error), + } + } + + fn ensure_initialized(&mut self) -> Result<()> { + fs::create_dir_all(&self.remote_dir).with_context(|| { + format!( + "create per-remote carrier cache {}", + self.remote_dir.display() + ) + })?; + let current = self.remote_dir.join("current"); + let generation = fs::read_to_string(¤t) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| valid_cache_generation(value)); + if let Some(generation) = generation { + self.path = self.remote_dir.join(generation); + } + let usable = !self.path.as_os_str().is_empty() + && self.path.exists() + && cache_git_output(&self.path, &["rev-parse", "--is-bare-repository"]).is_ok_and( + |output| { + output.status.success() + && String::from_utf8_lossy(&output.stdout).trim() == "true" + }, + ); + if !usable { + self.initialize_generation()?; + } + if configure_cache(&self.path).is_err() { + self.rebuild()?; + } + Ok(()) + } + + fn initialize_generation(&mut self) -> Result<()> { + fs::create_dir_all(&self.remote_dir)?; + let mut random = [0_u8; 8]; + OsRng.fill_bytes(&mut random); + let generation = format!("objects-{}-{}", std::process::id(), hex::encode(random)); + self.path = self.remote_dir.join(generation); + let output = carrier_git_command() + .args(["init", "--bare", "--quiet"]) + .arg(&self.path) + .output()?; + ensure_git_success(&output, "git init --bare carrier cache")?; + configure_cache(&self.path)?; + cache_git_command( + &self.path, + &["symbolic-ref", "HEAD", CARRIER_CACHE_FETCH_REF], + )?; + self.publish_current() + } + + fn is_corrupt(&self) -> Result { + let output = cache_git_output(&self.path, &["fsck", "--full"])?; + Ok(!output.status.success()) + } + + fn rebuild(&mut self) -> Result<()> { + // Existing temporary checkouts may still borrow the previous object + // directory. Keep that generation intact and publish a fresh one. + self.initialize_generation() + } + + fn publish_current(&self) -> Result<()> { + let generation = self + .path + .file_name() + .context("carrier cache generation has no name")?; + let generation = generation + .to_str() + .context("carrier cache generation name is not UTF-8")?; + let mut random = [0_u8; 8]; + OsRng.fill_bytes(&mut random); + let temporary = self.remote_dir.join(format!( + ".current-{}-{}", + std::process::id(), + hex::encode(random) + )); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary)?; + writeln!(file, "{generation}")?; + file.sync_all()?; + let current = self.remote_dir.join("current"); + if current.exists() { + fs::remove_file(¤t)?; + } + fs::rename(&temporary, ¤t)?; + Ok(()) + } +} + +fn valid_cache_generation(value: &str) -> bool { + value.starts_with("objects-") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +fn carrier_cache_root() -> Result { + if let Some(override_dir) = std::env::var_os(CARRIER_CACHE_ENV) { + if override_dir.is_empty() { + bail!("{CARRIER_CACHE_ENV} must not be empty") + } + return Ok(PathBuf::from(override_dir)); + } + let cache_home = match std::env::var_os("XDG_CACHE_HOME") { + Some(path) if !path.is_empty() => PathBuf::from(path), + _ => PathBuf::from( + std::env::var_os("HOME").context("HOME is unset and XDG_CACHE_HOME is unset")?, + ) + .join(".cache"), + }; + Ok(cache_home.join("git-remote-e2ee").join("carrier")) +} + +fn normalize_carrier_remote(remote: &str) -> Result { + if remote.trim().is_empty() { + bail!("empty carrier Git remote") + } + if remote.contains("://") { + return Ok(remote.trim().trim_end_matches('/').to_owned()); + } + if remote == remote.trim() + && let Some((host, path)) = remote.split_once(':') + && !host.is_empty() + && !host.contains('/') + && !host.contains('\\') + && !(host.len() == 1 && host.as_bytes()[0].is_ascii_alphabetic()) + && !remote.starts_with("./") + && !remote.starts_with("../") + && !remote.starts_with('/') + { + return Ok(format!("{host}:{}", path.trim_end_matches('/'))); + } + + let path = Path::new(remote); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + match fs::canonicalize(&absolute) { + Ok(path) => Ok(path.to_string_lossy().into_owned()), + Err(_) => Ok(lexically_normalize(&absolute) + .to_string_lossy() + .into_owned()), + } +} + +fn lexically_normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component.as_os_str()), + } + } + normalized +} + +fn configure_cache(cache: &Path) -> Result<()> { + cache_git_command(cache, &["config", "gc.auto", "0"])?; + cache_git_command(cache, &["config", "maintenance.auto", "false"])?; + Ok(()) +} + +fn fetch_carrier_branch(cache: &Path, remote: &str) -> Result> { + let refspec = format!("+refs/heads/{CARRIER_BRANCH}:{CARRIER_CACHE_FETCH_REF}"); + let fetch = cache_git_output(cache, &["fetch", "--quiet", "--no-tags", remote, &refspec])?; + if fetch.status.success() { + return git_rev_parse(cache, CARRIER_CACHE_FETCH_REF); + } + + let ls_remote = cache_git_output( + cache, + &["ls-remote", "--heads", remote, CARRIER_CACHE_FETCH_REF], + )?; + ensure_git_success(&ls_remote, "git ls-remote carrier branch")?; + if String::from_utf8_lossy(&ls_remote.stdout).trim().is_empty() { + let delete = cache_git_output(cache, &["update-ref", "-d", CARRIER_CACHE_FETCH_REF])?; + ensure_git_success(&delete, "git update-ref delete missing carrier branch")?; + return Ok(None); + } + ensure_git_success(&fetch, "git fetch carrier branch")?; + unreachable!("failed git fetch must return an error") +} + +fn create_carrier_checkout( + checkout: &Path, + cache: &Path, + remote: &str, + base_commit: &Option, +) -> Result<()> { + let output = carrier_git_command() + .arg("-C") + .arg(checkout) + .args(["init", "--quiet"]) + .output()?; + ensure_git_success(&output, "git init carrier checkout")?; + let cache_objects = cache.join("objects"); + let cache_objects = cache_objects + .to_str() + .context("carrier cache object path is not UTF-8")?; + if cache_objects.contains('\n') { + bail!("carrier cache object path contains a newline") + } + fs::write( + checkout.join(".git/objects/info/alternates"), + format!("{cache_objects}\n"), + )?; + git_command(checkout, &["config", "gc.auto", "0"])?; + git_command(checkout, &["config", "maintenance.auto", "false"])?; + git_command(checkout, &["config", "user.name", "git-remote-e2ee"])?; + git_command( + checkout, + &["config", "user.email", "git-remote-e2ee@invalid"], + )?; + git_command(checkout, &["remote", "add", "origin", remote])?; + + let remote_ref = format!("refs/remotes/origin/{CARRIER_BRANCH}"); + match base_commit { + Some(commit) => { + git_command(checkout, &["update-ref", &remote_ref, commit])?; + git_command( + checkout, + &["checkout", "--quiet", "-B", CARRIER_BRANCH, &remote_ref], + )?; + } + None => { + git_command( + checkout, + &["checkout", "--quiet", "--orphan", CARRIER_BRANCH], + )?; + } + } + Ok(()) +} + fn git_rev_parse(repo: &Path, reference: &str) -> Result> { let output = carrier_git_command() .arg("-C") @@ -671,6 +941,29 @@ fn git_rev_parse(repo: &Path, reference: &str) -> Result> { } } +fn cache_git_command(cache: &Path, args: &[&str]) -> Result<()> { + let output = cache_git_output(cache, args)?; + ensure_git_success(&output, &format!("git {}", args.join(" "))) +} + +fn cache_git_output(cache: &Path, args: &[&str]) -> Result { + Ok(carrier_git_command() + .arg("--git-dir") + .arg(cache) + .args(args) + .output()?) +} + +fn ensure_git_success(output: &std::process::Output, action: &str) -> Result<()> { + if !output.status.success() { + bail!( + "{action} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ) + } + Ok(()) +} + fn git_command(repo: &Path, args: &[&str]) -> Result<()> { let output = carrier_git_command() .arg("-C") diff --git a/tests/anonymous_recipients.rs b/tests/anonymous_recipients.rs index 0c5ad07..215529e 100644 --- a/tests/anonymous_recipients.rs +++ b/tests/anonymous_recipients.rs @@ -1,7 +1,9 @@ +use std::env; use std::fs; use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; +use std::sync::Mutex; use base64::Engine; use git_remote_e2ee::crypto::{KeyFile, object_id}; @@ -10,6 +12,8 @@ use git_remote_e2ee::policy::DeviceRoles; use git_remote_e2ee::repository::EncryptedRepository; use git_remote_e2ee::storage::{FilesystemStorage, GitStorage, Storage}; +static CACHE_ENV_LOCK: Mutex<()> = Mutex::new(()); + #[test] fn filesystem_storage_hides_device_metadata_and_pads_reader_envelopes() { let temporary = tempfile::tempdir().unwrap(); @@ -25,6 +29,13 @@ fn filesystem_storage_hides_device_metadata_and_pads_reader_envelopes() { #[test] fn carrier_history_hides_device_metadata_and_pads_reader_envelopes() { let temporary = tempfile::tempdir().unwrap(); + let _cache_lock = CACHE_ENV_LOCK.lock().unwrap(); + unsafe { + env::set_var( + "GIT_REMOTE_E2EE_CACHE_DIR", + temporary.path().join("carrier-cache"), + ) + }; let carrier = temporary.path().join("carrier.git"); let output = Command::new("git") .args(["init", "--bare", "-q"]) diff --git a/tests/carrier_git.rs b/tests/carrier_git.rs index c115d68..cd28188 100644 --- a/tests/carrier_git.rs +++ b/tests/carrier_git.rs @@ -2,12 +2,50 @@ use std::env; use std::fs; use std::path::Path; use std::process::{Command, Output}; -use std::sync::{Arc, Barrier}; +use std::sync::{Arc, Barrier, Mutex, MutexGuard}; use std::thread; +use std::time::{Duration, Instant}; use git_remote_e2ee::crypto::KeyFile; use git_remote_e2ee::repository::EncryptedRepository; -use git_remote_e2ee::storage::{CasConflict, GitStorage}; +use git_remote_e2ee::storage::{CasConflict, GitStorage, Storage}; + +static CACHE_ENV_LOCK: Mutex<()> = Mutex::new(()); + +fn isolated_cache(temporary: &tempfile::TempDir) -> MutexGuard<'static, ()> { + let guard = CACHE_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let cache = temporary.path().join("carrier-cache"); + // This test binary serializes all tests that open GitStorage, keeping the + // process-global override stable while helper subprocesses inherit it. + unsafe { env::set_var("GIT_REMOTE_E2EE_CACHE_DIR", cache) }; + guard +} + +fn cache_repo(cache_root: &Path) -> std::path::PathBuf { + let per_remote = fs::read_dir(cache_root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.is_dir()) + .expect("one per-remote carrier cache directory"); + let generation = fs::read_to_string(per_remote.join("current")).unwrap(); + per_remote.join(generation.trim()) +} + +fn reachable_object_count(repo: &Path) -> usize { + let output = Command::new("git") + .arg(format!("--git-dir={}", repo.display())) + .args(["rev-list", "--objects", "--all"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "git rev-list failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().lines().count() +} fn git_output(repo: &Path, args: &[&str], with_helper: bool) -> Output { let mut command = Command::new("git"); @@ -69,6 +107,7 @@ fn add_helper_to_path(command: &mut Command) { #[test] fn carrier_git_supports_native_push_and_clone_without_plaintext() { let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); let carrier = temporary.path().join("carrier.git"); let source = temporary.path().join("source"); let clone = temporary.path().join("clone"); @@ -141,6 +180,7 @@ fn carrier_git_supports_native_push_and_clone_without_plaintext() { #[test] fn carrier_git_allows_exactly_one_concurrent_writer() { let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); let carrier = temporary.path().join("carrier.git"); let source_a = temporary.path().join("source-a"); let source_b = temporary.path().join("source-b"); @@ -203,9 +243,282 @@ fn carrier_git_allows_exactly_one_concurrent_writer() { ); } +#[test] +fn carrier_cache_fetches_only_new_objects() { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let carrier = temporary.path().join("carrier.git"); + git( + temporary.path(), + &["init", "--bare", "-q", carrier.to_str().unwrap()], + false, + ); + let first = "1".repeat(64); + let second = "2".repeat(64); + + let initial = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + initial.compare_and_swap_head(None, &first).unwrap(); + drop(initial); + + let cached = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let cache_root = temporary.path().join("carrier-cache"); + let cache = cache_repo(&cache_root); + let before_push = reachable_object_count(&cache); + let remote_before = reachable_object_count(&carrier); + assert_eq!(before_push, remote_before); + + cached.compare_and_swap_head(Some(&first), &second).unwrap(); + drop(cached); + let remote_after = reachable_object_count(&carrier); + assert!(remote_after > remote_before); + + let updated = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let after_push = reachable_object_count(&cache); + assert_eq!(after_push - before_push, remote_after - remote_before); + assert_eq!(after_push, remote_after); + drop(updated); + + let unchanged = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + assert_eq!(reachable_object_count(&cache), after_push); + drop(unchanged); +} + +#[test] +fn carrier_cache_does_not_hide_remote_access_errors() { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let missing_remote = temporary.path().join("missing-carrier.git"); + let error = match GitStorage::open(missing_remote.to_str().unwrap()) { + Ok(_) => panic!("opening a missing carrier unexpectedly succeeded"), + Err(error) => error, + }; + assert!( + error.to_string().contains("fetch") || error.to_string().contains("ls-remote"), + "unexpected remote error: {error:#}" + ); +} + +#[test] +fn stale_carrier_cache_is_refreshed_before_compare_and_swap() { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let carrier = temporary.path().join("carrier.git"); + git( + temporary.path(), + &["init", "--bare", "-q", carrier.to_str().unwrap()], + false, + ); + let stale = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let winner = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let winner_head = "3".repeat(64); + let stale_head = "4".repeat(64); + + winner.compare_and_swap_head(None, &winner_head).unwrap(); + let error = stale.compare_and_swap_head(None, &stale_head).unwrap_err(); + let conflict = error.downcast_ref::().unwrap(); + assert_eq!(conflict.actual.as_deref(), Some(winner_head.as_str())); + + let remote_head = git( + temporary.path(), + &[ + &format!("--git-dir={}", carrier.display()), + "show", + "refs/heads/git-remote-e2ee:e2ee/HEAD", + ], + false, + ); + assert_eq!(remote_head, winner_head); +} + +#[test] +fn deleted_and_corrupt_carrier_caches_are_rebuilt() { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let carrier = temporary.path().join("carrier.git"); + git( + temporary.path(), + &["init", "--bare", "-q", carrier.to_str().unwrap()], + false, + ); + let head = "5".repeat(64); + let storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + storage.compare_and_swap_head(None, &head).unwrap(); + drop(storage); + + let populated = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + assert_eq!( + populated.read_head().unwrap().as_deref(), + Some(head.as_str()) + ); + drop(populated); + let cache_root = temporary.path().join("carrier-cache"); + let cache = cache_repo(&cache_root); + + fs::remove_dir_all(&cache).unwrap(); + let rebuilt = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + assert_eq!(rebuilt.read_head().unwrap().as_deref(), Some(head.as_str())); + drop(rebuilt); + + let cache = cache_repo(&cache_root); + let object_listing = git(&cache, &["rev-list", "--objects", "--all"], false); + let object_id = object_listing + .lines() + .next() + .and_then(|line| line.split_whitespace().next()) + .expect("fetched carrier commit"); + let loose_object = cache + .join("objects") + .join(&object_id[..2]) + .join(&object_id[2..]); + if loose_object.exists() { + // Unlink first because local Git transports may hard-link loose objects. + fs::remove_file(&loose_object).unwrap(); + fs::write(&loose_object, b"corrupt cache object").unwrap(); + } else { + let pack = fs::read_dir(cache.join("objects/pack")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.extension() + .is_some_and(|extension| extension == "pack") + }) + .expect("fetched carrier objects are packed"); + fs::write(pack, b"corrupt cache pack").unwrap(); + } + + let recovered = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + assert_eq!( + recovered.read_head().unwrap().as_deref(), + Some(head.as_str()) + ); + assert_eq!( + reachable_object_count(&cache_repo(&cache_root)), + reachable_object_count(&carrier) + ); +} + +#[test] +fn carrier_git_process_race_has_exactly_one_winner() { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let carrier = temporary.path().join("carrier.git"); + git( + temporary.path(), + &["init", "--bare", "-q", carrier.to_str().unwrap()], + false, + ); + let ready_a = temporary.path().join("ready-a"); + let ready_b = temporary.path().join("ready-b"); + let result_a = temporary.path().join("result-a"); + let result_b = temporary.path().join("result-b"); + let start = temporary.path().join("start"); + let executable = env::current_exe().unwrap(); + let launch = |candidate: &str, ready: &Path, result: &Path| { + Command::new(&executable) + .args([ + "--ignored", + "--exact", + "carrier_git_process_race_worker", + "--nocapture", + ]) + .env("E2EE_TEST_RACE_REMOTE", &carrier) + .env("E2EE_TEST_RACE_CANDIDATE", candidate) + .env("E2EE_TEST_RACE_READY", ready) + .env("E2EE_TEST_RACE_RESULT", result) + .env("E2EE_TEST_RACE_START", &start) + .spawn() + .unwrap() + }; + let first = launch(&"6".repeat(64), &ready_a, &result_a); + let second = launch(&"7".repeat(64), &ready_b, &result_b); + + let deadline = Instant::now() + Duration::from_secs(10); + while (!ready_a.exists() || !ready_b.exists()) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!( + ready_a.exists() && ready_b.exists(), + "race workers did not become ready" + ); + fs::write(&start, "go").unwrap(); + + let first_output = first.wait_with_output().unwrap(); + let second_output = second.wait_with_output().unwrap(); + assert!( + first_output.status.success(), + "first worker failed: {}", + String::from_utf8_lossy(&first_output.stderr) + ); + assert!( + second_output.status.success(), + "second worker failed: {}", + String::from_utf8_lossy(&second_output.stderr) + ); + let results = [ + fs::read_to_string(result_a).unwrap(), + fs::read_to_string(result_b).unwrap(), + ]; + assert_eq!( + results + .iter() + .filter(|result| result.as_str() == "winner") + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| result.as_str() == "conflict") + .count(), + 1 + ); + assert_eq!( + git( + temporary.path(), + &[ + &format!("--git-dir={}", carrier.display()), + "rev-list", + "--count", + "refs/heads/git-remote-e2ee", + ], + false, + ), + "1" + ); +} + +#[test] +#[ignore = "launched as a separate process by carrier_git_process_race_has_exactly_one_winner"] +fn carrier_git_process_race_worker() { + let Ok(remote) = env::var("E2EE_TEST_RACE_REMOTE") else { + return; + }; + let candidate = env::var("E2EE_TEST_RACE_CANDIDATE").unwrap(); + let ready = env::var("E2EE_TEST_RACE_READY").unwrap(); + let result = env::var("E2EE_TEST_RACE_RESULT").unwrap(); + let start = env::var("E2EE_TEST_RACE_START").unwrap(); + let storage = GitStorage::open(&remote).unwrap(); + fs::write(ready, "ready").unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !Path::new(&start).exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!( + Path::new(&start).exists(), + "parent did not release CAS race" + ); + let outcome = match storage.compare_and_swap_head(None, &candidate) { + Ok(()) => "winner", + Err(error) if error.downcast_ref::().is_some() => "conflict", + Err(error) => panic!("unexpected CAS race failure: {error:#}"), + }; + fs::write(result, outcome).unwrap(); +} + #[test] fn native_fetch_rejects_outer_carrier_rollback() { let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); let carrier = temporary.path().join("carrier.git"); let source = temporary.path().join("source"); let clone = temporary.path().join("clone"); @@ -272,6 +585,7 @@ fn native_fetch_rejects_outer_carrier_rollback() { #[test] fn carrier_git_detects_tampered_ciphertext_chunk() { let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); let carrier = temporary.path().join("carrier.git"); let source = temporary.path().join("source"); let attacker = temporary.path().join("attacker"); @@ -327,6 +641,7 @@ fn carrier_git_detects_tampered_ciphertext_chunk() { #[test] fn carrier_branch_coexists_with_unrelated_repository_history() { let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); let carrier = temporary.path().join("carrier.git"); let ordinary = temporary.path().join("ordinary"); init_source(&ordinary, "ordinary public content"); From 0078c0d2b0554cd8276854f6a4893ccd0ae130a7 Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 14:27:47 +0900 Subject: [PATCH 2/2] Add read-only device roles `git-e2ee device-add --role read|write|admin` assigns a role. Read-only devices receive generation keys but cannot author accepted transitions, and `device-list` shows each device's role. --- src/main.rs | 49 +++++--- src/manifest.rs | 24 ++++ src/policy.rs | 40 +++++++ src/repository.rs | 11 +- tests/cli_roles.rs | 106 +++++++++++++++++ tests/multi_device.rs | 43 +++++++ tests/read_only_helper.rs | 236 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 495 insertions(+), 14 deletions(-) create mode 100644 tests/cli_roles.rs create mode 100644 tests/read_only_helper.rs diff --git a/src/main.rs b/src/main.rs index 09ed287..69bc0da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use anyhow::Result; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use git_remote_e2ee::crypto::{KeyFile, PublicDevice}; use git_remote_e2ee::policy::DeviceRoles; @@ -15,6 +15,23 @@ struct Cli { command: Command, } +#[derive(Clone, Copy, Debug, ValueEnum)] +enum DeviceRole { + Read, + Write, + Admin, +} + +impl From for DeviceRoles { + fn from(role: DeviceRole) -> Self { + match role { + DeviceRole::Read => Self::reader(), + DeviceRole::Write => Self::collaborator(), + DeviceRole::Admin => Self::owner(), + } + } +} + #[derive(Subcommand)] enum Command { Keygen { @@ -78,8 +95,8 @@ enum Command { key: PathBuf, #[arg(long)] device: PathBuf, - #[arg(long)] - admin: bool, + #[arg(long, value_enum, default_value = "write")] + role: DeviceRole, }, DeviceRevoke { #[arg(long, conflicts_with = "remote", required_unless_present = "remote")] @@ -178,16 +195,12 @@ fn main() -> Result<()> { remote, key, device, - admin, + role, } => { let pin = admin_pin_path(&key); let public = PublicDevice::read(&device)?; let device_id = public.device_id.clone(); - let roles = if admin { - DeviceRoles::owner() - } else { - DeviceRoles::collaborator() - }; + let roles = DeviceRoles::from(role); let (manifest, policy) = match (storage, remote) { (Some(storage), None) => { EncryptedRepository::new(FilesystemStorage::new(storage), KeyFile::read(&key)?) @@ -239,11 +252,9 @@ fn main() -> Result<()> { }; for device in devices { println!( - "{} reader={} writer={} admin={} status={}", + "{} role={} status={}", device.public.device_id, - device.roles.reader, - device.roles.writer, - device.roles.administrator, + role_name(&device.roles), if device.active() { "active" } else { "revoked" } ); } @@ -252,6 +263,18 @@ fn main() -> Result<()> { Ok(()) } +fn role_name(roles: &DeviceRoles) -> &'static str { + if roles.administrator { + "admin" + } else if roles.writer { + "write" + } else if roles.reader { + "read" + } else { + "none" + } +} + fn admin_pin_path(key: &std::path::Path) -> PathBuf { let mut value = key.as_os_str().to_owned(); value.push(".admin-state.json"); diff --git a/src/manifest.rs b/src/manifest.rs index 8d535f1..6554e04 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1122,6 +1122,30 @@ mod tests { assert!(error.contains("does not match its sealed audit entry")); } + #[test] + fn reader_only_device_cannot_sign_an_ordinary_manifest() { + let owner = KeyFile::generate(); + let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let (genesis, _) = PolicyState::genesis(&owner).unwrap(); + let mut devices = genesis.body.devices.clone(); + devices.push(DeviceRecord { + public: reader.public_device().unwrap(), + roles: DeviceRoles::reader(), + revoked_at: None, + }); + let (policy, _) = PolicyState::successor(&genesis, devices, &owner).unwrap(); + let generation_key = random_key(); + + // seal_manifest can construct a cryptographically valid signature from + // any key. Verification must still enforce the signer's policy role. + let encrypted = writer_manifest(&reader, &policy, &generation_key); + let opened = open_manifest(&encrypted, &generation_key).unwrap(); + let error = verify_manifest(&opened, &policy, None, &generation_key) + .unwrap_err() + .to_string(); + assert!(error.contains("manifest was not signed by an active writer")); + } + #[test] fn reader_trials_all_envelopes_and_reports_inactive_membership_clearly() { let owner = KeyFile::generate(); diff --git a/src/policy.rs b/src/policy.rs index 8ccceb9..5d51e02 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -22,6 +22,14 @@ pub struct DeviceRoles { } impl DeviceRoles { + pub fn reader() -> Self { + Self { + reader: true, + writer: false, + administrator: false, + } + } + pub fn owner() -> Self { Self { reader: true, @@ -265,6 +273,11 @@ impl PolicyState { .is_some_and(|device| device.active() && device.roles.writer) } + pub fn is_active_reader(&self, device_id: &str) -> bool { + self.device(device_id) + .is_some_and(|device| device.active() && device.roles.reader) + } + pub fn is_active_admin(&self, device_id: &str) -> bool { self.device(device_id) .is_some_and(|device| device.active() && device.roles.administrator) @@ -342,6 +355,33 @@ mod tests { assert!(unauthorized.validate_successor(&parent).is_err()); } + #[test] + fn writers_and_administrators_must_also_be_readers() { + let owner = KeyFile::generate(); + let (parent, _) = PolicyState::genesis(&owner).unwrap(); + for roles in [ + DeviceRoles { + reader: false, + writer: true, + administrator: false, + }, + DeviceRoles { + reader: false, + writer: false, + administrator: true, + }, + ] { + let added = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let mut devices = parent.body.devices.clone(); + devices.push(DeviceRecord { + public: added.public_device().unwrap(), + roles, + revoked_at: None, + }); + assert!(PolicyState::successor(&parent, devices, &owner).is_err()); + } + } + #[test] fn genesis_cannot_be_substituted_under_a_known_repository_root() { let expected = KeyFile::generate(); diff --git a/src/repository.rs b/src/repository.rs index ca0a21a..b6e7bfa 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -349,7 +349,16 @@ impl EncryptedRepository { git::ensure_repository(repo)?; let chain = self.current_chain()?; let current = chain.first().context("manifest chain is empty")?.clone(); - if !current.policy.is_active_writer(&self.key.device_id()?) { + let device_id = self.key.device_id()?; + if !current.policy.is_active_writer(&device_id) { + if current.policy.is_active_reader(&device_id) { + if let Some(remote_name) = remote_name { + bail!( + "this device is read-only for {remote_name}; ask an administrator for the write role" + ) + } + bail!("this device is read-only; ask an administrator for the write role") + } bail!("device is not an active writer") } if let Some(remote_name) = remote_name { diff --git a/tests/cli_roles.rs b/tests/cli_roles.rs new file mode 100644 index 0000000..e19e80b --- /dev/null +++ b/tests/cli_roles.rs @@ -0,0 +1,106 @@ +use std::path::Path; +use std::process::{Command, Output}; + +use git_remote_e2ee::crypto::KeyFile; + +fn run_cli(args: &[String]) -> Output { + Command::new(env!("CARGO_BIN_EXE_git-e2ee")) + .args(args) + .output() + .unwrap() +} + +fn path_arg(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn add_device_args(storage: &Path, key: &Path, public: &Path) -> Vec { + vec![ + "device-add".to_owned(), + "--storage".to_owned(), + path_arg(storage), + "--key".to_owned(), + path_arg(key), + "--device".to_owned(), + path_arg(public), + ] +} + +#[test] +fn device_add_parses_roles_and_device_list_uses_role_names() { + let temporary = tempfile::tempdir().unwrap(); + let storage = temporary.path().join("storage"); + let owner_path = temporary.path().join("owner.key.json"); + let owner = KeyFile::generate(); + owner.write_new(&owner_path).unwrap(); + + let initialized = run_cli(&[ + "init".to_owned(), + "--storage".to_owned(), + path_arg(&storage), + "--key".to_owned(), + path_arg(&owner_path), + ]); + assert!( + initialized.status.success(), + "git-e2ee init: {}", + String::from_utf8_lossy(&initialized.stderr) + ); + + let roles = [ + ("default", None, "write"), + ("reader", Some("read"), "read"), + ("writer", Some("write"), "write"), + ("admin", Some("admin"), "admin"), + ]; + let mut device_ids = Vec::new(); + for (name, selected_role, expected_role) in roles { + let device_key = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let public = device_key.public_device().unwrap(); + let public_path = temporary.path().join(format!("{name}.public.json")); + public.write_new(&public_path).unwrap(); + let mut args = add_device_args(&storage, &owner_path, &public_path); + if let Some(role) = selected_role { + args.extend(["--role".to_owned(), role.to_owned()]); + } + let added = run_cli(&args); + assert!( + added.status.success(), + "git-e2ee device-add {name}: {}", + String::from_utf8_lossy(&added.stderr) + ); + device_ids.push((public.device_id, expected_role)); + } + + let mut legacy_args = add_device_args( + &storage, + &owner_path, + &temporary.path().join("default.public.json"), + ); + legacy_args.push("--admin".to_owned()); + let legacy = run_cli(&legacy_args); + assert!(!legacy.status.success()); + assert!(String::from_utf8_lossy(&legacy.stderr).contains("--admin")); + + let listed = run_cli(&[ + "device-list".to_owned(), + "--storage".to_owned(), + path_arg(&storage), + "--key".to_owned(), + path_arg(&owner_path), + ]); + assert!( + listed.status.success(), + "git-e2ee device-list: {}", + String::from_utf8_lossy(&listed.stderr) + ); + let output = String::from_utf8(listed.stdout).unwrap(); + for (device_id, role) in device_ids { + assert!( + output.contains(&format!("{device_id} role={role} status=active")), + "missing role {role} for {device_id} in:\n{output}" + ); + } + let owner_id = owner.device_id().unwrap(); + assert!(output.contains(&format!("{owner_id} role=admin status=active"))); +} diff --git a/tests/multi_device.rs b/tests/multi_device.rs index fc1b370..93bc578 100644 --- a/tests/multi_device.rs +++ b/tests/multi_device.rs @@ -341,6 +341,49 @@ fn non_admin_device_cannot_change_policy() { ); } +#[test] +fn read_only_device_cannot_add_or_revoke_devices() { + let temp = tempfile::tempdir().unwrap(); + let remote = temp.path().join("remote"); + let owner_key = KeyFile::generate(); + let reader_key = KeyFile::generate_for_repository(owner_key.repository_root.clone()).unwrap(); + let candidate_key = + KeyFile::generate_for_repository(owner_key.repository_root.clone()).unwrap(); + let admin_pin = temp.path().join("owner-admin-state.json"); + let owner = EncryptedRepository::new(FilesystemStorage::new(&remote), owner_key); + owner.initialize().unwrap(); + owner.pin_admin_state(&admin_pin).unwrap(); + owner + .add_device( + reader_key.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + + let reader = EncryptedRepository::new(FilesystemStorage::new(&remote), reader_key.clone()); + let before = snapshot_files(&remote); + assert!( + reader + .add_device( + candidate_key.public_device().unwrap(), + DeviceRoles::collaborator(), + &admin_pin, + ) + .unwrap_err() + .to_string() + .contains("not an active administrator") + ); + assert!( + reader + .revoke_device(&reader_key.device_id().unwrap(), &admin_pin) + .unwrap_err() + .to_string() + .contains("not an active administrator") + ); + assert_eq!(snapshot_files(&remote), before); +} + fn copy_directory(source: &Path, destination: &Path) { fs::create_dir_all(destination).unwrap(); for entry in fs::read_dir(source).unwrap() { diff --git a/tests/read_only_helper.rs b/tests/read_only_helper.rs new file mode 100644 index 0000000..8e1716e --- /dev/null +++ b/tests/read_only_helper.rs @@ -0,0 +1,236 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::{Mutex, MutexGuard}; + +use git_remote_e2ee::crypto::KeyFile; +use git_remote_e2ee::policy::DeviceRoles; +use git_remote_e2ee::repository::EncryptedRepository; +use git_remote_e2ee::storage::{FilesystemStorage, GitStorage, Storage}; + +static CACHE_ENV_LOCK: Mutex<()> = Mutex::new(()); + +fn git_output(repo: &Path, args: &[&str], with_helper: bool) -> Output { + let mut command = Command::new("git"); + command.arg("-C").arg(repo).args(args); + if with_helper { + add_helper_to_path(&mut command); + } + command.output().unwrap() +} + +fn git(repo: &Path, args: &[&str], with_helper: bool) -> String { + let output = git_output(repo, args, with_helper); + assert!( + output.status.success(), + "git {:?}: stdout={} stderr={}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn add_helper_to_path(command: &mut Command) { + let helper = Path::new(env!("CARGO_BIN_EXE_git-remote-e2ee")); + let mut paths = vec![helper.parent().unwrap().to_path_buf()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + command.env("PATH", env::join_paths(paths).unwrap()); +} + +fn initialize_git(repo: &Path) { + fs::create_dir_all(repo).unwrap(); + git(repo, &["init", "-q", "-b", "main"], false); + git(repo, &["config", "user.name", "Read Only Test"], false); + git( + repo, + &["config", "user.email", "read-only@example.invalid"], + false, + ); +} + +fn commit(repo: &Path, contents: &str, message: &str) -> String { + fs::write(repo.join("note.md"), contents).unwrap(); + git(repo, &["add", "note.md"], false); + git(repo, &["commit", "-q", "-m", message], false); + git(repo, &["rev-parse", "HEAD"], false) +} + +fn configure_remote(repo: &Path, name: &str, url: &str, key_path: &Path) { + git(repo, &["remote", "add", name, url], false); + git( + repo, + &[ + "config", + &format!("remote.{name}.e2ee-key"), + key_path.to_str().unwrap(), + ], + false, + ); +} + +fn snapshot_tree(root: &Path) -> Vec<(PathBuf, Vec)> { + fn visit(root: &Path, current: &Path, files: &mut Vec<(PathBuf, Vec)>) { + for entry in fs::read_dir(current).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if entry.file_type().unwrap().is_dir() { + visit(root, &path, files); + } else { + files.push(( + path.strip_prefix(root).unwrap().to_path_buf(), + fs::read(path).unwrap(), + )); + } + } + } + + let mut files = Vec::new(); + visit(root, root, &mut files); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files +} + +fn exercise_read_only_helper( + temporary: &Path, + storage: S, + remote_path: &Path, + remote_url: &str, +) { + fs::create_dir_all(temporary).unwrap(); + let owner_key_path = temporary.join("owner.key.json"); + let reader_key_path = temporary.join("reader.key.json"); + let owner_key = KeyFile::generate(); + let reader_key = KeyFile::generate_for_repository(owner_key.repository_root.clone()).unwrap(); + owner_key.write_new(&owner_key_path).unwrap(); + reader_key.write_new(&reader_key_path).unwrap(); + + let owner = EncryptedRepository::new(storage, owner_key); + owner.initialize().unwrap(); + let admin_pin = temporary.join("owner.admin-state.json"); + owner.pin_admin_state(&admin_pin).unwrap(); + owner + .add_device( + reader_key.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + + let source = temporary.join("writer"); + initialize_git(&source); + let first = commit(&source, "initial writer content\n", "initial"); + configure_remote(&source, "private", remote_url, &owner_key_path); + git(&source, &["push", "private", "main"], true); + + let clone = temporary.join("reader-clone"); + let mut clone_command = Command::new("git"); + clone_command + .args([ + "-c", + &format!("e2ee.key={}", reader_key_path.display()), + "clone", + "-q", + remote_url, + ]) + .arg(&clone); + add_helper_to_path(&mut clone_command); + let output = clone_command.output().unwrap(); + assert!( + output.status.success(), + "reader clone failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(git(&clone, &["rev-parse", "HEAD"], false), first); + assert_eq!( + fs::read_to_string(clone.join("note.md")).unwrap(), + "initial writer content\n" + ); + + let second = commit(&source, "fetched writer content\n", "second"); + git(&source, &["push", "private", "main"], true); + git(&clone, &["fetch", "origin"], true); + assert_eq!( + git(&clone, &["rev-parse", "refs/remotes/origin/main"], false), + second + ); + + let third = commit(&source, "pulled writer content\n", "third"); + git(&source, &["push", "private", "main"], true); + git(&clone, &["pull", "--ff-only"], true); + assert_eq!(git(&clone, &["rev-parse", "HEAD"], false), third); + assert_eq!( + fs::read_to_string(clone.join("note.md")).unwrap(), + "pulled writer content\n" + ); + + // A fresh clone has no identity; CI runners have no global Git identity. + git(&clone, &["config", "user.name", "Read Only Test"], false); + git( + &clone, + &["config", "user.email", "read-only@example.invalid"], + false, + ); + commit(&clone, "local reader change\n", "reader change"); + let expected_error = + "this device is read-only for origin; ask an administrator for the write role"; + for args in [ + vec!["push", "origin", "HEAD:refs/heads/main"], + vec!["push", "--dry-run", "origin", "HEAD:refs/heads/main"], + vec!["push", "--force", "origin", "HEAD:refs/heads/main"], + ] { + let before = snapshot_tree(remote_path); + let output = git_output(&clone, &args, true); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "push unexpectedly succeeded: {stderr}" + ); + assert!( + stderr.contains(expected_error), + "push error did not explain the read-only role: {stderr}" + ); + assert_eq!( + snapshot_tree(remote_path), + before, + "rejected push changed storage" + ); + } +} + +#[test] +fn read_only_devices_use_both_storage_backends_without_publishing() { + let temporary = tempfile::tempdir().unwrap(); + let filesystem_remote = temporary.path().join("filesystem-remote"); + let filesystem_url = format!("e2ee::{}", filesystem_remote.display()); + exercise_read_only_helper( + temporary.path(), + FilesystemStorage::new(&filesystem_remote), + &filesystem_remote, + &filesystem_url, + ); + + let _cache_lock: MutexGuard<'static, ()> = CACHE_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let cache_dir = temporary.path().join("isolated-carrier-cache"); + // Keep the carrier cache and all test objects outside the real user cache. + unsafe { env::set_var("GIT_REMOTE_E2EE_CACHE_DIR", &cache_dir) }; + + let carrier = temporary.path().join("carrier.git"); + let initialize_output = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&carrier) + .output() + .unwrap(); + assert!(initialize_output.status.success()); + let carrier_url = format!("e2ee::git+{}", carrier.display()); + exercise_read_only_helper( + &temporary.path().join("carrier-case"), + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + &carrier, + &carrier_url, + ); +}