use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::folder_ancestry;
use crate::drive::lease::check::{
conclude_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
LeasedWrite,
};
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::{self, DriveMutationOutcome};
#[derive(Debug, Clone)]
pub struct EditOptions {
pub file_id: String,
pub content: Vec<u8>,
pub content_type: String,
pub dry_run: bool,
pub lease_token: Option<String>,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum EditResult {
WouldEdit,
RefusedNativeDocument,
RefusedNoVisibleParents,
Blocked {
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Edited,
Failed {
detail: String,
},
}
impl FromLeaseRefusal for EditResult {
fn from_no_lease() -> Self {
Self::RefusedNoLease
}
fn from_lease_expired() -> Self {
Self::RefusedLeaseExpired
}
fn from_lease_wrong_file() -> Self {
Self::RefusedLeaseWrongFile
}
fn from_lease_stale() -> Self {
Self::RefusedLeaseStale
}
fn from_lease_failed(detail: String) -> Self {
Self::Failed { detail }
}
}
impl EditResult {
fn log_status(&self) -> &'static str {
match self {
Self::WouldEdit => "would-edit",
Self::RefusedNativeDocument => "refused-native-document",
Self::RefusedNoVisibleParents => "refused-no-visible-parents",
Self::Blocked { .. } => "blocked",
Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
Self::Edited => "edited",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct EditOutcome {
pub file_id: String,
pub file_name: Option<String>,
pub resolved_folder_id: Option<String>,
pub result: EditResult,
}
impl JsonlSerialize for EditOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
write_scalar_jsonl(self, out)
}
}
pub async fn edit(
client: &DriveClient,
opts: &EditOptions,
rules: &[FolderPermissionRule],
) -> EditOutcome {
let started = Instant::now();
let outcome = edit_inner(client, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, started.elapsed());
}
outcome
}
async fn edit_inner(
client: &DriveClient,
opts: &EditOptions,
rules: &[FolderPermissionRule],
) -> EditOutcome {
let files_api = FilesApi::new(client);
let target = match files_api.get_metadata(&opts.file_id).await {
Ok(target) => target,
Err(err) => {
return EditOutcome {
file_id: opts.file_id.clone(),
file_name: None,
resolved_folder_id: None,
result: EditResult::Failed {
detail: err.to_string(),
},
}
}
};
if target.is_google_native() {
return EditOutcome {
file_id: opts.file_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
result: EditResult::RefusedNativeDocument,
};
}
let evaluated = match folder_ancestry::resolve_decision_for_file_target(
&files_api,
&target,
DriveOperation::Edit,
rules,
)
.await
{
Ok(evaluated) => evaluated,
Err(err) => {
return EditOutcome {
file_id: opts.file_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
result: EditResult::Failed {
detail: err.to_string(),
},
}
}
};
if evaluated.source == folder_ancestry::DecisionSource::NoVisibleParents {
return EditOutcome {
file_id: opts.file_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
result: EditResult::RefusedNoVisibleParents,
};
}
let folder_ancestry::FileTargetDecision {
decision,
resolved_folder_id,
requires_lease,
..
} = evaluated;
let gated = |result: EditResult| EditOutcome {
file_id: opts.file_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: resolved_folder_id.clone(),
result,
};
if decision.verdict == write_gate::Verdict::Deny {
return gated(EditResult::Blocked {
decided_by: decision.decided_by,
});
}
if opts.dry_run {
return gated(EditResult::WouldEdit);
}
let leased = LeasedWrite {
log_prefix: "drive edit",
operation: "edit",
ledger_path: &opts.ledger_path,
file_id: &opts.file_id,
};
let lease_grant = match gate_optional_leased_write(
leased,
&files_api,
requires_lease,
opts.lease_token.as_deref(),
)
.await
{
Ok(grant) => grant,
Err(err) => return gated(err.into_result()),
};
let result = match conclude_leased_write(
leased,
&lease_grant,
files_api
.edit_content(&opts.file_id, &opts.content, &opts.content_type)
.await,
|updated| (updated.version.clone(), updated.modified_time.clone()),
ToString::to_string,
) {
Ok(_updated) => EditResult::Edited,
Err(err) => EditResult::Failed {
detail: err.to_string(),
},
};
drop(lease_grant);
gated(result)
}
fn record_attempt(outcome: &EditOutcome, duration: Duration) {
let error = match &outcome.result {
EditResult::Failed { detail } => Some(detail.clone()),
_ => None,
};
let decided_by = match &outcome.result {
EditResult::Blocked { decided_by } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
request_log::record_drive_mutation(DriveMutationOutcome {
operation: "edit",
file_id: outcome.file_id.clone(),
file_name: outcome.file_name.clone().unwrap_or_default(),
status: outcome.result.log_status().to_string(),
added_principals: Vec::new(),
removed_principals: Vec::new(),
crosses_drive_boundary: false,
resolved_folder_id: outcome.resolved_folder_id.clone(),
decided_by_folder_id: decided_by.folder_id,
decided_by_depth: decided_by.depth,
decided_by_file_id: decided_by.file_id,
error,
duration,
..Default::default()
});
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::lease::ledger::LeaseLedger;
use crate::drive::test_support::seed_lease;
use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
use crate::drive::write_gate::DriveOperation;
use crate::test_support::AuditLogGuard;
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
}
fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
let parents_json: Vec<&str> = parents.to_vec();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id, "name": id, "mimeType": mime_type, "parents": parents_json,
"version": "1",
})),
)
}
fn mount_folder(id: &str) -> wiremock::Mock {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id, "name": id, "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})),
)
}
fn opts(dry_run: bool) -> EditOptions {
opts_for("file-1", dry_run)
}
fn opts_for(file_id: &str, dry_run: bool) -> EditOptions {
EditOptions {
file_id: file_id.to_string(),
content: b"new content".to_vec(),
content_type: "text/plain".to_string(),
dry_run,
lease_token: None,
ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
}
}
fn opts_with_lease(file_id: &str, ledger_path: &std::path::Path, version: &str) -> EditOptions {
let token = seed_lease(ledger_path, file_id, version);
EditOptions {
lease_token: Some(token),
ledger_path: ledger_path.to_path_buf(),
..opts_for(file_id, false)
}
}
fn allow_rule() -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some("parent-1".to_string()),
file_id: None,
recursive: false,
allow: std::iter::once(DriveOperation::Edit).collect(),
deny: std::collections::HashSet::default(),
require_lease: true,
}
}
#[tokio::test]
async fn allowed_target_succeeds_and_calls_edit_endpoint_once() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "version": "2",
})),
)
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let outcome = edit(
&client,
&opts_with_lease("file-1", &ledger_path, "1"),
&[allow_rule()],
)
.await;
assert!(matches!(outcome.result, EditResult::Edited));
let reloaded = LeaseLedger::load(&ledger_path).unwrap();
assert_eq!(
reloaded.get("test-lease-token").unwrap().version,
"2",
"a successful write refreshes the lease's recorded version"
);
}
#[tokio::test]
async fn staleness_check_uses_a_version_fetched_after_the_ancestor_walk_not_before() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "mimeType": "text/plain",
"parents": ["parent-1"], "version": "0",
})),
)
.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/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "mimeType": "text/plain",
"parents": ["parent-1"], "version": "1",
})),
)
.with_priority(2)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "version": "2",
})),
)
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let outcome = edit(
&client,
&opts_with_lease("file-1", &ledger_path, "1"),
&[allow_rule()],
)
.await;
assert!(
matches!(outcome.result, EditResult::Edited),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_edit_call() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.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/file-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.with_priority(2)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let outcome = edit(
&client,
&opts_with_lease("file-1", &ledger_path, "1"),
&[allow_rule()],
)
.await;
assert!(
matches!(outcome.result, EditResult::Failed { .. }),
"{:?}",
outcome.result
);
assert_eq!(outcome.file_name.as_deref(), Some("file-1"));
}
#[cfg(unix)]
struct MakeDirReadOnlyThenRespond {
dir: std::path::PathBuf,
}
#[cfg(unix)]
impl wiremock::Respond for MakeDirReadOnlyThenRespond {
fn respond(&self, _req: &wiremock::Request) -> wiremock::ResponseTemplate {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o500)).unwrap();
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "version": "2",
}))
}
}
#[cfg(unix)]
#[tokio::test]
async fn a_failed_lease_refresh_after_a_successful_write_does_not_fail_the_edit() {
use std::os::unix::fs::PermissionsExt;
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let root = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(root.path());
let ledger_dir = root.path().join("ledger");
std::fs::create_dir(&ledger_dir).unwrap();
let ledger_path = ledger_dir.join("lease-ledger.jsonl");
let opts = opts_with_lease("file-1", &ledger_path, "1");
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(MakeDirReadOnlyThenRespond {
dir: ledger_dir.clone(),
})
.expect(1)
.mount(&server)
.await;
let outcome = edit(&client, &opts, &[allow_rule()]).await;
std::fs::set_permissions(&ledger_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(
matches!(outcome.result, EditResult::Edited),
"a refresh failure must not fail the edit itself: {:?}",
outcome.result
);
}
#[tokio::test]
async fn denied_target_refuses_with_zero_edit_calls() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let outcome = edit(&client, &opts(false), &[]).await;
assert!(matches!(outcome.result, EditResult::Blocked { .. }));
}
#[tokio::test]
async fn google_native_document_is_refused_before_any_gate_or_network_call() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file(
"doc-1",
"application/vnd.google-apps.document",
&["parent-1"],
)
.mount(&server)
.await;
let permissive_rule = FolderPermissionRule {
folder_id: Some("parent-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::Edit).collect(),
deny: std::collections::HashSet::default(),
require_lease: true,
};
let outcome = edit(&client, &opts_for("doc-1", false), &[permissive_rule]).await;
assert!(matches!(outcome.result, EditResult::RefusedNativeDocument));
}
#[tokio::test]
async fn orphan_file_is_refused_as_having_no_visible_parents() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("orphan", "text/plain", &[]).mount(&server).await;
let outcome = edit(&client, &opts_for("orphan", false), &[]).await;
assert!(matches!(
outcome.result,
EditResult::RefusedNoVisibleParents
));
assert_eq!(outcome.resolved_folder_id, None);
}
#[tokio::test]
async fn edit_denies_when_any_current_parent_denies_even_if_another_allows() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["allow-parent", "deny-parent"])
.mount(&server)
.await;
mount_folder("allow-parent").mount(&server).await;
mount_folder("deny-parent").mount(&server).await;
let rules = [allow_rule()];
let outcome = edit(&client, &opts(false), &rules).await;
assert!(matches!(outcome.result, EditResult::Blocked { .. }));
assert_eq!(
outcome.resolved_folder_id, None,
"multi-parent targets report no single resolved folder id"
);
}
#[tokio::test]
async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
.mount(&server)
.await;
let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
assert!(
matches!(outcome.result, EditResult::Failed { .. }),
"a fetch failure must never silently fall through to Edited/WouldEdit"
);
}
#[tokio::test]
async fn insufficient_scope_403_surfaces_both_write_flags() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": {
"message": "Insufficient Permission",
"errors": [{"reason": "insufficientPermissions"}],
}
})),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let outcome = edit(
&client,
&opts_with_lease("file-1", &ledger_path, "1"),
&[allow_rule()],
)
.await;
let EditResult::Failed { detail } = outcome.result else {
panic!("expected Failed, got {:?}", outcome.result);
};
assert!(detail.contains("--write-file"), "{detail}");
assert!(detail.contains("--write-full"), "{detail}");
}
#[tokio::test]
async fn dry_run_never_calls_edit_endpoint() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let outcome = edit(&client, &opts(true), &[allow_rule()]).await;
assert!(matches!(outcome.result, EditResult::WouldEdit));
}
#[tokio::test]
async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.expect(2)
.mount(&server)
.await;
mount_folder("parent-1").expect(2).mount(&server).await;
let dry_run_outcome = edit(&client, &opts(true), &[]).await;
let real_outcome = edit(&client, &opts(false), &[]).await;
assert!(matches!(dry_run_outcome.result, EditResult::Blocked { .. }));
assert!(matches!(real_outcome.result, EditResult::Blocked { .. }));
}
#[tokio::test]
async fn a_file_rule_grants_a_target_with_no_visible_parents() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &[]).mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id": "file-1", "name": "file-1"})),
)
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let outcome = edit(
&client,
&opts_with_lease("file-1", &ledger_path, "1"),
&[FolderPermissionRule::file("file-1").allowing([DriveOperation::Edit])],
)
.await;
assert!(
matches!(outcome.result, EditResult::Edited),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn a_parentless_target_is_now_refused_distinctly_not_generically_blocked() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &[]).mount(&server).await;
let outcome = edit(&client, &opts(false), &[]).await;
assert!(matches!(
outcome.result,
EditResult::RefusedNoVisibleParents
));
assert_eq!(outcome.result.log_status(), "refused-no-visible-parents");
}
#[tokio::test]
async fn a_file_deny_beats_an_allowing_parent_folder() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
let outcome = edit(
&client,
&opts(false),
&[
allow_rule(),
FolderPermissionRule::file("file-1").denying([DriveOperation::Edit]),
],
)
.await;
match &outcome.result {
EditResult::Blocked { decided_by } => {
let rule = decided_by.as_ref().expect("a file rule decided this");
assert_eq!(rule.kind_label(), "file");
assert_eq!(rule.id(), "file-1");
}
other => panic!("expected Blocked, got {other:?}"),
}
}
#[tokio::test]
async fn refuses_without_a_lease_when_the_rule_requires_one() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
assert!(matches!(outcome.result, EditResult::RefusedNoLease));
assert_eq!(outcome.result.log_status(), "refused-no-lease");
}
#[tokio::test]
async fn refuses_an_unknown_lease_token() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let opts = EditOptions {
lease_token: Some("bogus-token".to_string()),
ledger_path,
..opts_for("file-1", false)
};
let outcome = edit(&client, &opts, &[allow_rule()]).await;
assert!(matches!(outcome.result, EditResult::RefusedLeaseExpired));
}
#[tokio::test]
async fn refuses_a_lease_when_the_ledger_is_unreadable() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
std::fs::create_dir(&ledger_path).unwrap();
let opts = EditOptions {
lease_token: Some("any-token".to_string()),
ledger_path,
..opts_for("file-1", false)
};
let outcome = edit(&client, &opts, &[allow_rule()]).await;
assert!(matches!(outcome.result, EditResult::RefusedLeaseExpired));
}
#[tokio::test]
async fn refuses_an_expired_lease() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let token = "expired-token".to_string();
let mut ledger = LeaseLedger::default();
ledger.insert(crate::drive::lease::ledger::LeaseRecord {
token: token.clone(),
file_id: "file-1".to_string(),
version: "1".to_string(),
modified_time: None,
backup: crate::drive::lease::ledger::LeaseBackup::Bytes {
path: std::path::PathBuf::from("/tmp/test-backup"),
sha256: "deadbeef".to_string(),
size: 0,
},
acquired_at: chrono::Utc::now() - chrono::Duration::hours(2),
expires_at: chrono::Utc::now() - chrono::Duration::hours(1),
released_at: None,
superseded_by: None,
restored_at: None,
restored_sheet_id: None,
});
ledger.save(&ledger_path).unwrap();
let opts = EditOptions {
lease_token: Some(token),
ledger_path,
..opts_for("file-1", false)
};
let outcome = edit(&client, &opts, &[allow_rule()]).await;
assert!(matches!(outcome.result, EditResult::RefusedLeaseExpired));
}
#[tokio::test]
async fn refuses_a_lease_bound_to_a_different_file() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "some-other-file", "1");
let opts = EditOptions {
lease_token: Some(token),
ledger_path,
..opts_for("file-1", false)
};
let outcome = edit(&client, &opts, &[allow_rule()]).await;
assert!(matches!(outcome.result, EditResult::RefusedLeaseWrongFile));
}
#[tokio::test]
async fn refuses_a_stale_lease_when_the_file_has_moved() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "file-1", "0");
let opts = EditOptions {
lease_token: Some(token),
ledger_path,
..opts_for("file-1", false)
};
let outcome = edit(&client, &opts, &[allow_rule()]).await;
assert!(matches!(outcome.result, EditResult::RefusedLeaseStale));
}
#[tokio::test]
async fn edit_requires_a_lease_when_any_legacy_parent_requires_one_even_if_the_deciding_parent_opted_out(
) {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["strict-parent", "lenient-parent"])
.mount(&server)
.await;
mount_folder("strict-parent").mount(&server).await;
mount_folder("lenient-parent").mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let rules = [
FolderPermissionRule::folder("strict-parent").allowing([DriveOperation::Edit]),
FolderPermissionRule::folder("lenient-parent")
.allowing([DriveOperation::Edit])
.requiring_lease(false),
];
let outcome = edit(&client, &opts(false), &rules).await;
assert!(
matches!(outcome.result, EditResult::RefusedNoLease),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn a_held_ledger_lock_fails_the_edit_rather_than_risking_a_lease_double_spend() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "file-1", "1");
let mut lock_path = ledger_path.clone().into_os_string();
lock_path.push(".lock");
std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let opts = EditOptions {
lease_token: Some(token),
ledger_path,
..opts_for("file-1", false)
};
let outcome = edit(&client, &opts, &[allow_rule()]).await;
assert!(
matches!(outcome.result, EditResult::Failed { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn require_lease_false_writes_without_a_lease() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id": "file-1", "name": "file-1"})),
)
.expect(1)
.mount(&server)
.await;
let rule = FolderPermissionRule::folder("parent-1")
.allowing([DriveOperation::Edit])
.requiring_lease(false);
let outcome = edit(&client, &opts(false), &[rule]).await;
assert!(matches!(outcome.result, EditResult::Edited));
}
#[tokio::test]
async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "file-1", "0");
let rule = FolderPermissionRule::folder("parent-1")
.allowing([DriveOperation::Edit])
.requiring_lease(false);
let opts = EditOptions {
lease_token: Some(token),
ledger_path,
..opts_for("file-1", false)
};
let outcome = edit(&client, &opts, &[rule]).await;
assert!(
matches!(outcome.result, EditResult::RefusedLeaseStale),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn require_lease_false_still_refreshes_the_ledger_for_a_presented_lease() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "version": "2",
})),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "file-1", "1");
let rule = FolderPermissionRule::folder("parent-1")
.allowing([DriveOperation::Edit])
.requiring_lease(false);
let opts = EditOptions {
lease_token: Some(token.clone()),
ledger_path: ledger_path.clone(),
..opts_for("file-1", false)
};
let outcome = edit(&client, &opts, &[rule]).await;
assert!(
matches!(outcome.result, EditResult::Edited),
"{:?}",
outcome.result
);
let ledger = LeaseLedger::load(&ledger_path).unwrap();
let record = ledger
.get(&token)
.expect("the lease is still in the ledger");
assert_eq!(record.version, "2");
}
#[tokio::test]
async fn a_dry_run_never_needs_a_lease() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let outcome = edit(&client, &opts(true), &[allow_rule()]).await;
assert!(matches!(outcome.result, EditResult::WouldEdit));
}
#[tokio::test]
async fn a_successful_edit_writes_a_pending_intent_and_an_allowed_outcome() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "version": "2",
})),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let outcome = edit(
&client,
&opts_with_lease("file-1", &ledger_path, "1"),
&[allow_rule()],
)
.await;
assert!(matches!(outcome.result, EditResult::Edited));
let records = audit.records();
assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
for record in &records {
assert_eq!(record.command, ["drive", "edit"]);
assert_eq!(
record.context.get("lease_id").map(String::as_str),
Some("test-lease-token")
);
}
assert_eq!(
records[0].context.get("version_before").map(String::as_str),
Some("1")
);
assert_eq!(
records[1].context.get("version_after").map(String::as_str),
Some("2")
);
assert_eq!(records[1].error, None);
}
#[tokio::test]
async fn a_failed_edit_after_a_valid_lease_writes_a_failed_outcome() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = AuditLogGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let outcome = edit(
&client,
&opts_with_lease("file-1", &ledger_path, "1"),
&[allow_rule()],
)
.await;
let EditResult::Failed { detail } = &outcome.result else {
panic!("expected Failed, got {:?}", outcome.result);
};
let records = audit.records();
assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
}
}