Skip to main content

omni_dev/drive/
write_gate.rs

1//! Folder-scoped write-permission gate (issue #1574).
2//!
3//! `omni-dev`'s own local policy layer bounding `drive create`/`upload`/
4//! `edit`, independent of and enforced *in addition to* whatever the OAuth
5//! scope (`crate::drive::auth::DriveGrantedScopes`) would technically
6//! allow. Google's Drive scopes are all-or-nothing across a user's whole
7//! Drive — there is no Google-side way to say "this credential may only
8//! write inside folder X" — so this module fills that gap.
9//!
10//! Deliberately pure: zero `DriveClient`/network dependency, mirroring
11//! `crate::drive::visibility`'s contract exactly. Fetching the ancestor
12//! folder chain a target lives in is `crate::drive::folder_ancestry`'s job;
13//! this module only classifies an already-resolved chain.
14//!
15//! Named `write_gate`, not `permission(s)`, to avoid any confusion with
16//! `crate::drive::permissions_api` — Google's own sharing/ACL wrapper,
17//! a completely unrelated concept.
18//!
19//! # The algorithm
20//!
21//! A target (the `--parent` folder for `create`/`upload`, or a file's
22//! current parent folder(s) for `edit`/`sheets write`) is identified by
23//! its **ancestor chain**: `chain[0]` is the target folder itself,
24//! `chain[1]` its parent, `chain[2]` its grandparent, and so on up to
25//! Drive's root. [`resolve`] walks that chain looking for the closest
26//! (lowest-depth) rule naming any folder in it — a non-recursive rule only
27//! ever matches at depth 0, its own folder; a recursive rule matches at
28//! any depth. Two tie-breaks, both security-relevant and each covered by a
29//! dedicated test:
30//!
31//! - **Closest ancestor wins**: a rule on a subfolder overrides a broader
32//!   rule on its parent — the more specific grant/restriction is assumed
33//!   the more deliberate one.
34//! - **Deny beats allow at equal depth**: if two rules at the same depth
35//!   disagree, the safe direction wins.
36//!
37//! A rule may instead name a **file id** (issue #1612), which matches the
38//! target itself at **depth −1** — strictly closer than depth 0, so it
39//! beats every folder rule in both directions: a file `deny` overrides a
40//! recursive folder `allow`, and a file `allow` overrides a folder `deny`.
41//! That is the same "closest wins" principle, not an exception to it. Two
42//! consequences worth stating outright:
43//!
44//! - Because nothing in any chain can beat a file rule, a decisive one
45//!   short-circuits the ancestor walk entirely — see
46//!   [`resolve_for_file`] and
47//!   `crate::drive::folder_ancestry::resolve_decision_for_file_target`.
48//!   This is what makes a file **shared by link or email** grantable at
49//!   all: `files.get` returns only the parents the caller can see, so such
50//!   a file has none, and no folder rule could ever apply to it.
51//! - `combine_across_parents`' "deny wins" is a tie-break among *peers* —
52//!   a target's several legacy parents, all at the same level. A file rule
53//!   is not a peer, so a file `allow` still beats a denying parent.
54//!
55//! When no rule anywhere in the chain names `op`, [`DriveOperation::default_policy`]
56//! decides it: `Read` defaults to [`Verdict::Allow`], every write operation
57//! defaults to [`Verdict::Deny`]. This is where "disabled by default" for
58//! writes actually lives — there is deliberately no separate enabled/
59//! disabled toggle; an absent or empty rule list already means "deny
60//! everywhere" via this table alone.
61
62use std::collections::HashSet;
63
64use serde::{Deserialize, Serialize};
65
66/// A Drive operation this gate can permit or refuse. Reused directly as the
67/// settings-file rule shape (`crate::utils::settings::WritePermissionsSettings`)
68/// — no separate wire type.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
70#[serde(rename_all = "kebab-case")]
71pub enum DriveOperation {
72    /// List/read/export/download.
73    Read,
74    /// Create a new file or folder.
75    Create,
76    /// Upload local content into a new file.
77    Upload,
78    /// Replace an existing file's content.
79    Edit,
80    /// Write cells into an existing Google Sheet via the Sheets API
81    /// (issue #1589, [ADR-0073](../../docs/adrs/adr-0073.md) §3).
82    ///
83    /// Deliberately **not** folded into [`Self::Edit`]. Every existing
84    /// `allow: ["edit"]` rule was written when `drive edit` refused every
85    /// Google-native document outright, so reusing `Edit` here would
86    /// retroactively upgrade those rules into cell-write permission with no
87    /// config change and no re-consent — the exact silent widening this
88    /// gate's default-deny posture exists to prevent.
89    ///
90    /// Since issue #1798 ([ADR-0081](../../docs/adrs/adr-0081.md) §5), also
91    /// required (alongside [`Self::SheetsStructure`]) for `add-pivot-table`
92    /// — a pivot table is created by `updateCells`, writing through the
93    /// grid with a server-computed rendered extent, which is this
94    /// operation's territory by construction. `delete-pivot-table` needs
95    /// this operation alone.
96    ///
97    /// Since issue #1839 ([ADR-0083](../../docs/adrs/adr-0083.md) §4), also
98    /// the clipboard-style paste family — `cut-paste`/`copy-paste` with a
99    /// value-only `--paste-type` (`values`/`formula`) and `paste-data`'s
100    /// `values`/`formula` types — since each writes ordinary cell content
101    /// into a range this crate already lets `sheets clear`/`write` reach.
102    /// `cut-paste` additionally requires [`Self::SheetsStructure`] whatever
103    /// `--paste-type` names, since it always clears its source's formats
104    /// and merges too; `copy-paste`/`paste-data` require it alongside this
105    /// operation only for `--paste-type normal`, per that variant's doc
106    /// comment.
107    /// Since issue #1840 ([ADR-0083](../../docs/adrs/adr-0083.md) §1), also
108    /// covers `auto-fill` (`autoFill`) alone: it writes ordinary cell
109    /// content into a range the request names or derives, exactly what
110    /// this operation already permits via `sheets clear` followed by
111    /// `sheets write` of the same range. Provisional on ADR-0083 §5's
112    /// live-verification rule — if a fill is found to carry the source
113    /// cells' formatting, `auto-fill` moves to the two-operation
114    /// composition `add-pivot-table` uses, alongside [`Self::SheetsStructure`].
115    ///
116    /// Since issue #1841 ([ADR-0083](../../docs/adrs/adr-0083.md) §1), also
117    /// required for `find-replace`. Its range, sheet and all-sheets scopes
118    /// replace only cell values or formulas, already covered by `sheets
119    /// write` and `sheets clear`; scope does not create a new permission
120    /// boundary within a spreadsheet.
121    ///
122    /// Since issue #1842 ([ADR-0083](../../docs/adrs/adr-0083.md) §3), also
123    /// one half of `sort-range`'s gate, alongside [`Self::SheetsStructure`]
124    /// — re-gated by issue #1870 after live verification found a sorted row
125    /// carries its formatting, notes and data-validation rules with it (see
126    /// that variant's doc comment). It shipped first on this operation
127    /// alone, on a values-only reading: it permutes values within a
128    /// caller-named range, which a `sheets-write` grant could already
129    /// replace or clear.
130    ///
131    /// Since issue #1843 ([ADR-0083](../../docs/adrs/adr-0083.md) §1),
132    /// also one half of `text-to-columns`' gate, for the cell content the
133    /// split writes — but **not** the whole of it. ADR-0083 §1 proposed
134    /// this operation alone and §5 made that provisional on live
135    /// verification; the live run found a split carries the source cell's
136    /// formatting into the spill cells, so §5's fixed consequence
137    /// applies and `text-to-columns` needs [`Self::SheetsStructure`] too,
138    /// the two-operation composition `add-pivot-table` uses.
139    ///
140    /// Since issue #1845 ([ADR-0083](../../docs/adrs/adr-0083.md) §§3, 5),
141    /// also one half of `randomize-range`'s gate, alongside
142    /// [`Self::SheetsStructure`], for the cell values a reorder permutes.
143    /// ADR-0083 §3 proposed this operation alone and §5 made that
144    /// provisional on live verification, same as `text-to-columns`; the
145    /// live run found a reordered row carries its formatting, notes and
146    /// data-validation rules with it, so §5's fixed consequence applies
147    /// here too.
148    ///
149    /// Since issue #1940, also one half of `set-basic-filter --sort-by`'s
150    /// gate, alongside [`Self::SheetsStructure`]: a basic filter's sort is
151    /// applied by physically reordering the rows, the same permutation as
152    /// `randomize-range`, so it takes the same union (see that variant's
153    /// doc comment).
154    ///
155    /// Since issue #1844 ([ADR-0083](../../docs/adrs/adr-0083.md) §1),
156    /// also `trim-whitespace`: it rewrites cell content in place, which a
157    /// `sheets write` of the same range could already replace outright.
158    /// Unlike the reorder verbs above, it stays on this operation alone:
159    /// issue #1877's live check found a trim moves no formatting, and even
160    /// rebases rich-text runs to keep them on the characters that remain.
161    /// Its sibling verb in that issue, `delete-duplicates`, takes
162    /// [`Self::SheetsDelete`] instead — two verbs shipped together under
163    /// two different operations, which is the per-verb mapping working as
164    /// intended, not a special case.
165    SheetsWrite,
166    /// Structurally edit an existing Google Sheet via `spreadsheets.batchUpdate`
167    /// (issue #1613, [ADR-0075](../../docs/adrs/adr-0075.md) §1) — adding,
168    /// renaming and inserting rows or columns; since issue #1643
169    /// ([ADR-0078](../../docs/adrs/adr-0078.md)), cell/border formatting,
170    /// merging, auto-resize, column width/row height, data validation,
171    /// `duplicateSheet`, and sheet reorder/hide. Since issue #1795
172    /// ([ADR-0081](../../docs/adrs/adr-0081.md) §4), also developer-metadata
173    /// management (`set-developer-metadata`/`delete-developer-metadata`),
174    /// restricted to `DOCUMENT` visibility — `PROJECT`-visibility metadata,
175    /// which belongs to a different OAuth client, is never reachable
176    /// through this surface; since issue #1793
177    /// ([ADR-0081](../../docs/adrs/adr-0081.md) §1), conditional formatting
178    /// (`add-conditional-format`/`update-conditional-format`/
179    /// `delete-conditional-format`); since issue #1796
180    /// ([ADR-0081](../../docs/adrs/adr-0081.md) §2), also named-range
181    /// add/update/delete: a named range is a label over a region, not grid
182    /// data, so removing one leaves every cell's stored value and formula
183    /// text untouched — even though every cell formula referencing the
184    /// removed name starts evaluating to `#REF!`. That
185    /// visible-but-not-destructive effect is mitigated, not by a stronger
186    /// gate, but by `delete-named-range` scanning the workbook's cell
187    /// formulas for the name and reporting the count and A1 locations of
188    /// every reference before it deletes, in both `--dry-run` and the real
189    /// run; and, since issue #1797
190    /// ([ADR-0081](../../docs/adrs/adr-0081.md) §3), charts and slicers
191    /// (`add-chart`/`update-chart`/`delete-chart`/`add-slicer`/
192    /// `update-slicer`/`delete-slicer`) — including the two deletes, which
193    /// destroy an unrecoverable embedded object rather than grid data, and
194    /// so join this operation (not [`Self::SheetsDelete`]) on the same
195    /// "property of the sheet, not the sheet's data" reasoning as
196    /// `unmerge-cells`/`clear-data-validation`. None of that later set
197    /// destroys *grid* data either, which is what earns it the same
198    /// operation as the original three rather than one of its own.
199    ///
200    /// Since issue #1798 ([ADR-0081](../../docs/adrs/adr-0081.md) §5), also
201    /// required (alongside [`Self::SheetsWrite`]) for `add-pivot-table` —
202    /// the first capability in the crate resolving **two** operations
203    /// together rather than one. A pivot table is also a named, persistent,
204    /// structural feature of a sheet, which is why it needs this operation
205    /// too and not `SheetsWrite` alone.
206    ///
207    /// Since issue #1832 ([ADR-0082](../../docs/adrs/adr-0082-banded-ranges.md)),
208    /// also banded ranges (`add-banding`/`update-banding`/`delete-banding`)
209    /// — alternating row/column colors applied to a range, the same
210    /// "property of the sheet, not the sheet's data" reasoning as
211    /// `unmerge-cells`/`clear-data-validation`; removing one destroys no
212    /// grid data.
213    ///
214    /// Since issue #1833 ([ADR-0084](../../docs/adrs/adr-0084-dimension-groups.md)),
215    /// also dimension groups (`add-dimension-group`/`update-dimension-group`/
216    /// `delete-dimension-group`) — the collapsible +/- outline over a row
217    /// or column span, the same reasoning again; removing a group destroys
218    /// no grid data either.
219    ///
220    /// Since issue #1835 ([ADR-0085](../../docs/adrs/adr-0085.md)), also
221    /// `update-sheet-properties` — frozen rows/columns, tab color,
222    /// right-to-left layout, and hidden gridlines, all per-sheet view state
223    /// rather than grid data.
224    ///
225    /// Since issue #1836 ([ADR-0086](../../docs/adrs/adr-0086-workbook-properties.md)),
226    /// also `update-workbook-properties` — locale, time zone, automatic
227    /// recalculation, and iterative calculation. The one capability in this
228    /// variant with no *sheet* target at all, since it acts on the workbook
229    /// itself; turning iterative calculation on changes what an unchanged
230    /// circular-reference formula elsewhere *evaluates to* rather than
231    /// touching any formula directly, the same shape of value effect
232    /// ADR-0081 §2 already accepted here for named-range deletion.
233    ///
234    /// Since issue #1838, also `insert-range` — the exact inverse of
235    /// `delete-range`'s cell-range half, inserting empty cells into a
236    /// sub-rectangle and shifting the existing cells in that column/row
237    /// extent right or down. It joins this operation, not
238    /// [`Self::SheetsDelete`], because no cell value is discarded: every
239    /// shifted cell keeps its content and the vacated cells become empty.
240    /// The one asymmetry with `delete-range`: a cell pushed past the
241    /// sheet's current grid extent by the shift is silently dropped by the
242    /// Sheets API, so this capability can lose data at the grid edge even
243    /// though it destroys none within it.
244    ///
245    /// Since issue #1837, also moving/resizing an existing chart or slicer
246    /// (`move-chart`/`move-slicer`, `updateEmbeddedObjectPosition`) and
247    /// setting or clearing a chart's border colour (`update-chart-border`,
248    /// `updateEmbeddedObjectBorder`) — the same operation as the rest of
249    /// this chart/slicer set (`add-chart`/`update-chart`/`delete-chart`/
250    /// `add-slicer`/`update-slicer`/`delete-slicer`), since a move or a
251    /// border change discards no data either.
252    ///
253    /// Since issue #1839 ([ADR-0083](../../docs/adrs/adr-0083.md) §4), also
254    /// required (alongside [`Self::SheetsWrite`]) for `cut-paste`
255    /// unconditionally, and for `copy-paste`/`paste-data` when
256    /// `--paste-type` is `normal` (formats and merges travel with the cell
257    /// content) — plus required *alone*, no [`Self::SheetsWrite`] needed,
258    /// for `copy-paste`/`paste-data`'s presentation-only `format` type,
259    /// which writes nothing this crate treats as a cell value. `cut-paste`
260    /// needs this operation whatever it pastes because the source is
261    /// always cleared of its formats and merges too, the same "property of
262    /// the sheet, not the sheet's data" removal `unmerge-cells`/
263    /// `clear-data-validation` already make under this operation.
264    ///
265    /// Deliberately **not** folded into [`Self::SheetsWrite`], for the same
266    /// reason that one is not folded into [`Self::Edit`]. Every existing
267    /// `allow: ["sheets-write"]` rule was written when structural edits were
268    /// impossible, so reusing it here would retroactively upgrade those rules
269    /// into permission to restructure a workbook with no config change and no
270    /// re-consent.
271    ///
272    /// The same argument binds future work: row and sheet **deletion** must
273    /// not later join this variant either, or granting it today would silently
274    /// become consent to destroy data tomorrow. Nor may a *permission*
275    /// change — see [`Self::SheetsProtection`], carved out of this same
276    /// deferred set for exactly that reason.
277    ///
278    /// Since issue #1843 ([ADR-0083](../../docs/adrs/adr-0083.md) §§1, 5),
279    /// also the second half of `text-to-columns`' gate, alongside
280    /// [`Self::SheetsWrite`]. Not because the split is a structural edit
281    /// in itself, but because it was measured **propagating the source
282    /// cell's formatting** into spill cells that had none: a live split of
283    /// a bold, pink column left every cell it wrote bold and pink. That is
284    /// this operation's own subject matter, so a `sheets-write` grant
285    /// alone cannot confer it.
286    ///
287    /// Since issue #1845 ([ADR-0083](../../docs/adrs/adr-0083.md) §§3, 5),
288    /// also the second half of `randomize-range`'s gate, alongside
289    /// [`Self::SheetsWrite`]. Not because a reorder is a structural edit in
290    /// itself, but because it was measured carrying a row's **formatting,
291    /// notes and data-validation rules** with it, and rewriting the
292    /// relative references of any formula moved along with it — a live
293    /// probe reordering a bold, pink row with a note, a validation rule
294    /// and a relative formula moved all four with the row. That is this
295    /// operation's own subject matter, so a `sheets-write` grant alone
296    /// cannot confer it.
297    ///
298    /// Since issue #1870, `sort-range` needs this operation too, alongside
299    /// [`Self::SheetsWrite`] — the same live-verification finding as
300    /// `randomize-range`: formatting, notes and data-validation rules
301    /// travel with a reordered row, and an in-range relative formula
302    /// rewrites to keep pointing at its own row. It shipped first (#1842)
303    /// on `SheetsWrite` alone, before this was measured.
304    ///
305    /// Since issue #1940, `set-basic-filter --sort-by` resolves this
306    /// operation *and* [`Self::SheetsWrite`] — every other filter verb, and
307    /// `set-basic-filter` without `--sort-by`, still resolves this one
308    /// alone (ADR-0081 §1). A basic filter's sort physically reorders the
309    /// filtered range's rows, the same permutation `randomize-range`
310    /// performs, and the reorder outlives `clear-basic-filter`; so it takes
311    /// `randomize-range`'s union rather than being treated as view state.
312    SheetsStructure,
313    /// Destructively edit an existing Google Sheet via `spreadsheets.batchUpdate`
314    /// (issue #1623, [ADR-0077](../../docs/adrs/adr-0077-sheets-deletion-via-batchupdate.md)) — deleting a
315    /// sheet, a row/column range, or an arbitrary cell range.
316    ///
317    /// Deliberately **not** folded into [`Self::SheetsStructure`], per the
318    /// binding clause on that variant: every existing `allow:
319    /// ["sheets-structure"]` rule was written when deletion was impossible,
320    /// so reusing it here would retroactively upgrade those rules into
321    /// permission to destroy data with no config change and no re-consent.
322    ///
323    /// The same argument binds future work: no other operation should later
324    /// join this variant without the same re-consent reasoning.
325    ///
326    /// Since issue #1844 ([ADR-0083](../../docs/adrs/adr-0083.md) §2),
327    /// also `delete-duplicates` — the first capability to join this
328    /// variant since ADR-0077 defined it, and admitted under exactly the
329    /// re-consent reasoning the clause above demands: an operator who
330    /// granted `sheets-delete` consented to having cells removed and
331    /// shifted up within a caller-named range by this tool, and
332    /// `delete-duplicates` does just that. Content outside the range
333    /// stays in place. It asks for nothing beyond that.
334    ///
335    /// One thing about it *is* new, and is recorded here rather than
336    /// buried in the verb: every other capability under this operation
337    /// removes cells the caller named **by address**, whereas
338    /// `delete-duplicates` removes rows the **server** selects from the
339    /// data — by an equality rule that ignores letter case, formatting and
340    /// formulas, and that reaches rows hidden by a filter. A
341    /// `sheets-delete` grant used through this verb therefore deletes
342    /// whatever that rule finds. ADR-0083 §2 accepts that as within the
343    /// grant's consent (rows removed from a range this tool was told to
344    /// act on) and mitigates it where it belongs — in the verb, which
345    /// states the rule in both the preview and the real run rather than
346    /// implying the caller chose the rows.
347    SheetsDelete,
348    /// Add, change or remove a protected range on an existing Google Sheet
349    /// via `spreadsheets.batchUpdate` (issue #1643,
350    /// [ADR-0078](../../docs/adrs/adr-0078.md)).
351    ///
352    /// Deliberately **not** folded into [`Self::SheetsStructure`], and the
353    /// reason is different in kind from every other split on this enum: a
354    /// protected range is *who may edit*, not *what the sheet contains*.
355    /// `updateProtectedRange` can widen the set of editors and
356    /// `deleteProtectedRange` removes a guard someone deliberately placed —
357    /// both are permission changes inside the document, closer in spirit to
358    /// `drive permissions` than to any cell or structural write this tool
359    /// makes. Folding it into `sheets-structure` would let a grant issued
360    /// for "may reformat/validate/restructure this workbook" silently
361    /// double as "may also change who can edit it" — a widening in kind,
362    /// not merely in scope, which is why this earns its own operation
363    /// rather than joining the set `sheets-structure`'s own doc comment
364    /// just absorbed.
365    ///
366    /// This binds future work too: nothing reachable under this operation
367    /// may ever touch a *file-level* Drive permission (`drive permissions`
368    /// is that surface, and stays the only one).
369    SheetsProtection,
370    /// Replace or append *text* in an existing Google Doc via the Docs API
371    /// (issue #1615, [ADR-0076](../../docs/adrs/adr-0076.md) §2).
372    ///
373    /// Folded into neither [`Self::Edit`] nor [`Self::SheetsWrite`], and the
374    /// argument runs in both directions. Every `allow: ["edit"]` rule was
375    /// written when `drive edit` refused every Google-native document
376    /// outright — which it still does. Every `allow: ["sheets-write"]` rule
377    /// was written when Docs were unreachable through this tool, and that
378    /// operation is *lexically* about cells, so making it govern prose would
379    /// be a lie told by the config vocabulary itself.
380    ///
381    /// **This binds future work.** A Docs *deletion* verb must not be folded
382    /// into this variant either: a grant issued today for "may replace text"
383    /// must not silently become consent to remove content tomorrow. When
384    /// deletion is designed it needs its own operation, or explicit
385    /// re-consent. Same rule ADR-0075 §1 records for `sheets-structure`.
386    /// Anchored insertion joins this operation because, like append, it only
387    /// adds text. Deletion is gated separately by `DocsDelete` (ADR-0094).
388    DocsWrite,
389    /// Moving an individual file to Trash or restoring it (ADR-0092).
390    /// Default-deny and lease-exempt: recovery is provided by Drive Trash.
391    /// A future permanent delete must use separate consent, never this variant.
392    Trash,
393    /// Replace text on ordinary Google Slides pages (ADR-0093).
394    /// Existing Edit/DocsWrite/SheetsWrite grants never authorize Slides.
395    /// Object deletion must receive its own operation and explicit consent.
396    SlidesWrite,
397    /// Anchor-addressed document content removal. Separate from `DocsWrite`:
398    /// an existing text-write grant must never silently widen to deletion.
399    /// Future structural/object deletion verbs need their own explicit decision.
400    DocsDelete,
401    /// Anchor-addressed styling; never implied by text-write or deletion grants.
402    DocsFormat,
403    /// Add empty tables, rows or columns, or create or delete named-range metadata.
404    /// Never authorizes the removal of document text.
405    DocsStructure,
406    /// Remove table rows/columns under new explicit destructive consent.
407    /// Neither DocsWrite nor the anchor-only DocsDelete grant authorizes this.
408    DocsTableDelete,
409}
410
411impl std::fmt::Display for DriveOperation {
412    /// Matches the `#[serde(rename_all = "kebab-case")]` wire form — used
413    /// by `drive permissions show`/`check`'s rendering.
414    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415        let s = match self {
416            Self::Read => "read",
417            Self::Create => "create",
418            Self::Upload => "upload",
419            Self::Edit => "edit",
420            Self::SheetsWrite => "sheets-write",
421            Self::SheetsStructure => "sheets-structure",
422            Self::SheetsDelete => "sheets-delete",
423            Self::SheetsProtection => "sheets-protection",
424            Self::DocsWrite => "docs-write",
425            Self::Trash => "trash",
426            Self::SlidesWrite => "slides-write",
427            Self::DocsDelete => "docs-delete",
428            Self::DocsFormat => "docs-format",
429            Self::DocsStructure => "docs-structure",
430            Self::DocsTableDelete => "docs-table-delete",
431        };
432        write!(f, "{s}")
433    }
434}
435
436impl DriveOperation {
437    /// The verdict when no configured rule names this operation anywhere in
438    /// a target's ancestor chain. `Read` stays open by default (unchanged
439    /// from today's behavior); every write defaults closed — the whole
440    /// "disabled by default" requirement lives in this one match, not a
441    /// separate on/off flag.
442    pub(crate) fn default_policy(self) -> Verdict {
443        match self {
444            Self::Read => Verdict::Allow,
445            Self::Create
446            | Self::Upload
447            | Self::Edit
448            | Self::SheetsWrite
449            | Self::SheetsStructure
450            | Self::SheetsDelete
451            | Self::SheetsProtection
452            | Self::DocsWrite
453            | Self::Trash
454            | Self::SlidesWrite
455            | Self::DocsStructure
456            | Self::DocsTableDelete
457            | Self::DocsDelete
458            | Self::DocsFormat => Verdict::Deny,
459        }
460    }
461
462    /// Whether an `Allow` verdict for this operation is ever backed by a
463    /// Drive write lease requirement (issue #1917).
464    ///
465    /// `Read` never mutates anything, and `Create`/`Upload` write into a
466    /// **new** file — there is no existing content for a lease to protect,
467    /// and neither engine (`create.rs`, `upload.rs`) ever gates on
468    /// `require_lease`; the field appears on their `LeasedWrite` values
469    /// only in test fixtures. `Trash` is reversible through Drive Trash and
470    /// exempt under ADR-0092, even with `require_lease: true`. Every other
471    /// operation replaces or otherwise
472    /// mutates a target's existing content and does gate on it via
473    /// [`decided_rule_requires_lease`]. This is consulted only by
474    /// `drive permissions check`'s diagnostic, to keep it from reporting a
475    /// lease requirement an operation could never actually be asked to
476    /// satisfy — it does not itself change what any engine enforces.
477    #[must_use]
478    pub const fn ever_requires_lease(self) -> bool {
479        match self {
480            Self::Read | Self::Create | Self::Upload | Self::Trash => false,
481            Self::Edit
482            | Self::SheetsWrite
483            | Self::SheetsStructure
484            | Self::SheetsDelete
485            | Self::SheetsProtection
486            | Self::DocsWrite
487            | Self::SlidesWrite
488            | Self::DocsStructure
489            | Self::DocsTableDelete
490            | Self::DocsDelete
491            | Self::DocsFormat => true,
492        }
493    }
494}
495
496/// One configured permission rule, keyed on **either** a folder id or a
497/// file id — exactly one of the two, enforced when the settings file is
498/// deserialized (see the private `RawPermissionRule` below).
499///
500/// Ids are Drive's own canonical ids, not paths — Drive objects have no
501/// stable, unique path (names collide, files can have multiple legacy
502/// parents), so identity is the id, exactly as the browser bridge's
503/// `OriginAllowlist` matches exact origin strings rather than URL
504/// patterns.
505///
506/// A `file_id` rule is what makes a file shared by link or email
507/// grantable at all (issue #1612): `files.get` returns only the parents
508/// this account can *see*, so such a file arrives with none, and no
509/// folder rule could ever apply to it.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511#[serde(try_from = "RawPermissionRule")]
512pub struct FolderPermissionRule {
513    /// The Drive folder id this rule matches, for a folder rule.
514    #[serde(skip_serializing_if = "Option::is_none")]
515    pub folder_id: Option<String>,
516    /// The Drive file id this rule matches, for a file rule. Matched at
517    /// depth −1 against the target itself, before any ancestor walk — see
518    /// [`resolve_file_rule`].
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub file_id: Option<String>,
521    /// When `true`, this rule also matches every descendant of
522    /// `folder_id`, not just the folder itself (depth > 0 in the ancestor
523    /// chain). A non-recursive rule only ever matches at depth 0.
524    /// Meaningless — and rejected — on a `file_id` rule: a file has no
525    /// descendants.
526    pub recursive: bool,
527    /// Operations explicitly permitted at this target.
528    pub allow: HashSet<DriveOperation>,
529    /// Operations explicitly refused at this target.
530    pub deny: HashSet<DriveOperation>,
531    /// Whether a content-mutating write matching this rule requires a
532    /// valid Drive write lease ([ADR-0080](../../docs/adrs/adr-0080.md)
533    /// §1/§13) in addition to this gate's own verdict. Defaults to `true`;
534    /// an operator sets it to `false` to relax the requirement for a
535    /// specific folder, which skips **both** the backup and the Touch ID
536    /// prompt (§13) — a rule that skipped only one of the two would either
537    /// surprise an operator who explicitly relaxed this, or ask for
538    /// consent to a guarantee it then doesn't provide. Independent of
539    /// `allow`/`deny`: the lease is orthogonal to this gate, not another
540    /// operation in its vocabulary.
541    #[serde(default = "default_require_lease")]
542    pub require_lease: bool,
543}
544
545/// The default for [`FolderPermissionRule::require_lease`] — a free
546/// function because `#[serde(default)]` needs a path, not a literal, and
547/// `true` is not `bool`'s own `Default`.
548fn default_require_lease() -> bool {
549    true
550}
551
552impl FolderPermissionRule {
553    /// A folder rule naming `folder_id`.
554    #[must_use]
555    pub fn folder(folder_id: impl Into<String>) -> Self {
556        Self {
557            folder_id: Some(folder_id.into()),
558            file_id: None,
559            recursive: false,
560            allow: HashSet::new(),
561            deny: HashSet::new(),
562            require_lease: default_require_lease(),
563        }
564    }
565
566    /// A file rule naming `file_id`.
567    #[must_use]
568    pub fn file(file_id: impl Into<String>) -> Self {
569        Self {
570            folder_id: None,
571            file_id: Some(file_id.into()),
572            recursive: false,
573            allow: HashSet::new(),
574            deny: HashSet::new(),
575            require_lease: default_require_lease(),
576        }
577    }
578
579    /// Sets `recursive`, for builder-style construction in tests and
580    /// callers assembling rules programmatically.
581    #[must_use]
582    pub fn recursive(mut self, recursive: bool) -> Self {
583        self.recursive = recursive;
584        self
585    }
586
587    /// Sets `require_lease`, for builder-style construction in tests and
588    /// callers assembling rules programmatically.
589    #[must_use]
590    pub fn requiring_lease(mut self, require_lease: bool) -> Self {
591        self.require_lease = require_lease;
592        self
593    }
594
595    /// Sets the `allow` set.
596    #[must_use]
597    pub fn allowing(mut self, ops: impl IntoIterator<Item = DriveOperation>) -> Self {
598        self.allow = ops.into_iter().collect();
599        self
600    }
601
602    /// Sets the `deny` set.
603    #[must_use]
604    pub fn denying(mut self, ops: impl IntoIterator<Item = DriveOperation>) -> Self {
605        self.deny = ops.into_iter().collect();
606        self
607    }
608}
609
610/// The wire shape [`FolderPermissionRule`] deserializes through, so that
611/// "exactly one of `folder_id`/`file_id`" is a **load error** rather than a
612/// rule that silently matches nothing.
613///
614/// This is also the backward-compatibility mechanism. An older binary
615/// reading a config containing a file rule sees `folder_id` missing from a
616/// required field and fails `Settings::load()` outright; `active_account_rules`
617/// then degrades to an empty rule set, which denies every write. That is
618/// blunt — it drops the account's credentials too — but it is the same
619/// fail-closed path [ADR-0073](../../docs/adrs/adr-0073.md) §3 already
620/// relies on for an unrecognised `DriveOperation`, and the alternative
621/// (a second, separately-named rule list) would be *silently ignored* by
622/// an older binary, dropping a file-level `deny` and letting a folder-level
623/// `allow` win.
624#[derive(Debug, Deserialize)]
625struct RawPermissionRule {
626    #[serde(default)]
627    folder_id: Option<String>,
628    #[serde(default)]
629    file_id: Option<String>,
630    #[serde(default)]
631    recursive: bool,
632    #[serde(default)]
633    allow: HashSet<DriveOperation>,
634    #[serde(default)]
635    deny: HashSet<DriveOperation>,
636    #[serde(default = "default_require_lease")]
637    require_lease: bool,
638}
639
640impl TryFrom<RawPermissionRule> for FolderPermissionRule {
641    type Error = String;
642
643    fn try_from(raw: RawPermissionRule) -> Result<Self, Self::Error> {
644        match (&raw.folder_id, &raw.file_id) {
645            (None, None) => {
646                return Err(
647                    "a write-permission rule must set either `folder_id` or `file_id`".to_string(),
648                )
649            }
650            (Some(_), Some(_)) => {
651                return Err(
652                    "a write-permission rule must set `folder_id` or `file_id`, not both \
653                            — a rule keys on one target"
654                        .to_string(),
655                )
656            }
657            _ => {}
658        }
659        if raw.recursive && raw.file_id.is_some() {
660            return Err(
661                "`recursive` is meaningless on a `file_id` rule — a file has no descendants; \
662                 drop it, or use `folder_id` to grant a whole subtree"
663                    .to_string(),
664            );
665        }
666        Ok(Self {
667            folder_id: raw.folder_id,
668            file_id: raw.file_id,
669            recursive: raw.recursive,
670            allow: raw.allow,
671            deny: raw.deny,
672            require_lease: raw.require_lease,
673        })
674    }
675}
676
677/// The result of resolving a single operation against a rule set.
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679pub enum Verdict {
680    /// The operation is permitted.
681    Allow,
682    /// The operation is refused.
683    Deny,
684}
685
686/// Which configured rule (if any) decided a [`Decision`].
687///
688/// `None` — the `Option` wrapping this, not a variant here — means no rule
689/// matched anywhere and the bare default policy decided it instead.
690/// Carried into the request log's `decided_by_folder_id`/
691/// `decided_by_file_id`/`decided_by_depth` context fields so a refusal is
692/// exactly as auditable as a success.
693///
694/// An enum rather than one struct with optional fields: a file rule has no
695/// depth (it names the target itself, at depth −1) and a folder rule has no
696/// file id, so "a file rule at depth 3" is a state that should not be
697/// expressible at all.
698#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
699#[serde(tag = "kind", rename_all = "kebab-case")]
700pub enum DecidingRule {
701    /// A `folder_id` rule matched the target's ancestor chain.
702    Folder {
703        /// The folder id of the rule that decided the verdict.
704        folder_id: String,
705        /// How many levels above the target this rule's folder sits (0 =
706        /// the target itself).
707        depth: usize,
708    },
709    /// A `file_id` rule named the target itself.
710    File {
711        /// The file id of the rule that decided the verdict.
712        file_id: String,
713    },
714}
715
716impl DecidingRule {
717    /// The id this rule keys on — a file id or a folder id.
718    ///
719    /// Deliberately *not* rendered here: the id is operator-supplied but
720    /// the strings it sits beside are not, and this module stays free of
721    /// the CLI layer. Every render site must therefore sanitize before the
722    /// line reaches a terminal — the three CLI sites (`drive edit`,
723    /// `upload`, `create`) run the id through `sanitize_for_terminal`
724    /// directly; the two engine-layer `describe` functions (`sheets write`,
725    /// `sheets create`) embed it raw and their CLI callers sanitize the
726    /// whole rendered line instead.
727    #[must_use]
728    pub fn id(&self) -> &str {
729        match self {
730            Self::Folder { folder_id, .. } => folder_id,
731            Self::File { file_id } => file_id,
732        }
733    }
734
735    /// `"folder"` or `"file"` — the noun the operator-facing messages use.
736    #[must_use]
737    pub const fn kind_label(&self) -> &'static str {
738        match self {
739            Self::Folder { .. } => "folder",
740            Self::File { .. } => "file",
741        }
742    }
743
744    /// `" (depth 2)"` for a folder rule, `""` for a file rule.
745    ///
746    /// Exists so the five `describe`/`print_report` sites are one identical
747    /// line — `"refused by rule on {kind} {id}{suffix}"` — and cannot drift
748    /// into disagreeing about how a file rule is worded.
749    #[must_use]
750    pub fn depth_suffix(&self) -> String {
751        match self {
752            Self::Folder { depth, .. } => format!(" (depth {depth})"),
753            Self::File { .. } => String::new(),
754        }
755    }
756}
757
758/// The outcome of [`resolve`]: whether an operation is permitted, and which
759/// rule (if any) decided it.
760#[derive(Debug, Clone, PartialEq, Eq)]
761pub struct Decision {
762    /// Whether the operation is permitted.
763    pub verdict: Verdict,
764    /// The configured rule that decided this, or `None` when the bare
765    /// default policy decided it instead.
766    pub decided_by: Option<DecidingRule>,
767}
768
769/// The verdict from a rule naming `file_id` itself, or `None` when no
770/// configured rule names it.
771///
772/// A file rule sits at **depth −1**: strictly more specific than any
773/// folder rule, including one on the file's own immediate parent. That
774/// is what lets a file shared by link or email — whose parents this
775/// account cannot see at all — be granted, and it is also why a decisive
776/// file rule short-circuits the ancestor walk entirely
777/// (`crate::drive::folder_ancestry::resolve_decision_for_file_target`), sparing
778/// every `files.get` the walk would have cost.
779///
780/// Deny still beats allow among file rules naming the same id, the same
781/// fail-closed direction every other tie-break in this module takes.
782#[must_use]
783pub fn resolve_file_rule(
784    file_id: &str,
785    op: DriveOperation,
786    rules: &[FolderPermissionRule],
787) -> Option<Decision> {
788    let mut deny_here = false;
789    let mut allow_here = false;
790    for rule in rules {
791        if rule.file_id.as_deref() != Some(file_id) {
792            continue;
793        }
794        deny_here |= rule.deny.contains(&op);
795        allow_here |= rule.allow.contains(&op);
796    }
797    if !deny_here && !allow_here {
798        return None;
799    }
800    Some(Decision {
801        verdict: if deny_here {
802            Verdict::Deny
803        } else {
804            Verdict::Allow
805        },
806        decided_by: Some(DecidingRule::File {
807            file_id: file_id.to_string(),
808        }),
809    })
810}
811
812/// Resolves `op` for a **file** target: a rule naming `file_id` if one
813/// exists, else [`resolve`] against the file's ancestor `chain`.
814///
815/// The pure counterpart of
816/// `crate::drive::folder_ancestry::resolve_decision_for_file_target`. Callers
817/// whose target is a *folder* (`create`/`upload`'s `--parent`) call
818/// [`resolve`] directly instead — there is no file id to name, so a file
819/// rule can never participate.
820#[must_use]
821pub fn resolve_for_file(
822    file_id: &str,
823    chain: &[String],
824    op: DriveOperation,
825    rules: &[FolderPermissionRule],
826) -> Decision {
827    resolve_file_rule(file_id, op, rules).unwrap_or_else(|| resolve(chain, op, rules))
828}
829
830/// Resolves whether `op` is permitted against `chain` (depth 0 = the
831/// target folder itself, then parent, grandparent, ...) under `rules`. See
832/// the module doc for the full algorithm and its tie-breaks.
833///
834/// Only `folder_id` rules participate: a `file_id` rule names a target,
835/// not an ancestor, so it can never match a chain entry. See
836/// [`resolve_for_file`] for a file target.
837#[must_use]
838pub fn resolve(chain: &[String], op: DriveOperation, rules: &[FolderPermissionRule]) -> Decision {
839    let mut best: Option<(usize, Verdict)> = None;
840    for (depth, folder_id) in chain.iter().enumerate() {
841        let mut deny_here = false;
842        let mut allow_here = false;
843        for rule in rules {
844            if rule.folder_id.as_deref() != Some(folder_id.as_str()) {
845                continue;
846            }
847            if depth > 0 && !rule.recursive {
848                continue;
849            }
850            deny_here |= rule.deny.contains(&op);
851            allow_here |= rule.allow.contains(&op);
852        }
853        if !deny_here && !allow_here {
854            continue;
855        }
856        let verdict = if deny_here {
857            Verdict::Deny
858        } else {
859            Verdict::Allow
860        };
861        let is_closer = match best {
862            Some((best_depth, _)) => depth < best_depth,
863            None => true,
864        };
865        if is_closer {
866            best = Some((depth, verdict));
867        }
868    }
869    match best {
870        Some((depth, verdict)) => Decision {
871            verdict,
872            decided_by: Some(DecidingRule::Folder {
873                folder_id: chain[depth].clone(),
874                depth,
875            }),
876        },
877        None => Decision {
878            verdict: op.default_policy(),
879            decided_by: None,
880        },
881    }
882}
883
884/// Combines per-parent [`Decision`]s into one, for a target with more than
885/// one current parent (a legacy multi-parent file — Drive no longer
886/// permits creating new ones).
887///
888/// Deny wins across parents, the same fail-closed direction every other
889/// tie-break in this module takes. Callers with a single parent (the
890/// common case) can call [`resolve`] directly instead; an *orphan* target
891/// (zero parents) should call `resolve(&[], op, rules)` directly too,
892/// rather than calling this with zero decisions — a target's chain
893/// degenerates to "only the default policy applies," not to a policy
894/// about *no* chain, so `first` requires at least one decision by
895/// construction.
896///
897/// Shared by `drive edit` (whose target's chain starts at its *current*
898/// parents, unioned) and `drive permissions check` (whose target may
899/// itself be a file).
900#[must_use]
901pub fn combine_across_parents(
902    first: Decision,
903    rest: impl IntoIterator<Item = Decision>,
904) -> Decision {
905    rest.into_iter().fold(first, |acc, next| {
906        if acc.verdict == Verdict::Deny {
907            acc
908        } else {
909            next
910        }
911    })
912}
913
914/// The `(folder_id, file_id, depth)` triple
915/// `crate::request_log::DriveMutationOutcome`'s `decided_by_folder_id`/
916/// `decided_by_file_id`/`decided_by_depth` fields expect.
917///
918/// A named struct rather than a tuple: three same-shaped `Option`s in a
919/// row is exactly the signature a caller silently mis-orders.
920#[derive(Debug, Clone, Default, PartialEq, Eq)]
921pub struct DecidedByLogFields {
922    /// The deciding folder rule's id, when a folder rule decided it.
923    pub folder_id: Option<String>,
924    /// The deciding file rule's id, when a file rule decided it.
925    pub file_id: Option<String>,
926    /// The deciding folder rule's depth. Always `None` for a file rule,
927    /// which matches the target itself rather than an ancestor.
928    pub depth: Option<usize>,
929}
930
931/// Splits an optional [`DecidingRule`] into the log's flat fields.
932///
933/// Shared by `create`/`upload`/`edit`/`sheets write`'s otherwise
934/// near-identical `record_attempt` functions — each still builds its own
935/// `DriveMutationOutcome` (the verb-specific fields genuinely differ), but
936/// this was the one piece of extraction logic that was byte-for-byte the
937/// same in all of them. Kept here (rather than in `crate::request_log`,
938/// which stays decoupled from any one integration's internal types) and
939/// pure, matching this module's zero-I/O contract — it does not itself
940/// call `crate::request_log::record_drive_mutation`.
941#[must_use]
942pub fn decided_by_log_fields(decided_by: Option<&DecidingRule>) -> DecidedByLogFields {
943    match decided_by {
944        Some(DecidingRule::Folder { folder_id, depth }) => DecidedByLogFields {
945            folder_id: Some(folder_id.clone()),
946            file_id: None,
947            depth: Some(*depth),
948        },
949        Some(DecidingRule::File { file_id }) => DecidedByLogFields {
950            folder_id: None,
951            file_id: Some(file_id.clone()),
952            depth: None,
953        },
954        None => DecidedByLogFields::default(),
955    }
956}
957
958/// Whether the rule(s) that decided `decided_by` require a Drive write
959/// lease ([ADR-0080](../../docs/adrs/adr-0080.md) §1/§13), in addition to
960/// this gate's own verdict.
961///
962/// Deliberately a separate lookup rather than a field threaded through
963/// [`resolve`]/[`resolve_file_rule`]/[`combine_across_parents`]: those are
964/// well-exercised and security-critical, and `require_lease` is
965/// orthogonal to the verdict they compute — a rule's lease requirement
966/// never changes *whether* an operation is allowed, only whether a caller
967/// must also present a valid lease before acting on an `Allow`. Re-deriving
968/// it from `decided_by` against the same rule set keeps that code
969/// untouched.
970///
971/// `decided_by: None` (the bare default policy decided it) is treated as
972/// requiring a lease — moot in practice, since every write operation
973/// defaults to `Deny` (`DriveOperation::default_policy`), so a `None`
974/// `decided_by` never accompanies an `Allow` verdict a caller would act on.
975/// When more than one configured rule matches `decided_by` itself (two
976/// rules naming the same folder and depth, or the same file id), **any**
977/// of them requiring a lease is enough — the same safe-direction tie-break
978/// every other ambiguity in this gate resolves with.
979///
980/// This function only ever sees the **one** `decided_by` its caller passes
981/// in — it cannot by itself account for a legacy multi-parent target,
982/// where [`combine_across_parents`] keeps only the winning parent's
983/// `decided_by` and discards the rest. A caller with more than one parent
984/// decision in hand (`crate::drive::folder_ancestry::resolve_decision_for_parents`)
985/// must call this once per parent and OR the results together *before*
986/// folding the decisions, or a losing parent's own lease requirement would
987/// be silently dropped along with its `decided_by`.
988///
989/// `op` narrows `matching` to rules that actually govern this operation
990/// (`rule.allow`/`rule.deny` contains it) — without it, an unrelated rule
991/// on the same folder/depth (or file id) governing a *different*
992/// operation would still contribute its own `require_lease`, silently
993/// overriding a same-scope rule that explicitly opted this operation out.
994#[must_use]
995pub fn decided_rule_requires_lease(
996    decided_by: Option<&DecidingRule>,
997    op: DriveOperation,
998    rules: &[FolderPermissionRule],
999) -> bool {
1000    let governs_op =
1001        |rule: &&FolderPermissionRule| rule.allow.contains(&op) || rule.deny.contains(&op);
1002    let matching: Vec<&FolderPermissionRule> = match decided_by {
1003        None => return true,
1004        Some(DecidingRule::Folder { folder_id, depth }) => rules
1005            .iter()
1006            .filter(|rule| {
1007                rule.folder_id.as_deref() == Some(folder_id.as_str())
1008                    && (*depth == 0 || rule.recursive)
1009            })
1010            .filter(governs_op)
1011            .collect(),
1012        Some(DecidingRule::File { file_id }) => rules
1013            .iter()
1014            .filter(|rule| rule.file_id.as_deref() == Some(file_id.as_str()))
1015            .filter(governs_op)
1016            .collect(),
1017    };
1018    if matching.is_empty() {
1019        return true;
1020    }
1021    matching.iter().any(|rule| rule.require_lease)
1022}
1023
1024#[cfg(test)]
1025#[allow(clippy::unwrap_used, clippy::expect_used)]
1026mod tests {
1027    use super::*;
1028
1029    fn rule(
1030        folder_id: &str,
1031        recursive: bool,
1032        allow: &[DriveOperation],
1033        deny: &[DriveOperation],
1034    ) -> FolderPermissionRule {
1035        FolderPermissionRule::folder(folder_id)
1036            .recursive(recursive)
1037            .allowing(allow.iter().copied())
1038            .denying(deny.iter().copied())
1039    }
1040
1041    fn file_rule(
1042        file_id: &str,
1043        allow: &[DriveOperation],
1044        deny: &[DriveOperation],
1045    ) -> FolderPermissionRule {
1046        FolderPermissionRule::file(file_id)
1047            .allowing(allow.iter().copied())
1048            .denying(deny.iter().copied())
1049    }
1050
1051    fn folder_id_of(decision: &Decision) -> &str {
1052        match decision.decided_by.as_ref().unwrap() {
1053            DecidingRule::Folder { folder_id, .. } => folder_id,
1054            DecidingRule::File { .. } => panic!("expected a folder rule, got a file rule"),
1055        }
1056    }
1057
1058    fn chain(ids: &[&str]) -> Vec<String> {
1059        ids.iter().copied().map(ToString::to_string).collect()
1060    }
1061
1062    #[test]
1063    fn default_policy_allows_read_with_no_rules() {
1064        let decision = resolve(&chain(&["a"]), DriveOperation::Read, &[]);
1065        assert_eq!(decision.verdict, Verdict::Allow);
1066        assert_eq!(decision.decided_by, None);
1067    }
1068
1069    #[test]
1070    fn default_policy_denies_create_upload_edit_trash_with_no_rules() {
1071        for op in [
1072            DriveOperation::Create,
1073            DriveOperation::Upload,
1074            DriveOperation::Edit,
1075            DriveOperation::Trash,
1076        ] {
1077            let decision = resolve(&chain(&["a"]), op, &[]);
1078            assert_eq!(
1079                decision.verdict,
1080                Verdict::Deny,
1081                "{op:?} should default-deny"
1082            );
1083            assert_eq!(decision.decided_by, None);
1084        }
1085    }
1086
1087    #[test]
1088    fn ever_requires_lease_is_false_for_read_create_upload_and_trash() {
1089        for op in [
1090            DriveOperation::Read,
1091            DriveOperation::Create,
1092            DriveOperation::Upload,
1093            DriveOperation::Trash,
1094        ] {
1095            assert!(
1096                !op.ever_requires_lease(),
1097                "{op:?} should never take a lease"
1098            );
1099        }
1100    }
1101
1102    #[test]
1103    fn ever_requires_lease_is_true_for_every_content_mutating_operation() {
1104        for op in [
1105            DriveOperation::Edit,
1106            DriveOperation::SheetsWrite,
1107            DriveOperation::SheetsStructure,
1108            DriveOperation::SheetsDelete,
1109            DriveOperation::SheetsProtection,
1110            DriveOperation::DocsWrite,
1111            DriveOperation::DocsDelete,
1112            DriveOperation::DocsStructure,
1113            DriveOperation::DocsTableDelete,
1114        ] {
1115            assert!(op.ever_requires_lease(), "{op:?} should be lease-eligible");
1116        }
1117    }
1118
1119    #[test]
1120    fn trash_permission_serde_round_trip_does_not_widen_other_grants() {
1121        let rule: FolderPermissionRule = serde_json::from_value(serde_json::json!({
1122            "folder_id": "a", "allow": ["trash"]
1123        }))
1124        .unwrap();
1125        assert!(rule.allow.contains(&DriveOperation::Trash));
1126        assert_eq!(DriveOperation::Trash.to_string(), "trash");
1127        let serialized = serde_json::to_value(&rule).unwrap();
1128        assert_eq!(serialized["allow"], serde_json::json!(["trash"]));
1129        for op in [
1130            DriveOperation::Create,
1131            DriveOperation::Edit,
1132            DriveOperation::SheetsDelete,
1133        ] {
1134            let rule = FolderPermissionRule::folder("a").allowing([op]);
1135            assert_eq!(
1136                resolve(&chain(&["a"]), DriveOperation::Trash, &[rule]).verdict,
1137                Verdict::Deny
1138            );
1139        }
1140    }
1141
1142    #[test]
1143    fn recursive_rule_matches_deep_descendant() {
1144        let rules = [rule("root", true, &[DriveOperation::Create], &[])];
1145        let decision = resolve(
1146            &chain(&["child", "grandchild", "root"]),
1147            DriveOperation::Create,
1148            &rules,
1149        );
1150        assert_eq!(decision.verdict, Verdict::Allow);
1151        assert_eq!(folder_id_of(&decision), "root");
1152    }
1153
1154    #[test]
1155    fn non_recursive_rule_matches_own_folder_only() {
1156        let rules = [rule("target", false, &[DriveOperation::Create], &[])];
1157        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1158        assert_eq!(decision.verdict, Verdict::Allow);
1159    }
1160
1161    #[test]
1162    fn non_recursive_rule_does_not_match_child() {
1163        let rules = [rule("parent", false, &[DriveOperation::Create], &[])];
1164        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
1165        // Falls through to the default policy since the non-recursive rule
1166        // never matches at depth > 0.
1167        assert_eq!(decision.verdict, Verdict::Deny);
1168        assert_eq!(decision.decided_by, None);
1169    }
1170
1171    #[test]
1172    fn closest_ancestor_wins_deny_over_broader_allow() {
1173        let rules = [
1174            rule("child", true, &[], &[DriveOperation::Create]),
1175            rule("parent", true, &[DriveOperation::Create], &[]),
1176        ];
1177        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
1178        assert_eq!(decision.verdict, Verdict::Deny);
1179        assert_eq!(folder_id_of(&decision), "child");
1180    }
1181
1182    #[test]
1183    fn closest_ancestor_wins_allow_over_broader_deny() {
1184        // The inverse case — proves "closest wins" isn't secretly "deny
1185        // always wins": a closer *allow* beats a farther *deny*.
1186        let rules = [
1187            rule("child", true, &[DriveOperation::Create], &[]),
1188            rule("parent", true, &[], &[DriveOperation::Create]),
1189        ];
1190        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
1191        assert_eq!(decision.verdict, Verdict::Allow);
1192        assert_eq!(folder_id_of(&decision), "child");
1193    }
1194
1195    #[test]
1196    fn deny_beats_allow_at_equal_depth() {
1197        let rules = [
1198            rule("target", false, &[DriveOperation::Create], &[]),
1199            rule("target", false, &[], &[DriveOperation::Create]),
1200        ];
1201        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1202        assert_eq!(decision.verdict, Verdict::Deny);
1203    }
1204
1205    #[test]
1206    fn rule_on_unrelated_folder_does_not_apply() {
1207        let rules = [rule("unrelated", true, &[DriveOperation::Create], &[])];
1208        let decision = resolve(
1209            &chain(&["target", "parent"]),
1210            DriveOperation::Create,
1211            &rules,
1212        );
1213        assert_eq!(decision.verdict, Verdict::Deny);
1214        assert_eq!(decision.decided_by, None);
1215    }
1216
1217    #[test]
1218    fn empty_chain_orphan_file_uses_default_policy_only() {
1219        let rules = [rule("some-folder", true, &[DriveOperation::Create], &[])];
1220        let decision = resolve(&[], DriveOperation::Create, &rules);
1221        assert_eq!(decision.verdict, Verdict::Deny);
1222        assert_eq!(decision.decided_by, None);
1223    }
1224
1225    #[test]
1226    fn display_matches_the_serde_kebab_case_wire_form() {
1227        // Display is hand-written, so it can drift from the derive. Every
1228        // variant is asserted against a round-trip through serde rather
1229        // than a second hand-written literal, which would drift with it.
1230        for op in [
1231            DriveOperation::Read,
1232            DriveOperation::Create,
1233            DriveOperation::Upload,
1234            DriveOperation::Edit,
1235            DriveOperation::SheetsWrite,
1236            DriveOperation::SheetsStructure,
1237            DriveOperation::SheetsDelete,
1238            DriveOperation::SheetsProtection,
1239            DriveOperation::DocsWrite,
1240            DriveOperation::Trash,
1241            DriveOperation::DocsDelete,
1242            DriveOperation::DocsStructure,
1243            DriveOperation::DocsTableDelete,
1244        ] {
1245            let wire = serde_json::to_string(&op).unwrap();
1246            assert_eq!(
1247                format!("\"{op}\""),
1248                wire,
1249                "Display drifted from serde for {op:?}"
1250            );
1251        }
1252        // The pre-existing spellings are pinned literally: switching
1253        // `rename_all` to kebab-case must not have moved any of them.
1254        assert_eq!(DriveOperation::Read.to_string(), "read");
1255        assert_eq!(DriveOperation::Create.to_string(), "create");
1256        assert_eq!(DriveOperation::Upload.to_string(), "upload");
1257        assert_eq!(DriveOperation::Edit.to_string(), "edit");
1258        assert_eq!(DriveOperation::SheetsWrite.to_string(), "sheets-write");
1259        assert_eq!(
1260            DriveOperation::SheetsStructure.to_string(),
1261            "sheets-structure"
1262        );
1263        assert_eq!(DriveOperation::SheetsDelete.to_string(), "sheets-delete");
1264        assert_eq!(
1265            DriveOperation::SheetsProtection.to_string(),
1266            "sheets-protection"
1267        );
1268        assert_eq!(DriveOperation::DocsWrite.to_string(), "docs-write");
1269    }
1270
1271    #[test]
1272    fn sheets_write_defaults_to_deny_like_every_other_write() {
1273        let decision = resolve(&chain(&["f"]), DriveOperation::SheetsWrite, &[]);
1274        assert_eq!(decision.verdict, Verdict::Deny);
1275        assert!(decision.decided_by.is_none());
1276    }
1277
1278    #[test]
1279    fn an_edit_rule_does_not_grant_sheets_write() {
1280        // The whole reason for a separate variant: an existing
1281        // `allow: ["edit"]` rule must not silently gain cell-write power.
1282        let rules = [rule("target", true, &[DriveOperation::Edit], &[])];
1283        let edit = resolve(&chain(&["target"]), DriveOperation::Edit, &rules);
1284        let sheets = resolve(&chain(&["target"]), DriveOperation::SheetsWrite, &rules);
1285        assert_eq!(edit.verdict, Verdict::Allow);
1286        assert_eq!(sheets.verdict, Verdict::Deny);
1287    }
1288
1289    #[test]
1290    fn sheets_structure_defaults_to_deny_like_every_other_write() {
1291        let decision = resolve(&chain(&["f"]), DriveOperation::SheetsStructure, &[]);
1292        assert_eq!(decision.verdict, Verdict::Deny);
1293        assert!(decision.decided_by.is_none());
1294    }
1295
1296    #[test]
1297    fn slides_write_is_a_distinct_default_deny_leased_operation() {
1298        assert_eq!(DriveOperation::SlidesWrite.to_string(), "slides-write");
1299        assert!(DriveOperation::SlidesWrite.ever_requires_lease());
1300        assert_eq!(
1301            serde_json::to_string(&DriveOperation::SlidesWrite).unwrap(),
1302            "\"slides-write\""
1303        );
1304        assert_eq!(
1305            resolve(&chain(&["target"]), DriveOperation::SlidesWrite, &[]).verdict,
1306            Verdict::Deny
1307        );
1308        for granted in [
1309            DriveOperation::Edit,
1310            DriveOperation::DocsWrite,
1311            DriveOperation::SheetsWrite,
1312        ] {
1313            let rules = [rule("target", true, &[granted], &[])];
1314            assert_eq!(
1315                resolve(&chain(&["target"]), DriveOperation::SlidesWrite, &rules).verdict,
1316                Verdict::Deny
1317            );
1318        }
1319        let rules = [rule("target", true, &[DriveOperation::SlidesWrite], &[])];
1320        assert_eq!(
1321            resolve(&chain(&["target"]), DriveOperation::SlidesWrite, &rules).verdict,
1322            Verdict::Allow
1323        );
1324        for other in [
1325            DriveOperation::Edit,
1326            DriveOperation::DocsWrite,
1327            DriveOperation::SheetsWrite,
1328        ] {
1329            assert_eq!(
1330                resolve(&chain(&["target"]), other, &rules).verdict,
1331                Verdict::Deny
1332            );
1333        }
1334    }
1335
1336    #[test]
1337    fn docs_write_defaults_to_deny_like_every_other_write() {
1338        let decision = resolve(&chain(&["f"]), DriveOperation::DocsWrite, &[]);
1339        assert_eq!(decision.verdict, Verdict::Deny);
1340        assert!(decision.decided_by.is_none());
1341    }
1342
1343    #[test]
1344    fn a_sheets_write_rule_does_not_grant_sheets_structure() {
1345        // The whole reason for a second Sheets variant (issue #1613): an
1346        // existing `allow: ["sheets-write"]` rule was written when structural
1347        // edits were impossible, so it must not silently gain the power to
1348        // add, rename or reshape sheets.
1349        let rules = [rule("target", true, &[DriveOperation::SheetsWrite], &[])];
1350        let write = resolve(&chain(&["target"]), DriveOperation::SheetsWrite, &rules);
1351        let structure = resolve(&chain(&["target"]), DriveOperation::SheetsStructure, &rules);
1352        assert_eq!(write.verdict, Verdict::Allow);
1353        assert_eq!(structure.verdict, Verdict::Deny);
1354    }
1355
1356    #[test]
1357    fn an_edit_rule_does_not_grant_sheets_structure() {
1358        let rules = [rule("target", true, &[DriveOperation::Edit], &[])];
1359        let structure = resolve(&chain(&["target"]), DriveOperation::SheetsStructure, &rules);
1360        assert_eq!(structure.verdict, Verdict::Deny);
1361    }
1362
1363    /// ADR-0076 §2 runs ADR-0073 §3's argument in **three** directions, so
1364    /// all are pinned: an `edit` grant predates Docs being reachable at
1365    /// all, and both Sheets grants are lexically about cells.
1366    #[test]
1367    fn neither_an_edit_nor_a_sheets_write_nor_a_sheets_structure_rule_grants_docs_write() {
1368        for granted in [
1369            DriveOperation::Edit,
1370            DriveOperation::SheetsWrite,
1371            DriveOperation::SheetsStructure,
1372        ] {
1373            let rules = [rule("target", true, &[granted], &[])];
1374            let held = resolve(&chain(&["target"]), granted, &rules);
1375            let docs = resolve(&chain(&["target"]), DriveOperation::DocsWrite, &rules);
1376            assert_eq!(held.verdict, Verdict::Allow, "{granted:?}");
1377            assert_eq!(
1378                docs.verdict,
1379                Verdict::Deny,
1380                "an allow:[{granted}] rule must not leak into docs-write"
1381            );
1382        }
1383    }
1384
1385    /// The converse, so the separation is not accidentally one-way: a
1386    /// `docs-write` grant must not confer cell writes, structural sheet
1387    /// edits, or raw content edits.
1388    #[test]
1389    fn a_docs_write_rule_grants_nothing_else() {
1390        let rules = [rule("target", true, &[DriveOperation::DocsWrite], &[])];
1391        assert_eq!(
1392            resolve(&chain(&["target"]), DriveOperation::DocsWrite, &rules).verdict,
1393            Verdict::Allow
1394        );
1395        for other in [
1396            DriveOperation::Edit,
1397            DriveOperation::SheetsWrite,
1398            DriveOperation::SheetsStructure,
1399            DriveOperation::Create,
1400            DriveOperation::Upload,
1401            DriveOperation::Trash,
1402        ] {
1403            assert_eq!(
1404                resolve(&chain(&["target"]), other, &rules).verdict,
1405                Verdict::Deny,
1406                "an allow:[docs-write] rule must not leak into {other}"
1407            );
1408        }
1409    }
1410
1411    #[test]
1412    fn operations_on_one_rule_are_independent() {
1413        let rules = [rule("target", false, &[DriveOperation::Create], &[])];
1414        let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1415        let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
1416        assert_eq!(create.verdict, Verdict::Allow);
1417        assert_eq!(
1418            upload.verdict,
1419            Verdict::Deny,
1420            "an allow:[create] rule must not leak into upload"
1421        );
1422    }
1423
1424    #[test]
1425    fn deny_list_and_allow_list_on_same_rule_apply_to_different_ops_independently() {
1426        let rules = [rule(
1427            "target",
1428            false,
1429            &[DriveOperation::Create],
1430            &[DriveOperation::Edit],
1431        )];
1432        let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1433        let edit = resolve(&chain(&["target"]), DriveOperation::Edit, &rules);
1434        let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
1435        assert_eq!(create.verdict, Verdict::Allow);
1436        assert_eq!(edit.verdict, Verdict::Deny);
1437        assert_eq!(
1438            upload.verdict,
1439            Verdict::Deny,
1440            "no rule named upload; falls to default policy"
1441        );
1442    }
1443
1444    // ── combine_across_parents ────────────────────────────────────────
1445
1446    fn decision(verdict: Verdict) -> Decision {
1447        Decision {
1448            verdict,
1449            decided_by: None,
1450        }
1451    }
1452
1453    #[test]
1454    fn combine_across_parents_single_decision_returns_it_unchanged() {
1455        let combined = combine_across_parents(decision(Verdict::Allow), []);
1456        assert_eq!(combined.verdict, Verdict::Allow);
1457    }
1458
1459    #[test]
1460    fn combine_across_parents_deny_beats_allow_deny_first() {
1461        let combined = combine_across_parents(decision(Verdict::Deny), [decision(Verdict::Allow)]);
1462        assert_eq!(combined.verdict, Verdict::Deny);
1463    }
1464
1465    #[test]
1466    fn combine_across_parents_deny_beats_allow_allow_first() {
1467        let combined = combine_across_parents(decision(Verdict::Allow), [decision(Verdict::Deny)]);
1468        assert_eq!(combined.verdict, Verdict::Deny);
1469    }
1470
1471    // ── decided_by_log_fields ────────────────────────────────────────
1472
1473    #[test]
1474    fn decided_by_log_fields_none_yields_all_none() {
1475        assert_eq!(decided_by_log_fields(None), DecidedByLogFields::default());
1476    }
1477
1478    #[test]
1479    fn decided_by_log_fields_some_extracts_folder_id_and_depth() {
1480        let rule = DecidingRule::Folder {
1481            folder_id: "folder-1".to_string(),
1482            depth: 2,
1483        };
1484        assert_eq!(
1485            decided_by_log_fields(Some(&rule)),
1486            DecidedByLogFields {
1487                folder_id: Some("folder-1".to_string()),
1488                file_id: None,
1489                depth: Some(2),
1490            }
1491        );
1492    }
1493
1494    #[test]
1495    fn decided_by_log_fields_never_puts_a_file_id_in_the_folder_field() {
1496        // `omni-dev log --query decided_by_folder_id:X` must never start
1497        // matching file ids — that would silently reinterpret an existing
1498        // audit key.
1499        let rule = DecidingRule::File {
1500            file_id: "file-1".to_string(),
1501        };
1502        assert_eq!(
1503            decided_by_log_fields(Some(&rule)),
1504            DecidedByLogFields {
1505                folder_id: None,
1506                file_id: Some("file-1".to_string()),
1507                depth: None,
1508            }
1509        );
1510    }
1511
1512    #[test]
1513    fn decided_rule_requires_lease_defaults_true_with_no_decided_by() {
1514        assert!(decided_rule_requires_lease(None, DriveOperation::Edit, &[]));
1515    }
1516
1517    #[test]
1518    fn decided_rule_requires_lease_true_when_the_folder_rule_says_so() {
1519        let rules = [FolderPermissionRule::folder("f1").allowing([DriveOperation::Edit])];
1520        let decided_by = DecidingRule::Folder {
1521            folder_id: "f1".to_string(),
1522            depth: 0,
1523        };
1524        assert!(decided_rule_requires_lease(
1525            Some(&decided_by),
1526            DriveOperation::Edit,
1527            &rules
1528        ));
1529    }
1530
1531    #[test]
1532    fn decided_rule_requires_lease_false_when_the_folder_rule_opts_out() {
1533        let rules = [FolderPermissionRule::folder("f1")
1534            .allowing([DriveOperation::Edit])
1535            .requiring_lease(false)];
1536        let decided_by = DecidingRule::Folder {
1537            folder_id: "f1".to_string(),
1538            depth: 0,
1539        };
1540        assert!(!decided_rule_requires_lease(
1541            Some(&decided_by),
1542            DriveOperation::Edit,
1543            &rules
1544        ));
1545    }
1546
1547    #[test]
1548    fn decided_rule_requires_lease_true_when_the_file_rule_says_so() {
1549        let rules = [FolderPermissionRule::file("file-1").allowing([DriveOperation::Edit])];
1550        let decided_by = DecidingRule::File {
1551            file_id: "file-1".to_string(),
1552        };
1553        assert!(decided_rule_requires_lease(
1554            Some(&decided_by),
1555            DriveOperation::Edit,
1556            &rules
1557        ));
1558    }
1559
1560    #[test]
1561    fn decided_rule_requires_lease_any_matching_rule_opting_in_wins() {
1562        // Two rules on the same folder, both governing `Edit`: one opting
1563        // out and one not — the safe direction (any rule requiring a
1564        // lease is enough) wins.
1565        let rules = [
1566            FolderPermissionRule::folder("f1")
1567                .allowing([DriveOperation::Edit])
1568                .requiring_lease(false),
1569            FolderPermissionRule::folder("f1").allowing([DriveOperation::Edit]),
1570        ];
1571        let decided_by = DecidingRule::Folder {
1572            folder_id: "f1".to_string(),
1573            depth: 0,
1574        };
1575        assert!(decided_rule_requires_lease(
1576            Some(&decided_by),
1577            DriveOperation::Edit,
1578            &rules
1579        ));
1580    }
1581
1582    #[test]
1583    fn decided_rule_requires_lease_ignores_a_non_recursive_rule_on_an_ancestor() {
1584        // The depth-0 rule opted out; an unrelated non-recursive rule
1585        // naming the same folder id at a *different* depth must not be
1586        // consulted (mirrors `resolve`'s own recursive-match rule).
1587        let rules = [FolderPermissionRule::folder("f1")
1588            .allowing([DriveOperation::Edit])
1589            .requiring_lease(false)];
1590        let decided_by = DecidingRule::Folder {
1591            folder_id: "f1".to_string(),
1592            depth: 1,
1593        };
1594        // depth=1 with a non-recursive rule: the rule does not match at
1595        // this depth, so no matching rule is found and the fail-safe
1596        // default (true) applies.
1597        assert!(decided_rule_requires_lease(
1598            Some(&decided_by),
1599            DriveOperation::Edit,
1600            &rules
1601        ));
1602    }
1603
1604    #[test]
1605    fn decided_rule_requires_lease_ignores_a_rule_for_a_different_operation() {
1606        // Two rules on the same folder/depth, governing different
1607        // operations: the `Edit` rule opts out, and an unrelated
1608        // `SheetsWrite` rule (which does not opt out) must not contribute
1609        // its own `require_lease` to the `Edit` lookup — a rule that
1610        // doesn't govern the operation being checked isn't "matching".
1611        let rules = [
1612            FolderPermissionRule::folder("f1")
1613                .allowing([DriveOperation::Edit])
1614                .requiring_lease(false),
1615            FolderPermissionRule::folder("f1").allowing([DriveOperation::SheetsWrite]),
1616        ];
1617        let decided_by = DecidingRule::Folder {
1618            folder_id: "f1".to_string(),
1619            depth: 0,
1620        };
1621        assert!(!decided_rule_requires_lease(
1622            Some(&decided_by),
1623            DriveOperation::Edit,
1624            &rules
1625        ));
1626    }
1627
1628    #[test]
1629    fn combine_across_parents_all_allow_returns_allow() {
1630        let combined = combine_across_parents(
1631            decision(Verdict::Allow),
1632            [decision(Verdict::Allow), decision(Verdict::Allow)],
1633        );
1634        assert_eq!(combined.verdict, Verdict::Allow);
1635    }
1636
1637    // ── file rules (issue #1612) ──────────────────────────────────────
1638
1639    #[test]
1640    fn a_file_rule_grants_a_target_with_no_chain_at_all() {
1641        // The whole point of the feature: a Sheet shared by link or email
1642        // has no visible parent, so the chain is empty and no folder rule
1643        // could ever apply.
1644        let rules = [file_rule("shared", &[DriveOperation::SheetsWrite], &[])];
1645        let decision = resolve_for_file("shared", &[], DriveOperation::SheetsWrite, &rules);
1646        assert_eq!(decision.verdict, Verdict::Allow);
1647        assert_eq!(
1648            decision.decided_by,
1649            Some(DecidingRule::File {
1650                file_id: "shared".to_string()
1651            })
1652        );
1653    }
1654
1655    #[test]
1656    fn file_deny_beats_a_recursive_folder_allow() {
1657        let rules = [
1658            rule("root", true, &[DriveOperation::Edit], &[]),
1659            file_rule("target", &[], &[DriveOperation::Edit]),
1660        ];
1661        let decision = resolve_for_file(
1662            "target",
1663            &chain(&["parent", "root"]),
1664            DriveOperation::Edit,
1665            &rules,
1666        );
1667        assert_eq!(decision.verdict, Verdict::Deny);
1668        assert_eq!(decision.decided_by.unwrap().kind_label(), "file");
1669    }
1670
1671    #[test]
1672    fn file_allow_beats_a_folder_deny_on_the_immediate_parent() {
1673        // The inverse direction — proves depth −1 is "closest wins", not a
1674        // one-way "a file rule can only restrict" rule.
1675        let rules = [
1676            rule("parent", false, &[], &[DriveOperation::SheetsWrite]),
1677            file_rule("target", &[DriveOperation::SheetsWrite], &[]),
1678        ];
1679        let decision = resolve_for_file(
1680            "target",
1681            &chain(&["parent"]),
1682            DriveOperation::SheetsWrite,
1683            &rules,
1684        );
1685        assert_eq!(decision.verdict, Verdict::Allow);
1686    }
1687
1688    #[test]
1689    fn deny_beats_allow_among_file_rules_for_the_same_id() {
1690        let rules = [
1691            file_rule("target", &[DriveOperation::Edit], &[]),
1692            file_rule("target", &[], &[DriveOperation::Edit]),
1693        ];
1694        let decision = resolve_for_file("target", &[], DriveOperation::Edit, &rules);
1695        assert_eq!(decision.verdict, Verdict::Deny);
1696    }
1697
1698    #[test]
1699    fn resolve_file_rule_returns_none_rather_than_the_default_policy() {
1700        // Must be distinguishable from "decided deny", or the caller would
1701        // skip the ancestor walk that could still have granted it.
1702        let rules = [file_rule("target", &[DriveOperation::Edit], &[])];
1703        assert!(resolve_file_rule("target", DriveOperation::SheetsWrite, &rules).is_none());
1704        assert!(resolve_file_rule("other", DriveOperation::Edit, &rules).is_none());
1705    }
1706
1707    #[test]
1708    fn a_file_rule_is_invisible_to_the_ancestor_walk() {
1709        // A `file_id` rule whose id happens to equal a folder in the chain
1710        // must not match it: the two id spaces are not interchangeable.
1711        let rules = [file_rule("target", &[DriveOperation::Create], &[])];
1712        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1713        assert_eq!(decision.verdict, Verdict::Deny);
1714        assert_eq!(decision.decided_by, None);
1715    }
1716
1717    #[test]
1718    fn a_folder_rule_is_invisible_to_the_file_lookup() {
1719        let rules = [rule("target", true, &[DriveOperation::Create], &[])];
1720        assert!(resolve_file_rule("target", DriveOperation::Create, &rules).is_none());
1721    }
1722
1723    #[test]
1724    fn resolve_for_file_falls_through_to_the_chain_when_no_file_rule_matches() {
1725        let rules = [rule("parent", true, &[DriveOperation::Edit], &[])];
1726        let decision =
1727            resolve_for_file("target", &chain(&["parent"]), DriveOperation::Edit, &rules);
1728        assert_eq!(decision.verdict, Verdict::Allow);
1729        assert_eq!(folder_id_of(&decision), "parent");
1730    }
1731
1732    // ── rule schema validation ────────────────────────────────────────
1733
1734    fn parse_rule(json: &str) -> Result<FolderPermissionRule, serde_json::Error> {
1735        serde_json::from_str(json)
1736    }
1737
1738    #[test]
1739    fn a_folder_rule_round_trips_byte_identically_to_the_pre_1612_shape() {
1740        let parsed =
1741            parse_rule(r#"{"folder_id":"f1","recursive":true,"allow":["create"]}"#).unwrap();
1742        assert_eq!(parsed.folder_id.as_deref(), Some("f1"));
1743        assert_eq!(parsed.file_id, None);
1744        assert!(parsed.recursive);
1745        let json: serde_json::Value = serde_json::to_value(&parsed).unwrap();
1746        assert_eq!(json["folder_id"], "f1");
1747        assert_eq!(json["recursive"], true);
1748        assert!(
1749            json.get("file_id").is_none(),
1750            "an absent file_id must not be serialized"
1751        );
1752    }
1753
1754    #[test]
1755    fn a_pre_lease_rule_with_no_require_lease_key_defaults_to_requiring_one() {
1756        // Pins `default_require_lease`'s return value against a
1757        // pre-existing `settings.json` written before ADR-0080 (issue
1758        // #1664): every operator's already-saved rule has no
1759        // `require_lease` key at all, and the backward-compatibility
1760        // guarantee is that it is still gated by the lease, not silently
1761        // exempted. Nothing else in this test file asserts this — a
1762        // refactor that broke `default_require_lease`'s body (e.g.
1763        // swapping `#[serde(default = "default_require_lease")]` for a
1764        // bare `#[serde(default)]`) would otherwise compile and pass every
1765        // other test while disabling Touch ID/backup enforcement crate-wide
1766        // for every such rule.
1767        let parsed =
1768            parse_rule(r#"{"folder_id":"f1","recursive":true,"allow":["create"]}"#).unwrap();
1769        assert!(parsed.require_lease);
1770    }
1771
1772    #[test]
1773    fn a_file_rule_parses_and_serializes_without_a_folder_id() {
1774        let parsed = parse_rule(r#"{"file_id":"x1","allow":["sheets-write"]}"#).unwrap();
1775        assert_eq!(parsed.file_id.as_deref(), Some("x1"));
1776        assert_eq!(parsed.folder_id, None);
1777        let json: serde_json::Value = serde_json::to_value(&parsed).unwrap();
1778        assert_eq!(json["file_id"], "x1");
1779        assert!(json.get("folder_id").is_none());
1780    }
1781
1782    #[test]
1783    fn a_rule_naming_neither_target_is_a_load_error() {
1784        let err = parse_rule(r#"{"allow":["create"]}"#)
1785            .unwrap_err()
1786            .to_string();
1787        assert!(err.contains("folder_id"), "{err}");
1788        assert!(err.contains("file_id"), "{err}");
1789    }
1790
1791    #[test]
1792    fn a_rule_naming_both_targets_is_a_load_error() {
1793        let err = parse_rule(r#"{"folder_id":"f","file_id":"x"}"#)
1794            .unwrap_err()
1795            .to_string();
1796        assert!(err.contains("not both"), "{err}");
1797    }
1798
1799    #[test]
1800    fn recursive_true_on_a_file_rule_is_a_load_error() {
1801        // Silently ignoring it would let an operator believe they had
1802        // granted a subtree when they had granted one file.
1803        let err = parse_rule(r#"{"file_id":"x","recursive":true}"#)
1804            .unwrap_err()
1805            .to_string();
1806        assert!(err.contains("recursive"), "{err}");
1807    }
1808
1809    #[test]
1810    fn recursive_false_on_a_file_rule_is_accepted() {
1811        // Redundant but not a misunderstanding, so it must not error.
1812        let parsed = parse_rule(r#"{"file_id":"x","recursive":false}"#).unwrap();
1813        assert!(!parsed.recursive);
1814    }
1815
1816    #[test]
1817    fn deciding_rule_accessors_render_both_kinds() {
1818        let folder = DecidingRule::Folder {
1819            folder_id: "f1".to_string(),
1820            depth: 2,
1821        };
1822        assert_eq!(folder.id(), "f1");
1823        assert_eq!(folder.kind_label(), "folder");
1824        assert_eq!(folder.depth_suffix(), " (depth 2)");
1825
1826        let file = DecidingRule::File {
1827            file_id: "x1".to_string(),
1828        };
1829        assert_eq!(file.id(), "x1");
1830        assert_eq!(file.kind_label(), "file");
1831        assert_eq!(file.depth_suffix(), "");
1832    }
1833    #[test]
1834    fn docs_format_is_default_denied_lease_eligible_and_isolated() {
1835        let format: DriveOperation = serde_json::from_str("\"docs-format\"").unwrap();
1836        assert_eq!(format, DriveOperation::DocsFormat);
1837        assert_eq!(format.to_string(), "docs-format");
1838        assert_eq!(format.default_policy(), Verdict::Deny);
1839        assert!(format.ever_requires_lease());
1840        for other in [
1841            DriveOperation::DocsWrite,
1842            DriveOperation::DocsDelete,
1843            DriveOperation::Edit,
1844            DriveOperation::SheetsWrite,
1845            DriveOperation::SlidesWrite,
1846        ] {
1847            assert_eq!(
1848                resolve(
1849                    &chain(&["target"]),
1850                    format,
1851                    &[rule("target", true, &[other], &[])]
1852                )
1853                .verdict,
1854                Verdict::Deny
1855            );
1856            assert_eq!(
1857                resolve(
1858                    &chain(&["target"]),
1859                    other,
1860                    &[rule("target", true, &[format], &[])]
1861                )
1862                .verdict,
1863                Verdict::Deny
1864            );
1865        }
1866        assert_eq!(
1867            resolve(
1868                &chain(&["target"]),
1869                format,
1870                &[rule("target", true, &[format], &[])]
1871            )
1872            .verdict,
1873            Verdict::Allow
1874        );
1875    }
1876}