use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Context as _;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::cli::drive::format::JsonlSerialize;
use crate::cli::format::sanitize_for_terminal;
use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::lease::authenticate::{AuthOutcome, AuthPolicy, Authenticator};
use crate::drive::lease::ledger::{
default_lock_wait_timeout, LeaseBackup, LeaseLedger, LeaseRecord, LedgerLock, ReleaseOutcome,
};
use crate::drive::types::DriveFile;
#[derive(Debug, Clone)]
pub struct AcquireOptions {
pub file_id: String,
pub backup_dir: PathBuf,
pub native_backup_folder_id: Option<String>,
pub expiry: ChronoDuration,
pub auth_policy: AuthPolicy,
pub ledger_path: PathBuf,
pub allow_headless: bool,
pub supersedes: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum AcquireResult {
Acquired {
token: String,
expires_at: DateTime<Utc>,
backup: LeaseBackup,
headless_waiver: bool,
#[serde(skip_serializing_if = "Option::is_none")]
superseded_lease_id: Option<String>,
},
AlreadyLeased {
token: String,
expires_at: DateTime<Utc>,
},
RefusedNativeDocument,
RefusedConcurrentChange {
detail: String,
},
Denied {
detail: String,
},
Unavailable {
detail: String,
},
Failed {
detail: String,
},
}
impl JsonlSerialize for AcquireResult {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
crate::cli::drive::format::write_scalar_jsonl(self, out)
}
}
impl AcquireResult {
pub fn exit_code(&self) -> i32 {
match self {
Self::Acquired { .. } | Self::AlreadyLeased { .. } => 0,
Self::RefusedNativeDocument
| Self::RefusedConcurrentChange { .. }
| Self::Denied { .. }
| Self::Unavailable { .. }
| Self::Failed { .. } => 1,
}
}
}
pub async fn acquire(
client: &DriveClient,
opts: &AcquireOptions,
authenticator: &dyn Authenticator,
) -> AcquireResult {
acquire_with_timings(client, opts, authenticator, AcquireTimings::production()).await
}
const SANDWICH_ATTEMPTS: u32 = 3;
const SANDWICH_RETRY_DELAY: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy)]
pub(crate) struct AcquireTimings {
pub(crate) lock_wait: Duration,
pub(crate) sandwich_retry_delay: Duration,
}
impl AcquireTimings {
fn production() -> Self {
Self {
lock_wait: default_lock_wait_timeout(),
sandwich_retry_delay: SANDWICH_RETRY_DELAY,
}
}
}
pub(crate) async fn acquire_with_timings(
client: &DriveClient,
opts: &AcquireOptions,
authenticator: &dyn Authenticator,
timings: AcquireTimings,
) -> AcquireResult {
let (result, disposition) = acquire_inner(client, opts, authenticator, timings).await;
record_attempt(opts, &result, disposition.as_ref());
result
}
pub(crate) const MIN_EXPIRY_MINUTES: i64 = 1;
pub(crate) const MAX_EXPIRY_MINUTES: i64 = 24 * 60;
enum BackupDisposition {
Reclaimed(LeaseBackup),
ReclaimFailed(LeaseBackup),
}
async fn acquire_inner(
client: &DriveClient,
opts: &AcquireOptions,
authenticator: &dyn Authenticator,
timings: AcquireTimings,
) -> (AcquireResult, Option<BackupDisposition>) {
let expiry_minutes = opts.expiry.num_minutes();
if !(MIN_EXPIRY_MINUTES..=MAX_EXPIRY_MINUTES).contains(&expiry_minutes) {
return (
AcquireResult::Failed {
detail: format!(
"expiry must be between {MIN_EXPIRY_MINUTES} and {MAX_EXPIRY_MINUTES} \
minutes, got {expiry_minutes}"
),
},
None,
);
}
let files_api = FilesApi::new(client);
let target = match files_api.get_metadata(&opts.file_id).await {
Ok(target) => target,
Err(err) => {
return (
AcquireResult::Failed {
detail: err.to_string(),
},
None,
)
}
};
let is_native = target.is_google_native();
if is_native && opts.native_backup_folder_id.is_none() {
return (AcquireResult::RefusedNativeDocument, None);
}
if target.version.is_none() {
return (
AcquireResult::Failed {
detail: "Drive did not return a `version` for this file; refusing to lease it \
without a staleness check"
.to_string(),
},
None,
);
}
let pre_check = tokio::task::block_in_place(|| LeaseLedger::load(&opts.ledger_path));
match pre_check {
Ok(ledger) => {
if let Some(existing) =
ledger.live_lease_for_file(&opts.file_id, Utc::now(), opts.supersedes.as_deref())
{
return (
AcquireResult::AlreadyLeased {
token: existing.token.clone(),
expires_at: existing.expires_at,
},
None,
);
}
}
Err(err) => {
return (
AcquireResult::Failed {
detail: err.to_string(),
},
None,
)
}
}
let reason = format!(
"back up and lease-write '{}'",
sanitize_for_terminal(&target.name)
);
let auth_outcome =
tokio::task::block_in_place(|| authenticator.authenticate(&reason, opts.auth_policy));
let headless_waiver = match auth_outcome {
AuthOutcome::Authorized => false,
AuthOutcome::Denied(detail) => return (AcquireResult::Denied { detail }, None),
AuthOutcome::NoAuthenticator(detail) => {
if !opts.allow_headless {
return (AcquireResult::Unavailable { detail }, None);
}
true
}
AuthOutcome::PolicyUnsatisfiable(detail) => {
let detail = if opts.allow_headless {
format!(
"{detail} — the headless opt-out does not apply: it waives only a context \
with no authenticator at all, not a policy this session cannot satisfy"
)
} else {
detail
};
return (AcquireResult::Unavailable { detail }, None);
}
};
let needs_version_pin = is_native || target.sha256_checksum.is_none();
let pinned = match take_pinned_backup(
&files_api,
opts,
&target.name,
is_native,
needs_version_pin,
timings.sandwich_retry_delay,
)
.await
{
Ok(pinned) => pinned,
Err(refusal) => return *refusal,
};
let backup = pinned.backup.clone();
let result = mint(opts, pinned, headless_waiver, timings.lock_wait);
if matches!(result, AcquireResult::Acquired { .. }) {
return (result, None);
}
let disposition = reclaim_backup(&files_api, backup).await;
(result, Some(disposition))
}
struct PinnedBackup {
backup: LeaseBackup,
version: String,
modified_time: Option<String>,
}
async fn take_pinned_backup(
files_api: &FilesApi<'_>,
opts: &AcquireOptions,
name: &str,
is_native: bool,
needs_version_pin: bool,
retry_delay: Duration,
) -> Result<PinnedBackup, Box<(AcquireResult, Option<BackupDisposition>)>> {
let mut attempt = 1;
loop {
let pre_backup_version = if needs_version_pin {
let version = read_pre_backup_version(files_api, &opts.file_id)
.await
.map_err(|detail| Box::new((AcquireResult::Failed { detail }, None)))?;
Some(version)
} else {
None
};
let backup = take_backup(files_api, opts, name, is_native)
.await
.map_err(|err| {
Box::new((
AcquireResult::Failed {
detail: err.to_string(),
},
None,
))
})?;
let failure = match prove_pinned(
files_api,
&opts.file_id,
&backup,
pre_backup_version.as_deref(),
)
.await
{
PinCheck::Pinned {
version,
modified_time,
} => {
return Ok(PinnedBackup {
backup,
version,
modified_time,
})
}
PinCheck::Failed(detail) => {
let disposition = reclaim_backup(files_api, backup).await;
return Err(Box::new((
AcquireResult::Failed { detail },
Some(disposition),
)));
}
PinCheck::Unpinned(failure) => failure,
};
let disposition = reclaim_backup(files_api, backup).await;
let retry = failure.is_retryable()
&& attempt < SANDWICH_ATTEMPTS
&& matches!(disposition, BackupDisposition::Reclaimed(_));
if !retry {
return Err(Box::new((
AcquireResult::RefusedConcurrentChange {
detail: failure.detail(
attempt,
matches!(disposition, BackupDisposition::Reclaimed(_)),
),
},
Some(disposition),
)));
}
tracing::info!(
"drive lease acquire: the file's version moved across its backup (attempt \
{attempt} of {SANDWICH_ATTEMPTS}); discarded that backup, retrying"
);
tokio::time::sleep(retry_delay).await;
attempt += 1;
}
}
async fn read_pre_backup_version(
files_api: &FilesApi<'_>,
file_id: &str,
) -> Result<String, String> {
match files_api.get_metadata(file_id).await {
Ok(DriveFile {
version: Some(version),
..
}) => Ok(version),
Ok(_) => Err(
"Drive did not return a `version` for this file immediately before the \
backup; refusing to take a backup that could not be pinned to one"
.to_string(),
),
Err(err) => Err(err.to_string()),
}
}
async fn take_backup(
files_api: &FilesApi<'_>,
opts: &AcquireOptions,
name: &str,
is_native: bool,
) -> anyhow::Result<LeaseBackup> {
if is_native {
let folder_id = opts.native_backup_folder_id.as_deref().unwrap_or_default();
native_backup(files_api, &opts.file_id, folder_id, name).await
} else {
byte_backup(files_api, &opts.file_id, &opts.backup_dir, name).await
}
}
enum PinCheck {
Pinned {
version: String,
modified_time: Option<String>,
},
Unpinned(PinFailure),
Failed(String),
}
async fn prove_pinned(
files_api: &FilesApi<'_>,
file_id: &str,
backup: &LeaseBackup,
pre_backup_version: Option<&str>,
) -> PinCheck {
let post_backup = match files_api.get_metadata(file_id).await {
Ok(post_backup) => post_backup,
Err(err) => return PinCheck::Failed(err.to_string()),
};
let Some(version) = post_backup.version else {
return PinCheck::Failed(
"Drive did not return a `version` for this file after the backup; refusing to \
record a lease without a staleness check"
.to_string(),
);
};
match backup_is_pinned(
backup,
post_backup.sha256_checksum.as_deref(),
&version,
pre_backup_version,
) {
Ok(()) => PinCheck::Pinned {
version,
modified_time: post_backup.modified_time,
},
Err(failure) => PinCheck::Unpinned(failure),
}
}
fn mint(
opts: &AcquireOptions,
pinned: PinnedBackup,
headless_waiver: bool,
lock_wait: Duration,
) -> AcquireResult {
let PinnedBackup {
backup,
version,
modified_time,
} = pinned;
let token = crate::request_log::new_id();
let now = Utc::now();
let expires_at = now + opts.expiry;
let record = LeaseRecord {
token: token.clone(),
file_id: opts.file_id.clone(),
version,
modified_time,
backup: backup.clone(),
acquired_at: now,
expires_at,
released_at: None,
superseded_by: None,
restored_at: None,
restored_sheet_id: None,
};
let superseded = match tokio::task::block_in_place(|| {
insert_record(
record,
&opts.ledger_path,
opts.supersedes.as_deref(),
lock_wait,
)
}) {
Ok(InsertOutcome::Inserted { superseded }) => superseded,
Ok(InsertOutcome::AlreadyLeased(existing)) => {
return AcquireResult::AlreadyLeased {
token: existing.token,
expires_at: existing.expires_at,
};
}
Err(err) => {
return AcquireResult::Failed {
detail: err.to_string(),
};
}
};
AcquireResult::Acquired {
token,
expires_at,
backup,
headless_waiver,
superseded_lease_id: opts.supersedes.clone().filter(|_| superseded),
}
}
#[derive(Debug)]
enum PinFailure {
ChecksumMismatch {
remote: String,
post_version: String,
local: String,
},
VersionMoved { pre: String, post: String },
NoProof { post_version: String },
}
impl PinFailure {
fn is_retryable(&self) -> bool {
matches!(self, Self::VersionMoved { .. })
}
fn detail(&self, attempts: u32, discarded: bool) -> String {
let what = match self {
Self::ChecksumMismatch {
remote,
post_version,
local,
} => format!(
"the file changed while its backup was being taken: Drive reports checksum \
{remote} at version {post_version}, but the bytes backed up hash to {local}."
),
Self::VersionMoved { pre, post } if attempts > 1 && discarded => format!(
"the file's version changed across its backup on each of {attempts} attempts \
(last: version {pre} immediately before, {post} immediately after). A version \
that keeps moving may be changing for reasons unrelated to edits, so waiting \
for the file to be quiet may not help."
),
Self::VersionMoved { pre, post } if attempts > 1 => format!(
"the file's version changed across its backup on each of {attempts} attempts \
(last: version {pre} immediately before, {post} immediately after)."
),
Self::VersionMoved { pre, post } => format!(
"the file changed while its backup was being taken: version {pre} immediately \
before, {post} immediately after."
),
Self::NoProof { post_version } => format!(
"no proof available that the backup matches version {post_version}: Drive \
reported no checksum for the file after the backup and no pre-backup version \
was taken to fall back on."
),
};
let outcome = match (discarded, attempts > 1) {
(true, false) => "No lease was minted and the backup was discarded — retry.",
(true, true) => "No lease was minted and the backups were discarded.",
(false, false) => {
"No lease was minted, but the backup could not be discarded — its location is \
in the audit log (`omni-dev log --audit`); remove it by hand, then retry."
}
(false, true) => {
"No lease was minted. Retrying stopped early because the last backup could not \
be discarded — its location is in the audit log (`omni-dev log --audit`); \
remove it by hand. Every earlier backup was discarded."
}
};
format!("{what} {outcome}")
}
}
fn backup_is_pinned(
backup: &LeaseBackup,
remote_sha256: Option<&str>,
post_version: &str,
pre_backup_version: Option<&str>,
) -> Result<(), PinFailure> {
if let (LeaseBackup::Bytes { sha256, .. }, Some(remote)) = (backup, remote_sha256) {
if !sha256.eq_ignore_ascii_case(remote) {
return Err(PinFailure::ChecksumMismatch {
remote: remote.to_string(),
post_version: post_version.to_string(),
local: sha256.clone(),
});
}
return Ok(());
}
match pre_backup_version {
Some(pre) if pre != post_version => Err(PinFailure::VersionMoved {
pre: pre.to_string(),
post: post_version.to_string(),
}),
Some(_) => Ok(()),
None => Err(PinFailure::NoProof {
post_version: post_version.to_string(),
}),
}
}
async fn reclaim_backup(files_api: &FilesApi<'_>, backup: LeaseBackup) -> BackupDisposition {
match super::prune::clear_backup(files_api, &backup).await {
Ok(()) => BackupDisposition::Reclaimed(backup),
Err(err) => {
let (location, ..) = backup.audit_fields();
tracing::warn!(
"drive lease acquire: failed to reclaim an orphaned backup at {}: {err} — \
`drive lease prune` cannot see it either, since no ledger row references it; \
remove it manually",
location.unwrap_or_default()
);
BackupDisposition::ReclaimFailed(backup)
}
}
}
enum InsertOutcome {
Inserted { superseded: bool },
AlreadyLeased(Box<LeaseRecord>),
}
async fn byte_backup(
files_api: &FilesApi<'_>,
file_id: &str,
backup_dir: &Path,
name: &str,
) -> anyhow::Result<LeaseBackup> {
let bytes = files_api.download(file_id).await?;
let sha256 = crate::cli::drive::read::to_hex_string(&Sha256::digest(&bytes));
let path = backup_file_path(backup_dir, file_id, name);
tokio::task::block_in_place(|| write_backup(&path, &bytes))?;
Ok(LeaseBackup::Bytes {
path,
sha256,
size: bytes.len() as u64,
})
}
async fn native_backup(
files_api: &FilesApi<'_>,
file_id: &str,
backup_folder_id: &str,
name: &str,
) -> anyhow::Result<LeaseBackup> {
let copy_name = backup_name(file_id, name);
let copy = files_api
.copy(file_id, backup_folder_id, ©_name)
.await?;
Ok(LeaseBackup::DriveCopy { file_id: copy.id })
}
fn backup_file_path(dir: &Path, file_id: &str, name: &str) -> PathBuf {
dir.join(backup_name(file_id, name))
}
fn backup_name(file_id: &str, name: &str) -> String {
let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
let safe_name = name.replace('/', "_");
format!("{timestamp}-{file_id}-{safe_name}")
}
fn write_backup(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
use std::io::Write as _;
crate::daemon::paths::ensure_parent_dir_0700(path)?;
let mut file = crate::daemon::paths::create_new_file_0600(path).map_err(|err| {
if crate::daemon::paths::is_already_exists_error(&err) {
err.context(format!(
"Failed to create backup file at {} — it may already exist from a \
near-simultaneous `drive lease acquire` on the same file within the same \
second; retry",
path.display()
))
} else {
err.context(format!(
"Failed to create backup file at {} — not a same-second collision; check \
filesystem permissions",
path.display()
))
}
})?;
file.write_all(bytes)
.with_context(|| format!("Failed to write backup to {}", path.display()))?;
Ok(())
}
fn insert_record(
record: LeaseRecord,
ledger_path: &Path,
supersedes: Option<&str>,
lock_wait: Duration,
) -> anyhow::Result<InsertOutcome> {
let lock = LedgerLock::acquire_waiting_blocking_with_timeout(ledger_path, lock_wait)?;
let new_token = record.token.clone();
LeaseLedger::mutate(&lock, ledger_path, |ledger| {
let now = Utc::now();
if let Some(existing) = ledger.live_lease_for_file(&record.file_id, now, supersedes) {
return InsertOutcome::AlreadyLeased(Box::new(existing.clone()));
}
let superseded = supersedes.is_some_and(|token| {
ledger
.get(token)
.is_some_and(|r| r.file_id == record.file_id)
&& matches!(
ledger.release(token, now, Some(new_token.as_str())),
ReleaseOutcome::Released { .. }
)
});
ledger.insert(record);
InsertOutcome::Inserted { superseded }
})
}
fn orphan_verdict(
base_verdict: &str,
disposition: Option<&BackupDisposition>,
) -> (String, Option<String>) {
let backup = match disposition {
None => return (base_verdict.to_string(), None),
Some(BackupDisposition::Reclaimed(backup)) => backup,
Some(BackupDisposition::ReclaimFailed(backup)) => {
let (location, ..) = backup.audit_fields();
return (format!("{base_verdict}-backup-orphaned"), location);
}
};
let (location, ..) = backup.audit_fields();
(base_verdict.to_string(), location)
}
fn record_attempt(
opts: &AcquireOptions,
result: &AcquireResult,
disposition: Option<&BackupDisposition>,
) {
let auth_policy = Some(
match opts.auth_policy {
AuthPolicy::DeviceOwner => "device-owner",
AuthPolicy::BiometricsOnly => "biometrics-only",
}
.to_string(),
);
let outcome = match result {
AcquireResult::Acquired {
token,
backup,
expires_at: _,
headless_waiver,
superseded_lease_id,
} => {
let (backup_location, backup_sha256, backup_size) = backup.audit_fields();
let (version_after, modified_time_after) = LeaseLedger::load(&opts.ledger_path)
.ok()
.and_then(|ledger| ledger.get(token).cloned())
.map_or((None, None), |record| {
(Some(record.version), record.modified_time)
});
crate::request_log::AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: opts.file_id.clone(),
lease_id: Some(token.clone()),
verdict: if *headless_waiver {
"acquired-headless-waiver".to_string()
} else {
"acquired".to_string()
},
version_after,
modified_time_after,
backup_location,
backup_sha256,
backup_size,
auth_policy,
superseded_lease_id: superseded_lease_id.clone(),
..Default::default()
}
}
AcquireResult::AlreadyLeased { token, .. } => {
let (verdict, backup_location) = orphan_verdict("already-leased", disposition);
crate::request_log::AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: opts.file_id.clone(),
lease_id: Some(token.clone()),
verdict,
backup_location,
auth_policy,
..Default::default()
}
}
AcquireResult::RefusedNativeDocument => crate::request_log::AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: opts.file_id.clone(),
verdict: "refused-native-document".to_string(),
auth_policy,
..Default::default()
},
AcquireResult::RefusedConcurrentChange { detail } => {
let (verdict, backup_location) =
orphan_verdict("refused-concurrent-change", disposition);
crate::request_log::AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: opts.file_id.clone(),
verdict,
error: Some(detail.clone()),
backup_location,
auth_policy,
..Default::default()
}
}
AcquireResult::Denied { detail } => crate::request_log::AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: opts.file_id.clone(),
verdict: "denied".to_string(),
error: Some(detail.clone()),
auth_policy,
..Default::default()
},
AcquireResult::Unavailable { detail } => crate::request_log::AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: opts.file_id.clone(),
verdict: "unavailable".to_string(),
error: Some(detail.clone()),
auth_policy,
..Default::default()
},
AcquireResult::Failed { detail } => {
let (verdict, backup_location) = orphan_verdict("failed", disposition);
crate::request_log::AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: opts.file_id.clone(),
verdict,
error: Some(detail.clone()),
backup_location,
auth_policy,
..Default::default()
}
}
};
if let Err(err) = crate::request_log::record_audit_event(outcome) {
tracing::warn!("drive lease acquire: failed to write audit record: {err}");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::lease::authenticate::Unsupported;
use crate::drive::test_support::FakeAuthenticator;
use crate::test_support::AuditLogGuard as AuditGuard;
use crate::utils::secret::Secret;
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})),
)
.mount(server)
.await;
let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
struct PanicsIfCalled(&'static str);
impl Authenticator for PanicsIfCalled {
fn authenticate(&self, _reason: &str, _policy: AuthPolicy) -> AuthOutcome {
panic!("{}", self.0);
}
}
fn opts(dir: &Path) -> AcquireOptions {
AcquireOptions {
file_id: "f1".to_string(),
backup_dir: dir.join("backups"),
native_backup_folder_id: None,
expiry: ChronoDuration::minutes(30),
auth_policy: AuthPolicy::DeviceOwner,
ledger_path: dir.join("lease-ledger.jsonl"),
allow_headless: false,
supersedes: None,
}
}
const TEST_LOCK_WAIT: Duration = Duration::from_secs(5);
const FAST_TIMINGS: AcquireTimings = AcquireTimings {
lock_wait: TEST_LOCK_WAIT,
sandwich_retry_delay: Duration::ZERO,
};
#[tokio::test(flavor = "multi_thread")]
async fn denied_authentication_takes_no_backup_and_writes_no_ledger_row() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/pdf", "version": "1"
})),
)
.expect(1)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&FakeAuthenticator(AuthOutcome::Denied("no".to_string())),
)
.await;
assert!(matches!(result, AcquireResult::Denied { .. }));
assert!(!root.path().join("backups").exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn unavailable_authentication_takes_no_backup() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/pdf", "version": "1"
})),
)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(&client, &opts(root.path()), &Unsupported).await;
assert!(matches!(result, AcquireResult::Unavailable { .. }));
assert!(!root.path().join("backups").exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn headless_opt_out_proceeds_without_an_authenticator() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/pdf", "version": "1"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"bytes".to_vec()))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&AcquireOptions {
allow_headless: true,
..opts(root.path())
},
&Unsupported,
)
.await;
let AcquireResult::Acquired {
headless_waiver, ..
} = result
else {
panic!("expected Acquired, got {result:?}");
};
assert!(headless_waiver);
assert!(root.path().join("backups").exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn headless_opt_out_never_waives_an_unsatisfiable_policy() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/pdf", "version": "1"
})),
)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&AcquireOptions {
allow_headless: true,
..opts(root.path())
},
&FakeAuthenticator(AuthOutcome::PolicyUnsatisfiable(
"Biometry is locked out.".to_string(),
)),
)
.await;
let AcquireResult::Unavailable { detail } = result else {
panic!("expected Unavailable, got {result:?}");
};
assert!(detail.starts_with("Biometry is locked out."), "{detail}");
assert!(
detail.contains("headless opt-out does not apply"),
"{detail}"
);
assert!(!root.path().join("backups").exists());
assert!(!root.path().join("lease-ledger.jsonl").exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn unsatisfiable_policy_without_the_opt_out_reports_the_platform_message() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/pdf", "version": "1"
})),
)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&FakeAuthenticator(AuthOutcome::PolicyUnsatisfiable(
"Authentication canceled.".to_string(),
)),
)
.await;
let AcquireResult::Unavailable { detail } = result else {
panic!("expected Unavailable, got {result:?}");
};
assert_eq!(detail, "Authentication canceled.");
}
#[tokio::test]
async fn get_metadata_failure_is_reported_as_failed_before_authenticating() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(
wiremock::ResponseTemplate::new(404).set_body_json(serde_json::json!({
"error": {"code": 404, "message": "File not found"}
})),
)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&PanicsIfCalled("must not authenticate when metadata lookup already failed"),
)
.await;
assert!(matches!(result, AcquireResult::Failed { .. }));
}
#[tokio::test(flavor = "multi_thread")]
async fn download_failure_after_authorization_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/pdf", "version": "1"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
assert!(matches!(result, AcquireResult::Failed { .. }));
assert!(!root.path().join("backups").exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_backup_write_collision_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf", "version": "1"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
std::fs::write(&test_opts.backup_dir, b"not a directory").unwrap();
let result = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Failed { detail } = result else {
panic!("expected Failed, got {result:?}");
};
assert!(detail.contains("Failed to create backup file"), "{detail}");
}
#[test]
fn write_backup_reports_a_genuine_same_second_collision_distinctly() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backup-file");
std::fs::write(&path, b"already here").unwrap();
let err = write_backup(&path, b"hello").unwrap_err();
assert!(err.to_string().contains("may already exist"), "{err:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_ledger_insert_failure_reclaims_the_backup_just_taken() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf", "version": "1"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let _held = LedgerLock::acquire(&test_opts.ledger_path).unwrap();
let result = acquire_with_timings(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
AcquireTimings {
lock_wait: Duration::from_millis(150),
..FAST_TIMINGS
},
)
.await;
assert!(
matches!(&result, AcquireResult::Failed { detail } if detail.contains("timed out")),
"{result:?}"
);
assert!(
std::fs::read_dir(&test_opts.backup_dir)
.unwrap()
.next()
.is_none(),
"the backup this attempt took must be reclaimed, not left orphaned"
);
let contents = std::fs::read_to_string(root.path().join("audit.jsonl")).unwrap();
let rec: crate::request_log::LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(
rec.context.get("verdict").map(String::as_str),
Some("failed"),
"a successfully reclaimed backup takes no verdict suffix"
);
assert!(
rec.context.contains_key("backup_location"),
"a reclaimed backup's location must still be audited"
);
}
#[test]
fn acquire_result_serializes_to_jsonl() {
use crate::cli::drive::format::JsonlSerialize;
let mut buf = Vec::new();
AcquireResult::Acquired {
token: "tok-1".to_string(),
expires_at: Utc::now(),
backup: LeaseBackup::Bytes {
path: PathBuf::from("/tmp/backup"),
sha256: "deadbeef".to_string(),
size: 0,
},
headless_waiver: false,
superseded_lease_id: None,
}
.write_jsonl(&mut buf)
.unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("\"status\":\"acquired\""), "{text}");
assert!(text.contains("tok-1"), "{text}");
}
#[tokio::test]
async fn native_document_is_refused_before_authenticating() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/vnd.google-apps.document"
})),
)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&PanicsIfCalled("must not authenticate for a Google-native document"),
)
.await;
assert!(matches!(result, AcquireResult::RefusedNativeDocument));
}
#[tokio::test]
async fn missing_version_is_refused_before_authenticating() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/pdf"
})),
)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&PanicsIfCalled("must not authenticate without a version to lease against"),
)
.await;
assert!(matches!(result, AcquireResult::Failed { .. }));
}
#[tokio::test(flavor = "multi_thread")]
async fn authorized_acquisition_backs_up_bytes_and_records_a_ledger_row() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf",
"version": "42", "modifiedTime": "2026-09-11T00:00:00Z"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Acquired { token, backup, .. } = result else {
panic!("expected Acquired, got {result:?}");
};
let LeaseBackup::Bytes { path, .. } = backup else {
panic!("expected a Bytes backup for a binary file, got {backup:?}");
};
assert!(!token.is_empty());
assert_eq!(std::fs::read(&path).unwrap(), b"hello");
assert!(path
.file_name()
.unwrap()
.to_string_lossy()
.contains("f1-report.pdf"));
}
#[tokio::test(flavor = "multi_thread")]
async fn recorded_version_reflects_a_post_backup_fetch_not_the_pre_auth_snapshot() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf",
"version": "0", "modifiedTime": "2026-09-11T00:00:00Z"
})),
)
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf",
"version": "1", "modifiedTime": "2026-09-11T00:05:00Z"
})),
)
.with_priority(2)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let result = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Acquired { token, .. } = result else {
panic!("expected Acquired, got {result:?}");
};
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
let record = ledger.get(&token).expect("token must be in the ledger");
assert_eq!(
record.version, "1",
"the recorded version must come from the post-backup fetch, not the pre-auth one"
);
assert_eq!(
record.modified_time.as_deref(),
Some("2026-09-11T00:05:00Z")
);
}
const HELLO_SHA256: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
async fn mount_metadata_drift(
server: &wiremock::MockServer,
first: serde_json::Value,
n: u64,
rest: serde_json::Value,
) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(first))
.up_to_n_times(n)
.expect(n)
.with_priority(1)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(rest))
.with_priority(2)
.mount(server)
.await;
}
async fn mount_hello_download(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(server)
.await;
}
fn assert_nothing_minted(test_opts: &AcquireOptions) {
assert!(
!test_opts.ledger_path.exists(),
"no ledger row must be written"
);
assert!(
std::fs::read_dir(&test_opts.backup_dir).is_ok_and(|mut d| d.next().is_none()),
"the backup this attempt took must be reclaimed, not left orphaned"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_byte_backup_whose_checksum_disagrees_with_drive_is_refused() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf",
"version": "3",
"sha256Checksum": "0000000000000000000000000000000000000000000000000000000000000000"
})),
)
.expect(2)
.mount(&server)
.await;
mount_hello_download(&server).await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let result = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::RefusedConcurrentChange { detail } = result else {
panic!("expected RefusedConcurrentChange, got {result:?}");
};
assert!(detail.contains(HELLO_SHA256), "{detail}");
assert!(detail.contains("version 3"), "{detail}");
assert_nothing_minted(&test_opts);
let contents = std::fs::read_to_string(root.path().join("audit.jsonl")).unwrap();
let rec: crate::request_log::LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(
rec.context.get("verdict").map(String::as_str),
Some("refused-concurrent-change")
);
assert!(
rec.context
.get("backup_location")
.is_some_and(|loc| loc.contains("f1-report.pdf")),
"the reclaimed backup's location must still be audited: {:?}",
rec.context.get("backup_location")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_byte_backup_whose_checksum_matches_is_pinned_even_when_version_bumped() {
let server = wiremock::MockServer::start().await;
mount_metadata_drift(
&server,
serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf",
"version": "1", "sha256Checksum": HELLO_SHA256
}),
1,
serde_json::json!({
"id": "f1", "name": "renamed.pdf", "mimeType": "application/pdf",
"version": "2", "sha256Checksum": HELLO_SHA256
}),
)
.await;
mount_hello_download(&server).await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let result = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Acquired { token, .. } = result else {
panic!("expected Acquired, got {result:?}");
};
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
assert_eq!(ledger.get(&token).unwrap().version, "2");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_byte_target_without_a_checksum_falls_back_to_the_version_sandwich() {
let server = wiremock::MockServer::start().await;
mount_metadata_drift(
&server,
serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf", "version": "1"
}),
2,
serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf", "version": "2"
}),
)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.expect(2)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let result = acquire_with_timings(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
FAST_TIMINGS,
)
.await;
let AcquireResult::Acquired { token, .. } = result else {
panic!("expected Acquired, got {result:?}");
};
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
assert_eq!(ledger.get(&token).unwrap().version, "2");
assert_eq!(
std::fs::read_dir(&test_opts.backup_dir).unwrap().count(),
1,
"the first attempt's backup must be reclaimed, leaving only the pinned one"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_sandwich_read_without_a_version_refuses_before_taking_the_backup() {
let server = wiremock::MockServer::start().await;
mount_metadata_drift(
&server,
serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf", "version": "1"
}),
1,
serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf"
}),
)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.expect(0)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let result = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Failed { detail } = result else {
panic!("expected Failed, got {result:?}");
};
assert!(detail.contains("immediately before the backup"), "{detail}");
assert!(!test_opts.ledger_path.exists(), "no ledger row");
assert!(
!test_opts.backup_dir.exists()
|| std::fs::read_dir(&test_opts.backup_dir).is_ok_and(|mut d| d.next().is_none()),
"no backup may have been taken"
);
}
async fn mount_native_drift(server: &wiremock::MockServer, copies: u64) {
mount_metadata_drift(
server,
serde_json::json!({
"id": "f1", "name": "Budget",
"mimeType": "application/vnd.google-apps.spreadsheet", "version": "7"
}),
2,
serde_json::json!({
"id": "f1", "name": "Budget",
"mimeType": "application/vnd.google-apps.spreadsheet", "version": "8"
}),
)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/drive/v3/files/f1/copy"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "mimeType": "application/vnd.google-apps.spreadsheet"
})))
.expect(copies)
.mount(server)
.await;
}
struct EverMovingVersion {
base: serde_json::Value,
next: std::sync::atomic::AtomicU64,
}
impl wiremock::Respond for EverMovingVersion {
fn respond(&self, _request: &wiremock::Request) -> wiremock::ResponseTemplate {
let version = self.next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut body = self.base.clone();
body["version"] = serde_json::Value::String(version.to_string());
wiremock::ResponseTemplate::new(200).set_body_json(body)
}
}
async fn mount_trash_copy(server: &wiremock::MockServer, times: u64) {
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/copy-1"))
.and(wiremock::matchers::body_json(
serde_json::json!({"trashed": true}),
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "trashed": true,
})),
)
.expect(times)
.mount(server)
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_native_backup_whose_version_moved_once_is_retried_and_acquired() {
let server = wiremock::MockServer::start().await;
mount_native_drift(&server, 2).await;
mount_trash_copy(&server, 1).await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut test_opts = opts(root.path());
test_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = acquire_with_timings(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
FAST_TIMINGS,
)
.await;
let AcquireResult::Acquired { token, .. } = result else {
panic!("expected Acquired, got {result:?}");
};
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
assert_eq!(ledger.get(&token).unwrap().version, "8");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_native_backup_whose_version_never_settles_is_refused_after_every_attempt() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(EverMovingVersion {
base: serde_json::json!({
"id": "f1", "name": "Budget",
"mimeType": "application/vnd.google-apps.spreadsheet"
}),
next: std::sync::atomic::AtomicU64::new(7),
})
.expect(1 + 2 * u64::from(SANDWICH_ATTEMPTS))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/drive/v3/files/f1/copy"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "mimeType": "application/vnd.google-apps.spreadsheet"
})))
.expect(u64::from(SANDWICH_ATTEMPTS))
.mount(&server)
.await;
mount_trash_copy(&server, u64::from(SANDWICH_ATTEMPTS)).await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut test_opts = opts(root.path());
test_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = acquire_with_timings(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
FAST_TIMINGS,
)
.await;
let AcquireResult::RefusedConcurrentChange { detail } = result else {
panic!("expected RefusedConcurrentChange, got {result:?}");
};
assert!(detail.contains("on each of 3 attempts"), "{detail}");
assert!(detail.contains("version 12"), "{detail}");
assert!(detail.contains("13 immediately after"), "{detail}");
assert!(detail.contains("unrelated to edits"), "{detail}");
assert!(!test_opts.ledger_path.exists(), "no ledger row");
let contents = std::fs::read_to_string(root.path().join("audit.jsonl")).unwrap();
let rec: crate::request_log::LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(
rec.context.get("verdict").map(String::as_str),
Some("refused-concurrent-change")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_concurrent_change_whose_reclaim_fails_is_audited_as_backup_orphaned() {
let server = wiremock::MockServer::start().await;
mount_native_drift(&server, 1).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/copy-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut test_opts = opts(root.path());
test_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
assert!(matches!(
result,
AcquireResult::RefusedConcurrentChange { .. }
));
let contents = std::fs::read_to_string(root.path().join("audit.jsonl")).unwrap();
let rec: crate::request_log::LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(
rec.context.get("verdict").map(String::as_str),
Some("refused-concurrent-change-backup-orphaned")
);
assert_eq!(
rec.context.get("backup_location").map(String::as_str),
Some("copy-1")
);
assert!(
rec.error
.as_deref()
.is_some_and(|e| e.contains("version 7")),
"{:?}",
rec.error
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_reclaim_failing_mid_retry_stops_early_and_never_claims_the_backup_discarded() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(EverMovingVersion {
base: serde_json::json!({
"id": "f1", "name": "Budget",
"mimeType": "application/vnd.google-apps.spreadsheet"
}),
next: std::sync::atomic::AtomicU64::new(7),
})
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/drive/v3/files/f1/copy"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "mimeType": "application/vnd.google-apps.spreadsheet"
})))
.expect(2)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/copy-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "trashed": true,
})),
)
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/copy-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.with_priority(2)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut test_opts = opts(root.path());
test_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = acquire_with_timings(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
FAST_TIMINGS,
)
.await;
let AcquireResult::RefusedConcurrentChange { detail } = result else {
panic!("expected RefusedConcurrentChange, got {result:?}");
};
assert!(detail.contains("on each of 2 attempts"), "{detail}");
assert!(detail.contains("stopped early"), "{detail}");
assert!(
!detail.contains("minted and the backups were discarded"),
"{detail}"
);
let contents = std::fs::read_to_string(root.path().join("audit.jsonl")).unwrap();
let rec: crate::request_log::LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(
rec.context.get("verdict").map(String::as_str),
Some("refused-concurrent-change-backup-orphaned")
);
}
#[test]
fn backup_is_pinned_refuses_when_neither_proof_is_available() {
let backup = LeaseBackup::Bytes {
path: PathBuf::from("/x"),
sha256: HELLO_SHA256.to_string(),
size: 5,
};
let err = backup_is_pinned(&backup, None, "7", None).unwrap_err();
assert!(matches!(err, PinFailure::NoProof { .. }), "{err:?}");
assert!(!err.is_retryable());
let detail = err.detail(1, true);
assert!(detail.contains("no proof available"), "{detail}");
}
#[test]
fn only_a_moved_version_is_retried_and_its_final_wording_names_the_attempts() {
let moved = PinFailure::VersionMoved {
pre: "7".to_string(),
post: "8".to_string(),
};
assert!(moved.is_retryable());
let once = moved.detail(1, true);
assert!(once.contains("version 7 immediately before"), "{once}");
assert!(once.ends_with("backup was discarded — retry."), "{once}");
let exhausted = moved.detail(SANDWICH_ATTEMPTS, true);
assert!(exhausted.contains("on each of 3 attempts"), "{exhausted}");
assert!(exhausted.contains("unrelated to edits"), "{exhausted}");
assert!(!exhausted.contains("retry."), "{exhausted}");
let orphaned_once = moved.detail(1, false);
assert!(!orphaned_once.contains("was discarded"), "{orphaned_once}");
assert!(
orphaned_once.contains("could not be discarded"),
"{orphaned_once}"
);
let stopped_early = moved.detail(2, false);
assert!(
stopped_early.contains("on each of 2 attempts"),
"{stopped_early}"
);
assert!(stopped_early.contains("stopped early"), "{stopped_early}");
assert!(
!stopped_early.contains("minted and the backups were discarded"),
"{stopped_early}"
);
assert!(
!stopped_early.contains("unrelated to edits"),
"{stopped_early}"
);
let mismatch = PinFailure::ChecksumMismatch {
remote: "aa".to_string(),
post_version: "3".to_string(),
local: "bb".to_string(),
};
assert!(!mismatch.is_retryable());
}
#[test]
fn backup_is_pinned_compares_checksums_case_insensitively() {
let backup = LeaseBackup::Bytes {
path: PathBuf::from("/x"),
sha256: HELLO_SHA256.to_string(),
size: 5,
};
let upper = HELLO_SHA256.to_ascii_uppercase();
assert!(backup_is_pinned(&backup, Some(&upper), "1", None).is_ok());
}
#[tokio::test(flavor = "multi_thread")]
async fn native_document_with_a_backup_folder_configured_copies_instead_of_refusing() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "Budget", "mimeType": "application/vnd.google-apps.spreadsheet",
"version": "7"
})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/drive/v3/files/f1/copy"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "mimeType": "application/vnd.google-apps.spreadsheet"
})))
.expect(1)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut opts = opts(root.path());
opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = acquire(&client, &opts, &FakeAuthenticator(AuthOutcome::Authorized)).await;
let AcquireResult::Acquired { backup, .. } = result else {
panic!("expected Acquired, got {result:?}");
};
assert_eq!(
backup,
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string()
}
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_native_copy_after_authorization_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "Budget", "mimeType": "application/vnd.google-apps.spreadsheet",
"version": "7"
})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/drive/v3/files/f1/copy"))
.respond_with(wiremock::ResponseTemplate::new(500))
.expect(1)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut opts = opts(root.path());
opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = acquire(&client, &opts, &FakeAuthenticator(AuthOutcome::Authorized)).await;
assert!(
matches!(result, AcquireResult::Failed { .. }),
"expected Failed, got {result:?}"
);
assert!(!opts.ledger_path.exists(), "no ledger row must be written");
}
async fn mount_pre_auth_metadata_and_download(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf",
"version": "1"
})),
)
.up_to_n_times(2)
.with_priority(1)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(server)
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_post_backup_metadata_fetch_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
mount_pre_auth_metadata_and_download(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(wiremock::ResponseTemplate::new(500))
.with_priority(2)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let result = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
assert!(
matches!(result, AcquireResult::Failed { .. }),
"expected Failed, got {result:?}"
);
assert!(
!test_opts.ledger_path.exists(),
"no ledger row must be written"
);
assert!(
std::fs::read_dir(&test_opts.backup_dir)
.unwrap()
.next()
.is_none(),
"the backup this attempt took must be reclaimed, not left orphaned (issue #1690)"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_post_backup_fetch_without_a_version_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
mount_pre_auth_metadata_and_download(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf"
})),
)
.with_priority(2)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let result = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Failed { detail } = result else {
panic!("expected Failed, got {result:?}");
};
assert!(detail.contains("after the backup"), "{detail}");
assert!(
!test_opts.ledger_path.exists(),
"no ledger row must be written"
);
assert!(
std::fs::read_dir(&test_opts.backup_dir)
.unwrap()
.next()
.is_none(),
"the backup this attempt took must be reclaimed, not left orphaned (issue #1690)"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_acquired_lease_writes_an_audit_record_carrying_the_backup_and_version() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf",
"version": "42", "modifiedTime": "2026-09-11T00:00:00Z"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Acquired { token, .. } = result else {
panic!("expected Acquired, got {result:?}");
};
let contents = std::fs::read_to_string(root.path().join("audit.jsonl")).unwrap();
let rec: crate::request_log::LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(rec.kind, crate::request_log::RecordKind::Audit);
assert_eq!(rec.context.get("lease_id"), Some(&token));
assert_eq!(
rec.context.get("verdict").map(String::as_str),
Some("acquired")
);
assert_eq!(
rec.context.get("version_after").map(String::as_str),
Some("42")
);
assert_eq!(
rec.context.get("auth_policy").map(String::as_str),
Some("device-owner")
);
assert!(rec.context.contains_key("backup_sha256"));
}
#[tokio::test]
async fn a_refused_acquisition_still_writes_an_audit_record() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "Budget", "mimeType": "application/vnd.google-apps.spreadsheet",
"version": "7"
})))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&PanicsIfCalled("must not authenticate with no backup folder configured"),
)
.await;
assert!(matches!(result, AcquireResult::RefusedNativeDocument));
let contents = std::fs::read_to_string(root.path().join("audit.jsonl")).unwrap();
let rec: crate::request_log::LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(
rec.context.get("verdict").map(String::as_str),
Some("refused-native-document")
);
assert_eq!(rec.context.get("lease_id"), None);
}
#[tokio::test]
async fn native_document_without_a_backup_folder_is_refused_before_authenticating() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "Budget", "mimeType": "application/vnd.google-apps.spreadsheet",
"version": "7"
})))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let result = acquire(
&client,
&opts(root.path()),
&PanicsIfCalled("must not authenticate with no backup folder configured"),
)
.await;
assert!(matches!(result, AcquireResult::RefusedNativeDocument));
}
#[tokio::test]
async fn expiry_below_the_minimum_is_refused_before_authenticating() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut test_opts = opts(root.path());
test_opts.expiry = ChronoDuration::minutes(0);
let result = acquire(
&client,
&test_opts,
&PanicsIfCalled("must not authenticate with an out-of-range expiry"),
)
.await;
let AcquireResult::Failed { detail } = result else {
panic!("expected Failed, got {result:?}");
};
assert!(detail.contains("expiry must be between"), "{detail}");
}
#[tokio::test]
async fn expiry_above_the_maximum_is_refused_before_authenticating() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut test_opts = opts(root.path());
test_opts.expiry = ChronoDuration::minutes(MAX_EXPIRY_MINUTES + 1);
let result = acquire(
&client,
&test_opts,
&PanicsIfCalled("must not authenticate with an out-of-range expiry"),
)
.await;
let AcquireResult::Failed { detail } = result else {
panic!("expected Failed, got {result:?}");
};
assert!(detail.contains("expiry must be between"), "{detail}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_second_acquisition_while_one_is_still_live_reuses_the_existing_token_instead_of_minting_a_second(
) {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf",
"version": "1"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let first = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Acquired {
token: first_token, ..
} = first
else {
panic!("expected Acquired, got {first:?}");
};
let mut second_opts = test_opts.clone();
second_opts.backup_dir = root.path().join("backups2");
let second = acquire(
&client,
&second_opts,
&PanicsIfCalled(
"must not authenticate: the lock-free pre-check must refuse a second live \
lease before spending a prompt",
),
)
.await;
let AcquireResult::AlreadyLeased { token, .. } = second else {
panic!("expected AlreadyLeased, got {second:?}");
};
assert_eq!(token, first_token);
assert!(
!second_opts.backup_dir.exists(),
"the pre-check must refuse before ever taking a backup"
);
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
assert_eq!(
ledger
.live_lease_for_file("f1", Utc::now(), None)
.map(|r| &r.token),
Some(&first_token)
);
}
struct InsertsALiveLeaseDuringAuth {
ledger_path: PathBuf,
file_id: String,
}
impl Authenticator for InsertsALiveLeaseDuringAuth {
fn authenticate(&self, _reason: &str, _policy: AuthPolicy) -> AuthOutcome {
LeaseLedger::mutate_locked(&self.ledger_path, |ledger| {
ledger.insert(LeaseRecord {
token: "racer".to_string(),
file_id: self.file_id.clone(),
version: "1".to_string(),
modified_time: None,
backup: LeaseBackup::DriveCopy {
file_id: "racer-backup".to_string(),
},
acquired_at: Utc::now(),
expires_at: Utc::now() + ChronoDuration::minutes(30),
released_at: None,
superseded_by: None,
restored_at: None,
restored_sheet_id: None,
});
})
.unwrap();
AuthOutcome::Authorized
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_lease_inserted_during_the_prompt_reclaims_this_attempts_bytes_backup() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf", "version": "1"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let result = acquire(
&client,
&test_opts,
&InsertsALiveLeaseDuringAuth {
ledger_path: test_opts.ledger_path.clone(),
file_id: test_opts.file_id.clone(),
},
)
.await;
let AcquireResult::AlreadyLeased { token, .. } = result else {
panic!("expected AlreadyLeased, got {result:?}");
};
assert_eq!(token, "racer");
assert!(
std::fs::read_dir(&test_opts.backup_dir)
.unwrap()
.next()
.is_none(),
"this attempt's own now-orphaned backup must be reclaimed"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_lease_inserted_during_the_prompt_reclaims_this_attempts_native_backup() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "Budget", "mimeType": "application/vnd.google-apps.spreadsheet",
"version": "7"
})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/drive/v3/files/f1/copy"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "mimeType": "application/vnd.google-apps.spreadsheet"
})))
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/copy-1"))
.and(wiremock::matchers::body_json(
serde_json::json!({"trashed": true}),
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "trashed": true,
})),
)
.expect(1)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut test_opts = opts(root.path());
test_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = acquire(
&client,
&test_opts,
&InsertsALiveLeaseDuringAuth {
ledger_path: test_opts.ledger_path.clone(),
file_id: test_opts.file_id.clone(),
},
)
.await;
let AcquireResult::AlreadyLeased { token, .. } = result else {
panic!("expected AlreadyLeased, got {result:?}");
};
assert_eq!(token, "racer");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_reclamation_failure_is_audited_as_backup_orphaned_but_still_reports_already_leased()
{
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "Budget", "mimeType": "application/vnd.google-apps.spreadsheet",
"version": "7"
})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/drive/v3/files/f1/copy"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "mimeType": "application/vnd.google-apps.spreadsheet"
})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/copy-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let mut test_opts = opts(root.path());
test_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = acquire(
&client,
&test_opts,
&InsertsALiveLeaseDuringAuth {
ledger_path: test_opts.ledger_path.clone(),
file_id: test_opts.file_id.clone(),
},
)
.await;
assert!(matches!(result, AcquireResult::AlreadyLeased { .. }));
let contents = std::fs::read_to_string(root.path().join("audit.jsonl")).unwrap();
let rec: crate::request_log::LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(
rec.context.get("verdict").map(String::as_str),
Some("already-leased-backup-orphaned")
);
assert_eq!(
rec.context.get("backup_location").map(String::as_str),
Some("copy-1")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_second_acquisition_after_the_first_expires_mints_a_fresh_lease() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "report.pdf", "mimeType": "application/pdf",
"version": "1"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(root.path());
let test_opts = opts(root.path());
let first = acquire(
&client,
&test_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Acquired {
token: first_token, ..
} = first
else {
panic!("expected Acquired, got {first:?}");
};
let mut ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
let mut expired = ledger.get(&first_token).unwrap().clone();
expired.expires_at = Utc::now() - ChronoDuration::minutes(1);
ledger.insert(expired);
ledger.save(&test_opts.ledger_path).unwrap();
let mut second_opts = test_opts.clone();
second_opts.backup_dir = root.path().join("backups2");
let second = acquire(
&client,
&second_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
)
.await;
let AcquireResult::Acquired {
token: second_token,
..
} = second
else {
panic!("expected Acquired, got {second:?}");
};
assert_ne!(second_token, first_token);
}
#[test]
fn insert_record_waits_for_a_concurrent_holder_then_inserts() {
let root = tempfile::tempdir().unwrap();
let ledger_path = root.path().join("lease-ledger.jsonl");
let held = LedgerLock::acquire(&ledger_path).unwrap();
let releaser = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(100));
drop(held);
});
let outcome =
insert_record(live_row("new", "f1"), &ledger_path, None, TEST_LOCK_WAIT).unwrap();
releaser.join().unwrap();
assert!(matches!(
outcome,
InsertOutcome::Inserted { superseded: false }
));
assert!(LeaseLedger::load(&ledger_path)
.unwrap()
.get("new")
.is_some());
}
fn live_row(token: &str, file_id: &str) -> LeaseRecord {
LeaseRecord {
token: token.to_string(),
file_id: file_id.to_string(),
version: "1".to_string(),
modified_time: None,
backup: LeaseBackup::DriveCopy {
file_id: format!("{token}-backup"),
},
acquired_at: Utc::now(),
expires_at: Utc::now() + ChronoDuration::minutes(30),
released_at: None,
superseded_by: None,
restored_at: None,
restored_sheet_id: None,
}
}
fn seed(ledger_path: &Path, records: Vec<LeaseRecord>) {
LeaseLedger::mutate_locked(ledger_path, |ledger| {
for record in records {
ledger.insert(record);
}
})
.unwrap();
}
#[test]
fn superseding_releases_the_named_lease_in_the_same_rewrite_that_inserts() {
let root = tempfile::tempdir().unwrap();
let ledger_path = root.path().join("lease-ledger.jsonl");
seed(&ledger_path, vec![live_row("old", "f1")]);
let outcome = insert_record(
live_row("new", "f1"),
&ledger_path,
Some("old"),
TEST_LOCK_WAIT,
)
.unwrap();
assert!(matches!(
outcome,
InsertOutcome::Inserted { superseded: true }
));
let ledger = LeaseLedger::load(&ledger_path).unwrap();
assert!(
ledger.get("old").unwrap().released_at.is_some(),
"the superseded row must be released"
);
assert_eq!(
ledger.get("old").unwrap().superseded_by.as_deref(),
Some("new"),
"the superseded row must durably record which lease replaced it \
(issue #1768), not just that it was released"
);
assert_eq!(
ledger
.live_lease_for_file("f1", Utc::now(), None)
.map(|r| r.token.clone()),
Some("new".to_string()),
"exactly one live lease must cover the file afterwards"
);
}
#[test]
fn superseding_ignores_a_token_bound_to_another_file() {
let root = tempfile::tempdir().unwrap();
let ledger_path = root.path().join("lease-ledger.jsonl");
seed(&ledger_path, vec![live_row("elsewhere", "f2")]);
let outcome = insert_record(
live_row("new", "f1"),
&ledger_path,
Some("elsewhere"),
TEST_LOCK_WAIT,
)
.unwrap();
assert!(
matches!(outcome, InsertOutcome::Inserted { superseded: false }),
"a row this insert did not release must not be reported as superseded"
);
let ledger = LeaseLedger::load(&ledger_path).unwrap();
assert!(
ledger.get("elsewhere").unwrap().released_at.is_none(),
"naming another file's token must never release it"
);
assert_eq!(ledger.get("elsewhere").unwrap().superseded_by, None);
}
#[test]
fn a_blocked_insert_does_not_release_the_superseded_lease() {
let root = tempfile::tempdir().unwrap();
let ledger_path = root.path().join("lease-ledger.jsonl");
seed(
&ledger_path,
vec![live_row("old", "f1"), live_row("third-party", "f1")],
);
let outcome = insert_record(
live_row("new", "f1"),
&ledger_path,
Some("old"),
TEST_LOCK_WAIT,
)
.unwrap();
assert!(
matches!(&outcome, InsertOutcome::AlreadyLeased(rec) if rec.token == "third-party"),
"a third live lease must still block the insert"
);
let ledger = LeaseLedger::load(&ledger_path).unwrap();
assert!(
ledger.get("old").unwrap().released_at.is_none(),
"nothing was minted, so the superseded lease must survive intact"
);
assert!(ledger.get("new").is_none());
}
#[test]
fn superseding_an_already_dead_row_is_not_reported_as_a_release() {
let root = tempfile::tempdir().unwrap();
let ledger_path = root.path().join("lease-ledger.jsonl");
let mut old = live_row("old", "f1");
let first_release = Utc::now() - ChronoDuration::hours(1);
old.released_at = Some(first_release);
seed(&ledger_path, vec![old]);
let outcome = insert_record(
live_row("new", "f1"),
&ledger_path,
Some("old"),
TEST_LOCK_WAIT,
)
.unwrap();
assert!(matches!(
outcome,
InsertOutcome::Inserted { superseded: false }
));
assert_eq!(
LeaseLedger::load(&ledger_path)
.unwrap()
.get("old")
.unwrap()
.released_at,
Some(first_release),
"the earlier release's timestamp must survive untouched"
);
}
fn superseded_lease_id_on_record(root: &Path, superseded: Option<&str>) -> Option<String> {
let audit = AuditGuard::redirect(root);
let mut test_opts = opts(root);
test_opts.supersedes = Some("old".to_string());
seed(&test_opts.ledger_path, vec![live_row("new", "f1")]);
record_attempt(
&test_opts,
&AcquireResult::Acquired {
token: "new".to_string(),
expires_at: Utc::now(),
backup: LeaseBackup::DriveCopy {
file_id: "new-backup".to_string(),
},
headless_waiver: false,
superseded_lease_id: superseded.map(str::to_string),
},
None,
);
audit
.records()
.pop()
.and_then(|r| r.context.get("superseded_lease_id").cloned())
}
#[test]
fn the_audit_record_names_the_lease_the_result_says_was_released() {
let root = tempfile::tempdir().unwrap();
assert_eq!(
superseded_lease_id_on_record(root.path(), Some("old")),
Some("old".to_string())
);
}
#[test]
fn the_audit_record_omits_the_field_when_nothing_was_released() {
let root = tempfile::tempdir().unwrap();
assert_eq!(superseded_lease_id_on_record(root.path(), None), None);
}
#[test]
fn exit_code_matches_the_documented_classification() {
let cases: Vec<(AcquireResult, i32)> = vec![
(
AcquireResult::Acquired {
token: "tok".to_string(),
expires_at: Utc::now(),
backup: LeaseBackup::Bytes {
path: PathBuf::from("/tmp/backup"),
sha256: "deadbeef".to_string(),
size: 0,
},
headless_waiver: false,
superseded_lease_id: None,
},
0,
),
(
AcquireResult::AlreadyLeased {
token: "tok".to_string(),
expires_at: Utc::now(),
},
0,
),
(AcquireResult::RefusedNativeDocument, 1),
(
AcquireResult::RefusedConcurrentChange {
detail: "moved".to_string(),
},
1,
),
(
AcquireResult::Denied {
detail: "no".to_string(),
},
1,
),
(
AcquireResult::Unavailable {
detail: "no authenticator".to_string(),
},
1,
),
(
AcquireResult::Failed {
detail: "boom".to_string(),
},
1,
),
];
for (result, expected) in cases {
assert_eq!(result.exit_code(), expected, "{result:?}");
}
}
}