Skip to main content

omni_dev/drive/
content_edit.rs

1//! Drive file content edit — replaces an existing file's content (issue
2//! #1574, [ADR-0071](../../docs/adrs/adr-0071.md)).
3//!
4//! The most structurally distinct of the three mutating verbs: unlike
5//! `create`/`upload` (whose gate chain starts at the caller-given
6//! `--parent`), `edit`'s chain starts at the target's *current* parent
7//! folder(s) — `files.get` first, then
8//! [`folder_ancestry::resolve_decision_for_file_target`], which consults a
9//! `file_id` rule before the parents (issue #1612) and otherwise resolves
10//! and combines a decision per parent for a legacy multi-parent file
11//! (mirrors `visibility.rs`'s existing multi-parent-union contract; shared
12//! with `drive permissions check`'s identical file-target case). A target
13//! with no visible parent and no file rule is refused as
14//! [`EditResult::RefusedNoVisibleParents`] rather than degenerating to a
15//! bare default-policy `Blocked`.
16//!
17//! Still single-target, so this follows `create.rs`/`upload.rs`'s linear-
18//! function shape, not `file_move.rs`'s batch Plan/Execute.
19
20use std::path::PathBuf;
21use std::time::{Duration, Instant};
22
23use serde::Serialize;
24
25use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
26use crate::drive::client::DriveClient;
27use crate::drive::files_api::FilesApi;
28use crate::drive::folder_ancestry;
29use crate::drive::lease::check::{
30    conclude_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
31    LeasedWrite,
32};
33use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
34use crate::request_log::{self, DriveMutationOutcome};
35
36/// Per-call edit options.
37#[derive(Debug, Clone)]
38pub struct EditOptions {
39    /// The file id to edit.
40    pub file_id: String,
41    /// The new content, already read into memory (and already
42    /// size-checked) by the caller.
43    pub content: Vec<u8>,
44    /// The content's MIME type.
45    pub content_type: String,
46    /// When `true`, classify but never call `files.update`.
47    pub dry_run: bool,
48    /// The lease token presented via `--lease`. Checked only when the
49    /// deciding rule requires one
50    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§13);
51    /// `None` is only ever valid when it does not.
52    pub lease_token: Option<String>,
53    /// Path to the lease ledger the token is checked against. Production
54    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
55    /// result; tests pass a path under a `tempdir`.
56    pub ledger_path: PathBuf,
57}
58
59/// What happened (or, under `--dry-run`, would happen).
60#[derive(Debug, Clone, Serialize)]
61#[serde(tag = "status", rename_all = "kebab-case")]
62pub enum EditResult {
63    /// `--dry-run`, and the gate would allow it.
64    WouldEdit,
65    /// The target is a Google-native document (Docs/Sheets/Slides/...)
66    /// with no fixed byte content a raw media PATCH can replace. Checked
67    /// client-side, before the gate — this isn't a policy decision, the
68    /// operation is simply nonsensical for this target.
69    RefusedNativeDocument,
70    /// The target has no parents this account can see and no `file_id`
71    /// rule named it, so nothing could grant it (issue #1612).
72    ///
73    /// Mirrors `crate::drive::sheets::write::WriteResult`'s variant of the
74    /// same name. Before #1612 this case fell into `Blocked { decided_by:
75    /// None }`, which reads as "no rule matched, fix your rules" when no
76    /// *folder* rule the operator could write would have helped — the
77    /// latent gap [ADR-0073](../../docs/adrs/adr-0073.md) §4 flagged for
78    /// `drive edit`.
79    RefusedNoVisibleParents,
80    /// The folder write-permission gate refused it.
81    Blocked {
82        /// The rule that decided the refusal, if any.
83        decided_by: Option<DecidingRule>,
84    },
85    /// No `--lease` was presented, and the deciding rule requires one
86    /// (ADR-0080 §9).
87    RefusedNoLease,
88    /// The presented lease has expired, or was never a token this ledger
89    /// knows about.
90    RefusedLeaseExpired,
91    /// The presented lease is bound to a different file id.
92    RefusedLeaseWrongFile,
93    /// The file has moved since the lease's recorded `version` — the
94    /// staleness check (ADR-0080 §6).
95    RefusedLeaseStale,
96    /// `files.update` (media) succeeded.
97    Edited,
98    /// An API/validation error.
99    Failed {
100        /// A human-readable summary of what failed.
101        detail: String,
102    },
103}
104
105impl FromLeaseRefusal for EditResult {
106    fn from_no_lease() -> Self {
107        Self::RefusedNoLease
108    }
109    fn from_lease_expired() -> Self {
110        Self::RefusedLeaseExpired
111    }
112    fn from_lease_wrong_file() -> Self {
113        Self::RefusedLeaseWrongFile
114    }
115    fn from_lease_stale() -> Self {
116        Self::RefusedLeaseStale
117    }
118    fn from_lease_failed(detail: String) -> Self {
119        Self::Failed { detail }
120    }
121}
122
123impl EditResult {
124    /// The request-log `status` string — mirrors
125    /// `CreateResult`/`UploadResult`/`MoveResult::log_status`'s precedent.
126    fn log_status(&self) -> &'static str {
127        match self {
128            Self::WouldEdit => "would-edit",
129            Self::RefusedNativeDocument => "refused-native-document",
130            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
131            Self::Blocked { .. } => "blocked",
132            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
133            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
134            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
135            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
136            Self::Edited => "edited",
137            Self::Failed { .. } => "failed",
138        }
139    }
140}
141
142/// The planned (and, after a real run, final) outcome of one `edit` call.
143#[derive(Debug, Clone, Serialize)]
144pub struct EditOutcome {
145    /// The target file id.
146    pub file_id: String,
147    /// The file's name at the time of the attempt, once known (absent if
148    /// the initial `files.get` itself failed).
149    pub file_name: Option<String>,
150    /// The folder the write-permission gate evaluated against — the
151    /// target's resolved current parent, when the target has exactly one
152    /// *and* the ancestor chain is what decided the verdict. `None` for an
153    /// orphan target, a target refused before the gate ran
154    /// (`RefusedNativeDocument`), a target with more than one current
155    /// parent (no single folder to report), or a target decided by a
156    /// `file_id` rule (issue #1612) — that short-circuits at depth −1
157    /// before any parent is fetched, so no folder was evaluated even when
158    /// the target has exactly one.
159    pub resolved_folder_id: Option<String>,
160    /// The result.
161    pub result: EditResult,
162}
163
164impl JsonlSerialize for EditOutcome {
165    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
166        write_scalar_jsonl(self, out)
167    }
168}
169
170/// Replaces `opts.file_id`'s content with `opts.content`, gated by `rules`.
171///
172/// Every real (non-dry-run) attempt is logged; a `--dry-run` preview never
173/// is, matching `create`/`upload`/`move`'s existing precedent.
174pub async fn edit(
175    client: &DriveClient,
176    opts: &EditOptions,
177    rules: &[FolderPermissionRule],
178) -> EditOutcome {
179    let started = Instant::now();
180    let outcome = edit_inner(client, opts, rules).await;
181    if !opts.dry_run {
182        record_attempt(&outcome, started.elapsed());
183    }
184    outcome
185}
186
187async fn edit_inner(
188    client: &DriveClient,
189    opts: &EditOptions,
190    rules: &[FolderPermissionRule],
191) -> EditOutcome {
192    let files_api = FilesApi::new(client);
193    let target = match files_api.get_metadata(&opts.file_id).await {
194        Ok(target) => target,
195        Err(err) => {
196            return EditOutcome {
197                file_id: opts.file_id.clone(),
198                file_name: None,
199                resolved_folder_id: None,
200                result: EditResult::Failed {
201                    detail: err.to_string(),
202                },
203            }
204        }
205    };
206
207    if target.is_google_native() {
208        return EditOutcome {
209            file_id: opts.file_id.clone(),
210            file_name: Some(target.name),
211            resolved_folder_id: None,
212            result: EditResult::RefusedNativeDocument,
213        };
214    }
215
216    // A `file_id` rule is consulted before the parents are, so a file
217    // shared by link or email can still be granted (issue #1612).
218    let evaluated = match folder_ancestry::resolve_decision_for_file_target(
219        &files_api,
220        &target,
221        DriveOperation::Edit,
222        rules,
223    )
224    .await
225    {
226        Ok(evaluated) => evaluated,
227        Err(err) => {
228            return EditOutcome {
229                file_id: opts.file_id.clone(),
230                file_name: Some(target.name),
231                resolved_folder_id: None,
232                result: EditResult::Failed {
233                    detail: err.to_string(),
234                },
235            }
236        }
237    };
238
239    if evaluated.source == folder_ancestry::DecisionSource::NoVisibleParents {
240        return EditOutcome {
241            file_id: opts.file_id.clone(),
242            file_name: Some(target.name),
243            resolved_folder_id: None,
244            result: EditResult::RefusedNoVisibleParents,
245        };
246    }
247
248    let folder_ancestry::FileTargetDecision {
249        decision,
250        resolved_folder_id,
251        requires_lease,
252        ..
253    } = evaluated;
254
255    let gated = |result: EditResult| EditOutcome {
256        file_id: opts.file_id.clone(),
257        file_name: Some(target.name.clone()),
258        resolved_folder_id: resolved_folder_id.clone(),
259        result,
260    };
261
262    if decision.verdict == write_gate::Verdict::Deny {
263        return gated(EditResult::Blocked {
264            decided_by: decision.decided_by,
265        });
266    }
267
268    if opts.dry_run {
269        return gated(EditResult::WouldEdit);
270    }
271
272    // The lease check (ADR-0080 §9) sits here: after the permission gate
273    // and the `--dry-run` branch, before the mutating call. A folder-
274    // permission refusal above already made zero Drive API calls with the
275    // lease never touched; a `--dry-run` never needs `--lease` at all.
276    //
277    // No request-building step sits between the `--dry-run` branch and the
278    // gate below — `opts.content`/`opts.content_type` are already-validated
279    // caller-supplied bytes, passed straight to `edit_content` once the
280    // gate succeeds — so the build-before-gate invariant (`gate_leased_write`'s
281    // doc comment, `validation.rs`'s identical comment) is satisfied with
282    // nothing to build. A future fallible step added here must stay ahead
283    // of the gate, the same way (#1688/#1742).
284    //
285    // `requires_lease` already folds in every legacy multi-parent's own
286    // requirement (see `FileTargetDecision::requires_lease`'s doc comment)
287    // — it must not be re-derived from `decision.decided_by` alone here,
288    // which would only see the one parent whose decision happened to win
289    // the verdict. `gate_optional_leased_write` treats `requires_lease:
290    // false` as relaxing the *requirement*, not the *meaning* — a `--lease`
291    // presented anyway is still validated, consumed and audited (ADR-0080
292    // §13).
293    //
294    // The staleness check below is re-fetched fresh here rather than
295    // reusing `target.version` from the very first `get_metadata` call:
296    // `resolve_decision_for_file_target` above can issue its own
297    // `files.get` calls walking the ancestor chain, so by this point
298    // `target.version` may already be stale relative to the live file —
299    // exactly the same window ADR-0080 §6 introduces the check to guard
300    // against. Re-fetching immediately before the check (and thus
301    // immediately before `edit_content`) keeps that window as small as
302    // the Sheets/Docs equivalent ("a `files.get` immediately before the
303    // `batchUpdate`", ADR-0080 §6) rather than spanning the whole gate
304    // evaluation.
305    //
306    // The ledger lock acquired by `check_and_lock_lease` below is held
307    // across the `edit_content` call and released only after
308    // `finish_leased_write` — otherwise a second concurrent `drive
309    // edit` presenting the same token could load the ledger before this
310    // write's `record_write` lands, see the same (still non-stale)
311    // recorded version, and pass its own staleness check even though this
312    // write is about to invalidate it (a lease-token double-spend).
313    let leased = LeasedWrite {
314        log_prefix: "drive edit",
315        operation: "edit",
316        ledger_path: &opts.ledger_path,
317        file_id: &opts.file_id,
318    };
319    let lease_grant = match gate_optional_leased_write(
320        leased,
321        &files_api,
322        requires_lease,
323        opts.lease_token.as_deref(),
324    )
325    .await
326    {
327        Ok(grant) => grant,
328        Err(err) => return gated(err.into_result()),
329    };
330
331    let result = match conclude_leased_write(
332        leased,
333        &lease_grant,
334        files_api
335            .edit_content(&opts.file_id, &opts.content, &opts.content_type)
336            .await,
337        |updated| (updated.version.clone(), updated.modified_time.clone()),
338        ToString::to_string,
339    ) {
340        Ok(_updated) => EditResult::Edited,
341        Err(err) => EditResult::Failed {
342            detail: err.to_string(),
343        },
344    };
345    drop(lease_grant);
346    gated(result)
347}
348
349/// Builds and writes the [`DriveMutationOutcome`] for one `edit` attempt.
350fn record_attempt(outcome: &EditOutcome, duration: Duration) {
351    let error = match &outcome.result {
352        EditResult::Failed { detail } => Some(detail.clone()),
353        _ => None,
354    };
355    let decided_by = match &outcome.result {
356        EditResult::Blocked { decided_by } => decided_by.as_ref(),
357        _ => None,
358    };
359    let decided_by = write_gate::decided_by_log_fields(decided_by);
360    request_log::record_drive_mutation(DriveMutationOutcome {
361        operation: "edit",
362        file_id: outcome.file_id.clone(),
363        file_name: outcome.file_name.clone().unwrap_or_default(),
364        status: outcome.result.log_status().to_string(),
365        added_principals: Vec::new(),
366        removed_principals: Vec::new(),
367        crosses_drive_boundary: false,
368        resolved_folder_id: outcome.resolved_folder_id.clone(),
369        decided_by_folder_id: decided_by.folder_id,
370        decided_by_depth: decided_by.depth,
371        decided_by_file_id: decided_by.file_id,
372        error,
373        duration,
374        ..Default::default()
375    });
376}
377
378#[cfg(test)]
379#[allow(clippy::unwrap_used, clippy::expect_used)]
380mod tests {
381    use super::*;
382    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
383    use crate::drive::lease::ledger::LeaseLedger;
384    use crate::drive::test_support::seed_lease;
385    use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
386    use crate::drive::write_gate::DriveOperation;
387    use crate::test_support::AuditLogGuard;
388    use crate::utils::secret::Secret;
389
390    fn test_credentials() -> DriveCredentials {
391        DriveCredentials {
392            client_id: "client-1".to_string(),
393            client_secret: Secret::new("secret-1"),
394            refresh_token: Secret::new("refresh-1"),
395            scope: DriveGrantedScopes::READONLY,
396        }
397    }
398
399    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
400        wiremock::Mock::given(wiremock::matchers::method("POST"))
401            .and(wiremock::matchers::path("/token"))
402            .respond_with(
403                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
404                    "access_token": "test-token",
405                    "expires_in": 3600,
406                })),
407            )
408            .mount(server)
409            .await;
410
411        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
412        crate::drive::client::test_support::replace_session(
413            &mut client,
414            &test_credentials(),
415            &format!("{}/token", server.uri()),
416        );
417        client
418    }
419
420    /// `version: "1"` throughout — matches [`seed_lease`]'s default, so any
421    /// test that seeds a lease and mounts a file via this helper has a
422    /// live, non-stale lease by construction.
423    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
424        let parents_json: Vec<&str> = parents.to_vec();
425        wiremock::Mock::given(wiremock::matchers::method("GET"))
426            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
427            .respond_with(
428                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
429                    "id": id, "name": id, "mimeType": mime_type, "parents": parents_json,
430                    "version": "1",
431                })),
432            )
433    }
434
435    fn mount_folder(id: &str) -> wiremock::Mock {
436        wiremock::Mock::given(wiremock::matchers::method("GET"))
437            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
438            .respond_with(
439                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
440                    "id": id, "name": id, "mimeType": GOOGLE_FOLDER_MIME_TYPE,
441                })),
442            )
443    }
444
445    fn opts(dry_run: bool) -> EditOptions {
446        opts_for("file-1", dry_run)
447    }
448
449    /// No lease token and a ledger path that is never created — every test
450    /// using this reaches a refusal (native document, no visible parents,
451    /// blocked, a fetch failure) before the lease check would matter. Tests
452    /// exercising the success path use [`opts_with_lease`] instead.
453    fn opts_for(file_id: &str, dry_run: bool) -> EditOptions {
454        EditOptions {
455            file_id: file_id.to_string(),
456            content: b"new content".to_vec(),
457            content_type: "text/plain".to_string(),
458            dry_run,
459            lease_token: None,
460            ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
461        }
462    }
463
464    /// [`opts_for`] plus a lease already seeded (via [`seed_lease`]) into
465    /// `ledger_path` for `file_id` at `version` — for tests exercising the
466    /// success path.
467    fn opts_with_lease(file_id: &str, ledger_path: &std::path::Path, version: &str) -> EditOptions {
468        let token = seed_lease(ledger_path, file_id, version);
469        EditOptions {
470            lease_token: Some(token),
471            ledger_path: ledger_path.to_path_buf(),
472            ..opts_for(file_id, false)
473        }
474    }
475
476    fn allow_rule() -> FolderPermissionRule {
477        FolderPermissionRule {
478            folder_id: Some("parent-1".to_string()),
479            file_id: None,
480            recursive: false,
481            allow: std::iter::once(DriveOperation::Edit).collect(),
482            deny: std::collections::HashSet::default(),
483            require_lease: true,
484        }
485    }
486
487    #[tokio::test]
488    async fn allowed_target_succeeds_and_calls_edit_endpoint_once() {
489        let server = wiremock::MockServer::start().await;
490        let client = client_with_bootstrapped_token(&server).await;
491        mount_file("file-1", "text/plain", &["parent-1"])
492            .mount(&server)
493            .await;
494        mount_folder("parent-1").mount(&server).await;
495        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
496            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
497            .respond_with(
498                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
499                    "id": "file-1", "name": "file-1", "version": "2",
500                })),
501            )
502            .expect(1)
503            .mount(&server)
504            .await;
505        let dir = tempfile::tempdir().unwrap();
506        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
507        let ledger_path = dir.path().join("lease-ledger.jsonl");
508
509        let outcome = edit(
510            &client,
511            &opts_with_lease("file-1", &ledger_path, "1"),
512            &[allow_rule()],
513        )
514        .await;
515        assert!(matches!(outcome.result, EditResult::Edited));
516
517        let reloaded = LeaseLedger::load(&ledger_path).unwrap();
518        assert_eq!(
519            reloaded.get("test-lease-token").unwrap().version,
520            "2",
521            "a successful write refreshes the lease's recorded version"
522        );
523    }
524
525    #[tokio::test]
526    async fn staleness_check_uses_a_version_fetched_after_the_ancestor_walk_not_before() {
527        // Regression test for the lease staleness-check TOCTOU (issue
528        // #1664): the *first* `files.get` (used for the permission-gate's
529        // ancestor walk) returns version "0" — stale, as if a foreign edit
530        // landed while the gate was still resolving. A *second*,
531        // freshly-fetched `files.get` returns "1", matching the lease. If
532        // the staleness check reused the first call's snapshot (the bug),
533        // this would incorrectly refuse as stale; using the fresh fetch,
534        // it must succeed.
535        let server = wiremock::MockServer::start().await;
536        let client = client_with_bootstrapped_token(&server).await;
537        wiremock::Mock::given(wiremock::matchers::method("GET"))
538            .and(wiremock::matchers::path("/drive/v3/files/file-1"))
539            .respond_with(
540                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
541                    "id": "file-1", "name": "file-1", "mimeType": "text/plain",
542                    "parents": ["parent-1"], "version": "0",
543                })),
544            )
545            .up_to_n_times(1)
546            .with_priority(1)
547            .mount(&server)
548            .await;
549        wiremock::Mock::given(wiremock::matchers::method("GET"))
550            .and(wiremock::matchers::path("/drive/v3/files/file-1"))
551            .respond_with(
552                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
553                    "id": "file-1", "name": "file-1", "mimeType": "text/plain",
554                    "parents": ["parent-1"], "version": "1",
555                })),
556            )
557            .with_priority(2)
558            .mount(&server)
559            .await;
560        mount_folder("parent-1").mount(&server).await;
561        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
562            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
563            .respond_with(
564                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
565                    "id": "file-1", "name": "file-1", "version": "2",
566                })),
567            )
568            .expect(1)
569            .mount(&server)
570            .await;
571        let dir = tempfile::tempdir().unwrap();
572        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
573        let ledger_path = dir.path().join("lease-ledger.jsonl");
574
575        let outcome = edit(
576            &client,
577            &opts_with_lease("file-1", &ledger_path, "1"),
578            &[allow_rule()],
579        )
580        .await;
581        assert!(
582            matches!(outcome.result, EditResult::Edited),
583            "{:?}",
584            outcome.result
585        );
586    }
587
588    #[tokio::test]
589    async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_edit_call() {
590        // The gate's ancestor walk succeeds off the first `files.get`, but
591        // the fresh re-fetch feeding the staleness check (ADR-0080 §6)
592        // fails — the edit must report `Failed` and never reach the
593        // media PATCH.
594        let server = wiremock::MockServer::start().await;
595        let client = client_with_bootstrapped_token(&server).await;
596        mount_file("file-1", "text/plain", &["parent-1"])
597            .up_to_n_times(1)
598            .with_priority(1)
599            .mount(&server)
600            .await;
601        wiremock::Mock::given(wiremock::matchers::method("GET"))
602            .and(wiremock::matchers::path("/drive/v3/files/file-1"))
603            .respond_with(wiremock::ResponseTemplate::new(500))
604            .with_priority(2)
605            .mount(&server)
606            .await;
607        mount_folder("parent-1").mount(&server).await;
608        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
609            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
610            .respond_with(wiremock::ResponseTemplate::new(200))
611            .expect(0)
612            .mount(&server)
613            .await;
614        let dir = tempfile::tempdir().unwrap();
615        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
616        let ledger_path = dir.path().join("lease-ledger.jsonl");
617
618        let outcome = edit(
619            &client,
620            &opts_with_lease("file-1", &ledger_path, "1"),
621            &[allow_rule()],
622        )
623        .await;
624        assert!(
625            matches!(outcome.result, EditResult::Failed { .. }),
626            "{:?}",
627            outcome.result
628        );
629        assert_eq!(outcome.file_name.as_deref(), Some("file-1"));
630    }
631
632    /// A responder that makes the ledger's directory read-only the instant
633    /// the media PATCH lands — i.e. after `check_and_lock_lease` has
634    /// already locked and loaded the ledger, but before
635    /// `finish_leased_write` tries to save it. `tempfile::NamedTempFile::new_in`
636    /// then fails to create its temp file, so `LeaseLedger::save` errors.
637    #[cfg(unix)]
638    struct MakeDirReadOnlyThenRespond {
639        dir: std::path::PathBuf,
640    }
641
642    #[cfg(unix)]
643    impl wiremock::Respond for MakeDirReadOnlyThenRespond {
644        fn respond(&self, _req: &wiremock::Request) -> wiremock::ResponseTemplate {
645            use std::os::unix::fs::PermissionsExt;
646            std::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o500)).unwrap();
647            wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
648                "id": "file-1", "name": "file-1", "version": "2",
649            }))
650        }
651    }
652
653    /// A failed post-write lease refresh (ADR-0080 §5) must never turn an
654    /// already-successful edit into a reported failure — see
655    /// `finish_leased_write`'s own doc comment.
656    #[cfg(unix)]
657    #[tokio::test]
658    async fn a_failed_lease_refresh_after_a_successful_write_does_not_fail_the_edit() {
659        use std::os::unix::fs::PermissionsExt;
660
661        let server = wiremock::MockServer::start().await;
662        let client = client_with_bootstrapped_token(&server).await;
663        mount_file("file-1", "text/plain", &["parent-1"])
664            .mount(&server)
665            .await;
666        mount_folder("parent-1").mount(&server).await;
667        let root = tempfile::tempdir().unwrap();
668        let _audit = crate::test_support::AuditLogGuard::redirect(root.path());
669        let ledger_dir = root.path().join("ledger");
670        std::fs::create_dir(&ledger_dir).unwrap();
671        let ledger_path = ledger_dir.join("lease-ledger.jsonl");
672        let opts = opts_with_lease("file-1", &ledger_path, "1");
673        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
674            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
675            .respond_with(MakeDirReadOnlyThenRespond {
676                dir: ledger_dir.clone(),
677            })
678            .expect(1)
679            .mount(&server)
680            .await;
681
682        let outcome = edit(&client, &opts, &[allow_rule()]).await;
683
684        // Restore write permission before the tempdir is dropped, or its
685        // own cleanup fails to remove a now-read-only directory.
686        std::fs::set_permissions(&ledger_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
687
688        assert!(
689            matches!(outcome.result, EditResult::Edited),
690            "a refresh failure must not fail the edit itself: {:?}",
691            outcome.result
692        );
693    }
694
695    #[tokio::test]
696    async fn denied_target_refuses_with_zero_edit_calls() {
697        let server = wiremock::MockServer::start().await;
698        let client = client_with_bootstrapped_token(&server).await;
699        mount_file("file-1", "text/plain", &["parent-1"])
700            .mount(&server)
701            .await;
702        mount_folder("parent-1").mount(&server).await;
703        // No PATCH /upload/drive/v3/files/file-1 mock mounted — an
704        // accidental edit attempt fails loudly with "no matching mock".
705
706        let outcome = edit(&client, &opts(false), &[]).await;
707        assert!(matches!(outcome.result, EditResult::Blocked { .. }));
708    }
709
710    #[tokio::test]
711    async fn google_native_document_is_refused_before_any_gate_or_network_call() {
712        let server = wiremock::MockServer::start().await;
713        let client = client_with_bootstrapped_token(&server).await;
714        mount_file(
715            "doc-1",
716            "application/vnd.google-apps.document",
717            &["parent-1"],
718        )
719        .mount(&server)
720        .await;
721        // Deliberately no mock for parent-1 (the gate never runs) and no
722        // PATCH mock — proves the refusal happens strictly before the
723        // ancestor-chain walk and before any mutating call, even though
724        // an allow-everything rule set would otherwise permit it.
725        let permissive_rule = FolderPermissionRule {
726            folder_id: Some("parent-1".to_string()),
727            file_id: None,
728            recursive: true,
729            allow: std::iter::once(DriveOperation::Edit).collect(),
730            deny: std::collections::HashSet::default(),
731            require_lease: true,
732        };
733
734        let outcome = edit(&client, &opts_for("doc-1", false), &[permissive_rule]).await;
735        assert!(matches!(outcome.result, EditResult::RefusedNativeDocument));
736    }
737
738    #[tokio::test]
739    async fn orphan_file_is_refused_as_having_no_visible_parents() {
740        // Before issue #1612 this reported `Blocked { decided_by: None }`,
741        // i.e. "no rule matched" — true but unhelpful, since no *folder*
742        // rule could ever match a target with no chain. It is now its own
743        // outcome, whose message names the `file_id` rule that would work.
744        let server = wiremock::MockServer::start().await;
745        let client = client_with_bootstrapped_token(&server).await;
746        mount_file("orphan", "text/plain", &[]).mount(&server).await;
747
748        let outcome = edit(&client, &opts_for("orphan", false), &[]).await;
749        assert!(matches!(
750            outcome.result,
751            EditResult::RefusedNoVisibleParents
752        ));
753        assert_eq!(outcome.resolved_folder_id, None);
754    }
755
756    #[tokio::test]
757    async fn edit_denies_when_any_current_parent_denies_even_if_another_allows() {
758        let server = wiremock::MockServer::start().await;
759        let client = client_with_bootstrapped_token(&server).await;
760        mount_file("file-1", "text/plain", &["allow-parent", "deny-parent"])
761            .mount(&server)
762            .await;
763        mount_folder("allow-parent").mount(&server).await;
764        mount_folder("deny-parent").mount(&server).await;
765        let rules = [allow_rule()]; // only "parent-1" is allowed; neither
766                                    // allow-parent nor deny-parent match it,
767                                    // so both fall to the default deny —
768                                    // this asserts deny-wins-across-parents
769                                    // even when BOTH parents individually
770                                    // resolve to the same (deny) verdict,
771                                    // and the multi-parent path is exercised.
772
773        let outcome = edit(&client, &opts(false), &rules).await;
774        assert!(matches!(outcome.result, EditResult::Blocked { .. }));
775        assert_eq!(
776            outcome.resolved_folder_id, None,
777            "multi-parent targets report no single resolved folder id"
778        );
779    }
780
781    #[tokio::test]
782    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
783        let server = wiremock::MockServer::start().await;
784        let client = client_with_bootstrapped_token(&server).await;
785        mount_file("file-1", "text/plain", &["parent-1"])
786            .mount(&server)
787            .await;
788        wiremock::Mock::given(wiremock::matchers::method("GET"))
789            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
790            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
791            .mount(&server)
792            .await;
793
794        let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
795        assert!(
796            matches!(outcome.result, EditResult::Failed { .. }),
797            "a fetch failure must never silently fall through to Edited/WouldEdit"
798        );
799    }
800
801    #[tokio::test]
802    async fn insufficient_scope_403_surfaces_both_write_flags() {
803        let server = wiremock::MockServer::start().await;
804        let client = client_with_bootstrapped_token(&server).await;
805        mount_file("file-1", "text/plain", &["parent-1"])
806            .mount(&server)
807            .await;
808        mount_folder("parent-1").mount(&server).await;
809        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
810            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
811            .respond_with(
812                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
813                    "error": {
814                        "message": "Insufficient Permission",
815                        "errors": [{"reason": "insufficientPermissions"}],
816                    }
817                })),
818            )
819            .mount(&server)
820            .await;
821        let dir = tempfile::tempdir().unwrap();
822        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
823        let ledger_path = dir.path().join("lease-ledger.jsonl");
824
825        let outcome = edit(
826            &client,
827            &opts_with_lease("file-1", &ledger_path, "1"),
828            &[allow_rule()],
829        )
830        .await;
831        let EditResult::Failed { detail } = outcome.result else {
832            panic!("expected Failed, got {:?}", outcome.result);
833        };
834        assert!(detail.contains("--write-file"), "{detail}");
835        assert!(detail.contains("--write-full"), "{detail}");
836    }
837
838    #[tokio::test]
839    async fn dry_run_never_calls_edit_endpoint() {
840        let server = wiremock::MockServer::start().await;
841        let client = client_with_bootstrapped_token(&server).await;
842        mount_file("file-1", "text/plain", &["parent-1"])
843            .mount(&server)
844            .await;
845        mount_folder("parent-1").mount(&server).await;
846
847        let outcome = edit(&client, &opts(true), &[allow_rule()]).await;
848        assert!(matches!(outcome.result, EditResult::WouldEdit));
849    }
850
851    #[tokio::test]
852    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
853        let server = wiremock::MockServer::start().await;
854        let client = client_with_bootstrapped_token(&server).await;
855        mount_file("file-1", "text/plain", &["parent-1"])
856            .expect(2)
857            .mount(&server)
858            .await;
859        mount_folder("parent-1").expect(2).mount(&server).await;
860
861        let dry_run_outcome = edit(&client, &opts(true), &[]).await;
862        let real_outcome = edit(&client, &opts(false), &[]).await;
863        assert!(matches!(dry_run_outcome.result, EditResult::Blocked { .. }));
864        assert!(matches!(real_outcome.result, EditResult::Blocked { .. }));
865    }
866
867    // ── file-id rules (issue #1612) ────────────────────────────────────
868
869    #[tokio::test]
870    async fn a_file_rule_grants_a_target_with_no_visible_parents() {
871        // The `drive edit` half of the shared-file gap: a binary file
872        // shared by link arrives with no parents, so no folder rule could
873        // ever apply to it.
874        let server = wiremock::MockServer::start().await;
875        let client = client_with_bootstrapped_token(&server).await;
876        mount_file("file-1", "text/plain", &[]).mount(&server).await;
877        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
878            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
879            .respond_with(
880                wiremock::ResponseTemplate::new(200)
881                    .set_body_json(serde_json::json!({"id": "file-1", "name": "file-1"})),
882            )
883            .expect(1)
884            .mount(&server)
885            .await;
886        let dir = tempfile::tempdir().unwrap();
887        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
888        let ledger_path = dir.path().join("lease-ledger.jsonl");
889
890        let outcome = edit(
891            &client,
892            &opts_with_lease("file-1", &ledger_path, "1"),
893            &[FolderPermissionRule::file("file-1").allowing([DriveOperation::Edit])],
894        )
895        .await;
896
897        assert!(
898            matches!(outcome.result, EditResult::Edited),
899            "{:?}",
900            outcome.result
901        );
902    }
903
904    #[tokio::test]
905    async fn a_parentless_target_is_now_refused_distinctly_not_generically_blocked() {
906        // Before #1612 this reported `Blocked { decided_by: None }`, which
907        // reads as "fix your rules" when no folder rule would have helped.
908        let server = wiremock::MockServer::start().await;
909        let client = client_with_bootstrapped_token(&server).await;
910        mount_file("file-1", "text/plain", &[]).mount(&server).await;
911
912        let outcome = edit(&client, &opts(false), &[]).await;
913
914        assert!(matches!(
915            outcome.result,
916            EditResult::RefusedNoVisibleParents
917        ));
918        assert_eq!(outcome.result.log_status(), "refused-no-visible-parents");
919    }
920
921    #[tokio::test]
922    async fn a_file_deny_beats_an_allowing_parent_folder() {
923        let server = wiremock::MockServer::start().await;
924        let client = client_with_bootstrapped_token(&server).await;
925        mount_file("file-1", "text/plain", &["parent-1"])
926            .mount(&server)
927            .await;
928        // No parent-1 mock and no PATCH mock: the file rule must decide
929        // before either is reached.
930
931        let outcome = edit(
932            &client,
933            &opts(false),
934            &[
935                allow_rule(),
936                FolderPermissionRule::file("file-1").denying([DriveOperation::Edit]),
937            ],
938        )
939        .await;
940
941        match &outcome.result {
942            EditResult::Blocked { decided_by } => {
943                let rule = decided_by.as_ref().expect("a file rule decided this");
944                assert_eq!(rule.kind_label(), "file");
945                assert_eq!(rule.id(), "file-1");
946            }
947            other => panic!("expected Blocked, got {other:?}"),
948        }
949    }
950
951    // ── the Drive write lease (ADR-0080 §9) ────────────────────────────
952
953    #[tokio::test]
954    async fn refuses_without_a_lease_when_the_rule_requires_one() {
955        let server = wiremock::MockServer::start().await;
956        let client = client_with_bootstrapped_token(&server).await;
957        mount_file("file-1", "text/plain", &["parent-1"])
958            .mount(&server)
959            .await;
960        mount_folder("parent-1").mount(&server).await;
961        // No PATCH mock mounted — a refusal must make zero mutating calls.
962        let dir = tempfile::tempdir().unwrap();
963        let _audit = AuditLogGuard::redirect(dir.path());
964
965        let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
966        assert!(matches!(outcome.result, EditResult::RefusedNoLease));
967        assert_eq!(outcome.result.log_status(), "refused-no-lease");
968    }
969
970    #[tokio::test]
971    async fn refuses_an_unknown_lease_token() {
972        let server = wiremock::MockServer::start().await;
973        let client = client_with_bootstrapped_token(&server).await;
974        mount_file("file-1", "text/plain", &["parent-1"])
975            .mount(&server)
976            .await;
977        mount_folder("parent-1").mount(&server).await;
978        let dir = tempfile::tempdir().unwrap();
979        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
980        let ledger_path = dir.path().join("lease-ledger.jsonl");
981        // Never seeded — the ledger exists nowhere near this token.
982
983        let opts = EditOptions {
984            lease_token: Some("bogus-token".to_string()),
985            ledger_path,
986            ..opts_for("file-1", false)
987        };
988        let outcome = edit(&client, &opts, &[allow_rule()]).await;
989        assert!(matches!(outcome.result, EditResult::RefusedLeaseExpired));
990    }
991
992    #[tokio::test]
993    async fn refuses_a_lease_when_the_ledger_is_unreadable() {
994        // A directory in place of the ledger file makes `LeaseLedger::load`
995        // fail with something other than a missing-file error — refused as
996        // expired rather than trusting an unreadable ledger (fail-closed,
997        // see `check_and_lock_lease`'s doc comment).
998        let server = wiremock::MockServer::start().await;
999        let client = client_with_bootstrapped_token(&server).await;
1000        mount_file("file-1", "text/plain", &["parent-1"])
1001            .mount(&server)
1002            .await;
1003        mount_folder("parent-1").mount(&server).await;
1004        let dir = tempfile::tempdir().unwrap();
1005        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1006        let ledger_path = dir.path().join("lease-ledger.jsonl");
1007        std::fs::create_dir(&ledger_path).unwrap();
1008
1009        let opts = EditOptions {
1010            lease_token: Some("any-token".to_string()),
1011            ledger_path,
1012            ..opts_for("file-1", false)
1013        };
1014        let outcome = edit(&client, &opts, &[allow_rule()]).await;
1015        assert!(matches!(outcome.result, EditResult::RefusedLeaseExpired));
1016    }
1017
1018    #[tokio::test]
1019    async fn refuses_an_expired_lease() {
1020        let server = wiremock::MockServer::start().await;
1021        let client = client_with_bootstrapped_token(&server).await;
1022        mount_file("file-1", "text/plain", &["parent-1"])
1023            .mount(&server)
1024            .await;
1025        mount_folder("parent-1").mount(&server).await;
1026        let dir = tempfile::tempdir().unwrap();
1027        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1028        let ledger_path = dir.path().join("lease-ledger.jsonl");
1029        let token = "expired-token".to_string();
1030        let mut ledger = LeaseLedger::default();
1031        ledger.insert(crate::drive::lease::ledger::LeaseRecord {
1032            token: token.clone(),
1033            file_id: "file-1".to_string(),
1034            version: "1".to_string(),
1035            modified_time: None,
1036            backup: crate::drive::lease::ledger::LeaseBackup::Bytes {
1037                path: std::path::PathBuf::from("/tmp/test-backup"),
1038                sha256: "deadbeef".to_string(),
1039                size: 0,
1040            },
1041            acquired_at: chrono::Utc::now() - chrono::Duration::hours(2),
1042            expires_at: chrono::Utc::now() - chrono::Duration::hours(1),
1043            released_at: None,
1044            superseded_by: None,
1045            restored_at: None,
1046            restored_sheet_id: None,
1047        });
1048        ledger.save(&ledger_path).unwrap();
1049
1050        let opts = EditOptions {
1051            lease_token: Some(token),
1052            ledger_path,
1053            ..opts_for("file-1", false)
1054        };
1055        let outcome = edit(&client, &opts, &[allow_rule()]).await;
1056        assert!(matches!(outcome.result, EditResult::RefusedLeaseExpired));
1057    }
1058
1059    #[tokio::test]
1060    async fn refuses_a_lease_bound_to_a_different_file() {
1061        let server = wiremock::MockServer::start().await;
1062        let client = client_with_bootstrapped_token(&server).await;
1063        mount_file("file-1", "text/plain", &["parent-1"])
1064            .mount(&server)
1065            .await;
1066        mount_folder("parent-1").mount(&server).await;
1067        let dir = tempfile::tempdir().unwrap();
1068        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1069        let ledger_path = dir.path().join("lease-ledger.jsonl");
1070        // Seeded for a *different* file id.
1071        let token = seed_lease(&ledger_path, "some-other-file", "1");
1072
1073        let opts = EditOptions {
1074            lease_token: Some(token),
1075            ledger_path,
1076            ..opts_for("file-1", false)
1077        };
1078        let outcome = edit(&client, &opts, &[allow_rule()]).await;
1079        assert!(matches!(outcome.result, EditResult::RefusedLeaseWrongFile));
1080    }
1081
1082    #[tokio::test]
1083    async fn refuses_a_stale_lease_when_the_file_has_moved() {
1084        let server = wiremock::MockServer::start().await;
1085        let client = client_with_bootstrapped_token(&server).await;
1086        // mount_file always returns version "1"; the lease below was
1087        // acquired against version "0" — a foreign edit landed since.
1088        mount_file("file-1", "text/plain", &["parent-1"])
1089            .mount(&server)
1090            .await;
1091        mount_folder("parent-1").mount(&server).await;
1092        let dir = tempfile::tempdir().unwrap();
1093        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1094        let ledger_path = dir.path().join("lease-ledger.jsonl");
1095        let token = seed_lease(&ledger_path, "file-1", "0");
1096
1097        let opts = EditOptions {
1098            lease_token: Some(token),
1099            ledger_path,
1100            ..opts_for("file-1", false)
1101        };
1102        let outcome = edit(&client, &opts, &[allow_rule()]).await;
1103        assert!(matches!(outcome.result, EditResult::RefusedLeaseStale));
1104    }
1105
1106    #[tokio::test]
1107    async fn edit_requires_a_lease_when_any_legacy_parent_requires_one_even_if_the_deciding_parent_opted_out(
1108    ) {
1109        // Regression test for the multi-parent lease-bypass (issue #1664):
1110        // `combine_across_parents` keeps only the *last* Allow decision's
1111        // `decided_by` — here that's `lenient-parent`, whose rule opts out
1112        // of the lease. `strict-parent`'s own requirement must still
1113        // apply, or a lease could be silently skipped just by attaching an
1114        // opted-out legacy parent to a file.
1115        let server = wiremock::MockServer::start().await;
1116        let client = client_with_bootstrapped_token(&server).await;
1117        mount_file("file-1", "text/plain", &["strict-parent", "lenient-parent"])
1118            .mount(&server)
1119            .await;
1120        mount_folder("strict-parent").mount(&server).await;
1121        mount_folder("lenient-parent").mount(&server).await;
1122        // No PATCH mock mounted — a refusal must make zero mutating calls.
1123        let dir = tempfile::tempdir().unwrap();
1124        let _audit = AuditLogGuard::redirect(dir.path());
1125        let rules = [
1126            FolderPermissionRule::folder("strict-parent").allowing([DriveOperation::Edit]),
1127            FolderPermissionRule::folder("lenient-parent")
1128                .allowing([DriveOperation::Edit])
1129                .requiring_lease(false),
1130        ];
1131
1132        let outcome = edit(&client, &opts(false), &rules).await;
1133        assert!(
1134            matches!(outcome.result, EditResult::RefusedNoLease),
1135            "{:?}",
1136            outcome.result
1137        );
1138    }
1139
1140    #[tokio::test]
1141    async fn a_held_ledger_lock_fails_the_edit_rather_than_risking_a_lease_double_spend() {
1142        // Regression test for the lease-check/refresh TOCTOU (issue
1143        // #1664): simulates a concurrent `drive lease`/`drive edit`
1144        // operation already holding the ledger lock. The edit must fail
1145        // outright rather than silently reading the ledger unlocked.
1146        let server = wiremock::MockServer::start().await;
1147        let client = client_with_bootstrapped_token(&server).await;
1148        mount_file("file-1", "text/plain", &["parent-1"])
1149            .mount(&server)
1150            .await;
1151        mount_folder("parent-1").mount(&server).await;
1152        let dir = tempfile::tempdir().unwrap();
1153        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1154        let ledger_path = dir.path().join("lease-ledger.jsonl");
1155        let token = seed_lease(&ledger_path, "file-1", "1");
1156        // Under flock (issue #1687), a busy lock now waits rather than
1157        // hard-failing (`check_and_lock_lease` -> `acquire_waiting`), so a
1158        // held `LedgerLock` no longer reproduces an immediate failure here.
1159        // A directory at the lock path does: opening it for write fails
1160        // outright with an I/O error, which is never retried.
1161        let mut lock_path = ledger_path.clone().into_os_string();
1162        lock_path.push(".lock");
1163        std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
1164        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1165            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1166            .respond_with(wiremock::ResponseTemplate::new(200))
1167            .expect(0)
1168            .mount(&server)
1169            .await;
1170
1171        let opts = EditOptions {
1172            lease_token: Some(token),
1173            ledger_path,
1174            ..opts_for("file-1", false)
1175        };
1176        let outcome = edit(&client, &opts, &[allow_rule()]).await;
1177        assert!(
1178            matches!(outcome.result, EditResult::Failed { .. }),
1179            "{:?}",
1180            outcome.result
1181        );
1182    }
1183
1184    #[tokio::test]
1185    async fn require_lease_false_writes_without_a_lease() {
1186        let server = wiremock::MockServer::start().await;
1187        let client = client_with_bootstrapped_token(&server).await;
1188        mount_file("file-1", "text/plain", &["parent-1"])
1189            .mount(&server)
1190            .await;
1191        mount_folder("parent-1").mount(&server).await;
1192        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1193            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1194            .respond_with(
1195                wiremock::ResponseTemplate::new(200)
1196                    .set_body_json(serde_json::json!({"id": "file-1", "name": "file-1"})),
1197            )
1198            .expect(1)
1199            .mount(&server)
1200            .await;
1201        let rule = FolderPermissionRule::folder("parent-1")
1202            .allowing([DriveOperation::Edit])
1203            .requiring_lease(false);
1204
1205        // No lease token presented at all, and no ledger exists.
1206        let outcome = edit(&client, &opts(false), &[rule]).await;
1207        assert!(matches!(outcome.result, EditResult::Edited));
1208    }
1209
1210    #[tokio::test]
1211    async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
1212        // ADR-0080 §13: `require_lease: false` relaxes the *requirement*,
1213        // not the *meaning* — a token volunteered anyway is checked exactly
1214        // like a required one, including staleness.
1215        let server = wiremock::MockServer::start().await;
1216        let client = client_with_bootstrapped_token(&server).await;
1217        // mount_file always returns version "1"; the lease below was
1218        // acquired against version "0" — a foreign edit landed since.
1219        mount_file("file-1", "text/plain", &["parent-1"])
1220            .mount(&server)
1221            .await;
1222        mount_folder("parent-1").mount(&server).await;
1223        // No PATCH mock mounted — a refusal must make zero mutating calls.
1224        let dir = tempfile::tempdir().unwrap();
1225        let _audit = AuditLogGuard::redirect(dir.path());
1226        let ledger_path = dir.path().join("lease-ledger.jsonl");
1227        let token = seed_lease(&ledger_path, "file-1", "0");
1228        let rule = FolderPermissionRule::folder("parent-1")
1229            .allowing([DriveOperation::Edit])
1230            .requiring_lease(false);
1231
1232        let opts = EditOptions {
1233            lease_token: Some(token),
1234            ledger_path,
1235            ..opts_for("file-1", false)
1236        };
1237        let outcome = edit(&client, &opts, &[rule]).await;
1238        assert!(
1239            matches!(outcome.result, EditResult::RefusedLeaseStale),
1240            "{:?}",
1241            outcome.result
1242        );
1243    }
1244
1245    #[tokio::test]
1246    async fn require_lease_false_still_refreshes_the_ledger_for_a_presented_lease() {
1247        // The issue's own named consequence: a volunteered lease that goes
1248        // unvalidated still moves Drive's `version`, leaving its ledger row
1249        // stale so a *later* required-lease write on the same file refuses
1250        // `RefusedLeaseStale` because of this write. Enforcing the lease
1251        // (ADR-0080 §13) closes that by refreshing the row like any other
1252        // successful leased write.
1253        let server = wiremock::MockServer::start().await;
1254        let client = client_with_bootstrapped_token(&server).await;
1255        mount_file("file-1", "text/plain", &["parent-1"])
1256            .mount(&server)
1257            .await;
1258        mount_folder("parent-1").mount(&server).await;
1259        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1260            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1261            .respond_with(
1262                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1263                    "id": "file-1", "name": "file-1", "version": "2",
1264                })),
1265            )
1266            .mount(&server)
1267            .await;
1268        let dir = tempfile::tempdir().unwrap();
1269        let _audit = AuditLogGuard::redirect(dir.path());
1270        let ledger_path = dir.path().join("lease-ledger.jsonl");
1271        let token = seed_lease(&ledger_path, "file-1", "1");
1272        let rule = FolderPermissionRule::folder("parent-1")
1273            .allowing([DriveOperation::Edit])
1274            .requiring_lease(false);
1275
1276        let opts = EditOptions {
1277            lease_token: Some(token.clone()),
1278            ledger_path: ledger_path.clone(),
1279            ..opts_for("file-1", false)
1280        };
1281        let outcome = edit(&client, &opts, &[rule]).await;
1282        assert!(
1283            matches!(outcome.result, EditResult::Edited),
1284            "{:?}",
1285            outcome.result
1286        );
1287
1288        let ledger = LeaseLedger::load(&ledger_path).unwrap();
1289        let record = ledger
1290            .get(&token)
1291            .expect("the lease is still in the ledger");
1292        assert_eq!(record.version, "2");
1293    }
1294
1295    #[tokio::test]
1296    async fn a_dry_run_never_needs_a_lease() {
1297        let server = wiremock::MockServer::start().await;
1298        let client = client_with_bootstrapped_token(&server).await;
1299        mount_file("file-1", "text/plain", &["parent-1"])
1300            .mount(&server)
1301            .await;
1302        mount_folder("parent-1").mount(&server).await;
1303        // No PATCH mock, no lease token, no ledger — a dry run must not
1304        // need any of them.
1305
1306        let outcome = edit(&client, &opts(true), &[allow_rule()]).await;
1307        assert!(matches!(outcome.result, EditResult::WouldEdit));
1308    }
1309
1310    // ── the write's own audit trail (ADR-0080 §11) ─────────────────────
1311
1312    #[tokio::test]
1313    async fn a_successful_edit_writes_a_pending_intent_and_an_allowed_outcome() {
1314        let server = wiremock::MockServer::start().await;
1315        let client = client_with_bootstrapped_token(&server).await;
1316        mount_file("file-1", "text/plain", &["parent-1"])
1317            .mount(&server)
1318            .await;
1319        mount_folder("parent-1").mount(&server).await;
1320        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1321            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1322            .respond_with(
1323                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1324                    "id": "file-1", "name": "file-1", "version": "2",
1325                })),
1326            )
1327            .mount(&server)
1328            .await;
1329        let dir = tempfile::tempdir().unwrap();
1330        let audit = AuditLogGuard::redirect(dir.path());
1331        let ledger_path = dir.path().join("lease-ledger.jsonl");
1332
1333        let outcome = edit(
1334            &client,
1335            &opts_with_lease("file-1", &ledger_path, "1"),
1336            &[allow_rule()],
1337        )
1338        .await;
1339        assert!(matches!(outcome.result, EditResult::Edited));
1340
1341        let records = audit.records();
1342        assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
1343        // Both halves share the lease id, and `command` has the same
1344        // segmented shape every other record kind uses.
1345        for record in &records {
1346            assert_eq!(record.command, ["drive", "edit"]);
1347            assert_eq!(
1348                record.context.get("lease_id").map(String::as_str),
1349                Some("test-lease-token")
1350            );
1351        }
1352        assert_eq!(
1353            records[0].context.get("version_before").map(String::as_str),
1354            Some("1")
1355        );
1356        assert_eq!(
1357            records[1].context.get("version_after").map(String::as_str),
1358            Some("2")
1359        );
1360        assert_eq!(records[1].error, None);
1361    }
1362
1363    #[tokio::test]
1364    async fn a_failed_edit_after_a_valid_lease_writes_a_failed_outcome() {
1365        let server = wiremock::MockServer::start().await;
1366        let client = client_with_bootstrapped_token(&server).await;
1367        mount_file("file-1", "text/plain", &["parent-1"])
1368            .mount(&server)
1369            .await;
1370        mount_folder("parent-1").mount(&server).await;
1371        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1372            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1373            .respond_with(wiremock::ResponseTemplate::new(500))
1374            .mount(&server)
1375            .await;
1376        let dir = tempfile::tempdir().unwrap();
1377        let audit = AuditLogGuard::redirect(dir.path());
1378        let ledger_path = dir.path().join("lease-ledger.jsonl");
1379
1380        let outcome = edit(
1381            &client,
1382            &opts_with_lease("file-1", &ledger_path, "1"),
1383            &[allow_rule()],
1384        )
1385        .await;
1386        let EditResult::Failed { detail } = &outcome.result else {
1387            panic!("expected Failed, got {:?}", outcome.result);
1388        };
1389
1390        let records = audit.records();
1391        assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
1392        // The outcome carries the same error the CLI reports.
1393        assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
1394    }
1395}