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    DocsWrite,
387}
388
389impl std::fmt::Display for DriveOperation {
390    /// Matches the `#[serde(rename_all = "kebab-case")]` wire form — used
391    /// by `drive permissions show`/`check`'s rendering.
392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        let s = match self {
394            Self::Read => "read",
395            Self::Create => "create",
396            Self::Upload => "upload",
397            Self::Edit => "edit",
398            Self::SheetsWrite => "sheets-write",
399            Self::SheetsStructure => "sheets-structure",
400            Self::SheetsDelete => "sheets-delete",
401            Self::SheetsProtection => "sheets-protection",
402            Self::DocsWrite => "docs-write",
403        };
404        write!(f, "{s}")
405    }
406}
407
408impl DriveOperation {
409    /// The verdict when no configured rule names this operation anywhere in
410    /// a target's ancestor chain. `Read` stays open by default (unchanged
411    /// from today's behavior); every write defaults closed — the whole
412    /// "disabled by default" requirement lives in this one match, not a
413    /// separate on/off flag.
414    pub(crate) fn default_policy(self) -> Verdict {
415        match self {
416            Self::Read => Verdict::Allow,
417            Self::Create
418            | Self::Upload
419            | Self::Edit
420            | Self::SheetsWrite
421            | Self::SheetsStructure
422            | Self::SheetsDelete
423            | Self::SheetsProtection
424            | Self::DocsWrite => Verdict::Deny,
425        }
426    }
427
428    /// Whether an `Allow` verdict for this operation is ever backed by a
429    /// Drive write lease requirement (issue #1917).
430    ///
431    /// `Read` never mutates anything, and `Create`/`Upload` write into a
432    /// **new** file — there is no existing content for a lease to protect,
433    /// and neither engine (`create.rs`, `upload.rs`) ever gates on
434    /// `require_lease`; the field appears on their `LeasedWrite` values
435    /// only in test fixtures. Every other operation replaces or otherwise
436    /// mutates a target's existing content and does gate on it via
437    /// [`decided_rule_requires_lease`]. This is consulted only by
438    /// `drive permissions check`'s diagnostic, to keep it from reporting a
439    /// lease requirement an operation could never actually be asked to
440    /// satisfy — it does not itself change what any engine enforces.
441    #[must_use]
442    pub const fn ever_requires_lease(self) -> bool {
443        match self {
444            Self::Read | Self::Create | Self::Upload => false,
445            Self::Edit
446            | Self::SheetsWrite
447            | Self::SheetsStructure
448            | Self::SheetsDelete
449            | Self::SheetsProtection
450            | Self::DocsWrite => true,
451        }
452    }
453}
454
455/// One configured permission rule, keyed on **either** a folder id or a
456/// file id — exactly one of the two, enforced when the settings file is
457/// deserialized (see the private `RawPermissionRule` below).
458///
459/// Ids are Drive's own canonical ids, not paths — Drive objects have no
460/// stable, unique path (names collide, files can have multiple legacy
461/// parents), so identity is the id, exactly as the browser bridge's
462/// `OriginAllowlist` matches exact origin strings rather than URL
463/// patterns.
464///
465/// A `file_id` rule is what makes a file shared by link or email
466/// grantable at all (issue #1612): `files.get` returns only the parents
467/// this account can *see*, so such a file arrives with none, and no
468/// folder rule could ever apply to it.
469#[derive(Debug, Clone, Serialize, Deserialize)]
470#[serde(try_from = "RawPermissionRule")]
471pub struct FolderPermissionRule {
472    /// The Drive folder id this rule matches, for a folder rule.
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub folder_id: Option<String>,
475    /// The Drive file id this rule matches, for a file rule. Matched at
476    /// depth −1 against the target itself, before any ancestor walk — see
477    /// [`resolve_file_rule`].
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub file_id: Option<String>,
480    /// When `true`, this rule also matches every descendant of
481    /// `folder_id`, not just the folder itself (depth > 0 in the ancestor
482    /// chain). A non-recursive rule only ever matches at depth 0.
483    /// Meaningless — and rejected — on a `file_id` rule: a file has no
484    /// descendants.
485    pub recursive: bool,
486    /// Operations explicitly permitted at this target.
487    pub allow: HashSet<DriveOperation>,
488    /// Operations explicitly refused at this target.
489    pub deny: HashSet<DriveOperation>,
490    /// Whether a content-mutating write matching this rule requires a
491    /// valid Drive write lease ([ADR-0080](../../docs/adrs/adr-0080.md)
492    /// §1/§13) in addition to this gate's own verdict. Defaults to `true`;
493    /// an operator sets it to `false` to relax the requirement for a
494    /// specific folder, which skips **both** the backup and the Touch ID
495    /// prompt (§13) — a rule that skipped only one of the two would either
496    /// surprise an operator who explicitly relaxed this, or ask for
497    /// consent to a guarantee it then doesn't provide. Independent of
498    /// `allow`/`deny`: the lease is orthogonal to this gate, not another
499    /// operation in its vocabulary.
500    #[serde(default = "default_require_lease")]
501    pub require_lease: bool,
502}
503
504/// The default for [`FolderPermissionRule::require_lease`] — a free
505/// function because `#[serde(default)]` needs a path, not a literal, and
506/// `true` is not `bool`'s own `Default`.
507fn default_require_lease() -> bool {
508    true
509}
510
511impl FolderPermissionRule {
512    /// A folder rule naming `folder_id`.
513    #[must_use]
514    pub fn folder(folder_id: impl Into<String>) -> Self {
515        Self {
516            folder_id: Some(folder_id.into()),
517            file_id: None,
518            recursive: false,
519            allow: HashSet::new(),
520            deny: HashSet::new(),
521            require_lease: default_require_lease(),
522        }
523    }
524
525    /// A file rule naming `file_id`.
526    #[must_use]
527    pub fn file(file_id: impl Into<String>) -> Self {
528        Self {
529            folder_id: None,
530            file_id: Some(file_id.into()),
531            recursive: false,
532            allow: HashSet::new(),
533            deny: HashSet::new(),
534            require_lease: default_require_lease(),
535        }
536    }
537
538    /// Sets `recursive`, for builder-style construction in tests and
539    /// callers assembling rules programmatically.
540    #[must_use]
541    pub fn recursive(mut self, recursive: bool) -> Self {
542        self.recursive = recursive;
543        self
544    }
545
546    /// Sets `require_lease`, for builder-style construction in tests and
547    /// callers assembling rules programmatically.
548    #[must_use]
549    pub fn requiring_lease(mut self, require_lease: bool) -> Self {
550        self.require_lease = require_lease;
551        self
552    }
553
554    /// Sets the `allow` set.
555    #[must_use]
556    pub fn allowing(mut self, ops: impl IntoIterator<Item = DriveOperation>) -> Self {
557        self.allow = ops.into_iter().collect();
558        self
559    }
560
561    /// Sets the `deny` set.
562    #[must_use]
563    pub fn denying(mut self, ops: impl IntoIterator<Item = DriveOperation>) -> Self {
564        self.deny = ops.into_iter().collect();
565        self
566    }
567}
568
569/// The wire shape [`FolderPermissionRule`] deserializes through, so that
570/// "exactly one of `folder_id`/`file_id`" is a **load error** rather than a
571/// rule that silently matches nothing.
572///
573/// This is also the backward-compatibility mechanism. An older binary
574/// reading a config containing a file rule sees `folder_id` missing from a
575/// required field and fails `Settings::load()` outright; `active_account_rules`
576/// then degrades to an empty rule set, which denies every write. That is
577/// blunt — it drops the account's credentials too — but it is the same
578/// fail-closed path [ADR-0073](../../docs/adrs/adr-0073.md) §3 already
579/// relies on for an unrecognised `DriveOperation`, and the alternative
580/// (a second, separately-named rule list) would be *silently ignored* by
581/// an older binary, dropping a file-level `deny` and letting a folder-level
582/// `allow` win.
583#[derive(Debug, Deserialize)]
584struct RawPermissionRule {
585    #[serde(default)]
586    folder_id: Option<String>,
587    #[serde(default)]
588    file_id: Option<String>,
589    #[serde(default)]
590    recursive: bool,
591    #[serde(default)]
592    allow: HashSet<DriveOperation>,
593    #[serde(default)]
594    deny: HashSet<DriveOperation>,
595    #[serde(default = "default_require_lease")]
596    require_lease: bool,
597}
598
599impl TryFrom<RawPermissionRule> for FolderPermissionRule {
600    type Error = String;
601
602    fn try_from(raw: RawPermissionRule) -> Result<Self, Self::Error> {
603        match (&raw.folder_id, &raw.file_id) {
604            (None, None) => {
605                return Err(
606                    "a write-permission rule must set either `folder_id` or `file_id`".to_string(),
607                )
608            }
609            (Some(_), Some(_)) => {
610                return Err(
611                    "a write-permission rule must set `folder_id` or `file_id`, not both \
612                            — a rule keys on one target"
613                        .to_string(),
614                )
615            }
616            _ => {}
617        }
618        if raw.recursive && raw.file_id.is_some() {
619            return Err(
620                "`recursive` is meaningless on a `file_id` rule — a file has no descendants; \
621                 drop it, or use `folder_id` to grant a whole subtree"
622                    .to_string(),
623            );
624        }
625        Ok(Self {
626            folder_id: raw.folder_id,
627            file_id: raw.file_id,
628            recursive: raw.recursive,
629            allow: raw.allow,
630            deny: raw.deny,
631            require_lease: raw.require_lease,
632        })
633    }
634}
635
636/// The result of resolving a single operation against a rule set.
637#[derive(Debug, Clone, Copy, PartialEq, Eq)]
638pub enum Verdict {
639    /// The operation is permitted.
640    Allow,
641    /// The operation is refused.
642    Deny,
643}
644
645/// Which configured rule (if any) decided a [`Decision`].
646///
647/// `None` — the `Option` wrapping this, not a variant here — means no rule
648/// matched anywhere and the bare default policy decided it instead.
649/// Carried into the request log's `decided_by_folder_id`/
650/// `decided_by_file_id`/`decided_by_depth` context fields so a refusal is
651/// exactly as auditable as a success.
652///
653/// An enum rather than one struct with optional fields: a file rule has no
654/// depth (it names the target itself, at depth −1) and a folder rule has no
655/// file id, so "a file rule at depth 3" is a state that should not be
656/// expressible at all.
657#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
658#[serde(tag = "kind", rename_all = "kebab-case")]
659pub enum DecidingRule {
660    /// A `folder_id` rule matched the target's ancestor chain.
661    Folder {
662        /// The folder id of the rule that decided the verdict.
663        folder_id: String,
664        /// How many levels above the target this rule's folder sits (0 =
665        /// the target itself).
666        depth: usize,
667    },
668    /// A `file_id` rule named the target itself.
669    File {
670        /// The file id of the rule that decided the verdict.
671        file_id: String,
672    },
673}
674
675impl DecidingRule {
676    /// The id this rule keys on — a file id or a folder id.
677    ///
678    /// Deliberately *not* rendered here: the id is operator-supplied but
679    /// the strings it sits beside are not, and this module stays free of
680    /// the CLI layer. Every render site must therefore sanitize before the
681    /// line reaches a terminal — the three CLI sites (`drive edit`,
682    /// `upload`, `create`) run the id through `sanitize_for_terminal`
683    /// directly; the two engine-layer `describe` functions (`sheets write`,
684    /// `sheets create`) embed it raw and their CLI callers sanitize the
685    /// whole rendered line instead.
686    #[must_use]
687    pub fn id(&self) -> &str {
688        match self {
689            Self::Folder { folder_id, .. } => folder_id,
690            Self::File { file_id } => file_id,
691        }
692    }
693
694    /// `"folder"` or `"file"` — the noun the operator-facing messages use.
695    #[must_use]
696    pub const fn kind_label(&self) -> &'static str {
697        match self {
698            Self::Folder { .. } => "folder",
699            Self::File { .. } => "file",
700        }
701    }
702
703    /// `" (depth 2)"` for a folder rule, `""` for a file rule.
704    ///
705    /// Exists so the five `describe`/`print_report` sites are one identical
706    /// line — `"refused by rule on {kind} {id}{suffix}"` — and cannot drift
707    /// into disagreeing about how a file rule is worded.
708    #[must_use]
709    pub fn depth_suffix(&self) -> String {
710        match self {
711            Self::Folder { depth, .. } => format!(" (depth {depth})"),
712            Self::File { .. } => String::new(),
713        }
714    }
715}
716
717/// The outcome of [`resolve`]: whether an operation is permitted, and which
718/// rule (if any) decided it.
719#[derive(Debug, Clone, PartialEq, Eq)]
720pub struct Decision {
721    /// Whether the operation is permitted.
722    pub verdict: Verdict,
723    /// The configured rule that decided this, or `None` when the bare
724    /// default policy decided it instead.
725    pub decided_by: Option<DecidingRule>,
726}
727
728/// The verdict from a rule naming `file_id` itself, or `None` when no
729/// configured rule names it.
730///
731/// A file rule sits at **depth −1**: strictly more specific than any
732/// folder rule, including one on the file's own immediate parent. That
733/// is what lets a file shared by link or email — whose parents this
734/// account cannot see at all — be granted, and it is also why a decisive
735/// file rule short-circuits the ancestor walk entirely
736/// (`crate::drive::folder_ancestry::resolve_decision_for_file_target`), sparing
737/// every `files.get` the walk would have cost.
738///
739/// Deny still beats allow among file rules naming the same id, the same
740/// fail-closed direction every other tie-break in this module takes.
741#[must_use]
742pub fn resolve_file_rule(
743    file_id: &str,
744    op: DriveOperation,
745    rules: &[FolderPermissionRule],
746) -> Option<Decision> {
747    let mut deny_here = false;
748    let mut allow_here = false;
749    for rule in rules {
750        if rule.file_id.as_deref() != Some(file_id) {
751            continue;
752        }
753        deny_here |= rule.deny.contains(&op);
754        allow_here |= rule.allow.contains(&op);
755    }
756    if !deny_here && !allow_here {
757        return None;
758    }
759    Some(Decision {
760        verdict: if deny_here {
761            Verdict::Deny
762        } else {
763            Verdict::Allow
764        },
765        decided_by: Some(DecidingRule::File {
766            file_id: file_id.to_string(),
767        }),
768    })
769}
770
771/// Resolves `op` for a **file** target: a rule naming `file_id` if one
772/// exists, else [`resolve`] against the file's ancestor `chain`.
773///
774/// The pure counterpart of
775/// `crate::drive::folder_ancestry::resolve_decision_for_file_target`. Callers
776/// whose target is a *folder* (`create`/`upload`'s `--parent`) call
777/// [`resolve`] directly instead — there is no file id to name, so a file
778/// rule can never participate.
779#[must_use]
780pub fn resolve_for_file(
781    file_id: &str,
782    chain: &[String],
783    op: DriveOperation,
784    rules: &[FolderPermissionRule],
785) -> Decision {
786    resolve_file_rule(file_id, op, rules).unwrap_or_else(|| resolve(chain, op, rules))
787}
788
789/// Resolves whether `op` is permitted against `chain` (depth 0 = the
790/// target folder itself, then parent, grandparent, ...) under `rules`. See
791/// the module doc for the full algorithm and its tie-breaks.
792///
793/// Only `folder_id` rules participate: a `file_id` rule names a target,
794/// not an ancestor, so it can never match a chain entry. See
795/// [`resolve_for_file`] for a file target.
796#[must_use]
797pub fn resolve(chain: &[String], op: DriveOperation, rules: &[FolderPermissionRule]) -> Decision {
798    let mut best: Option<(usize, Verdict)> = None;
799    for (depth, folder_id) in chain.iter().enumerate() {
800        let mut deny_here = false;
801        let mut allow_here = false;
802        for rule in rules {
803            if rule.folder_id.as_deref() != Some(folder_id.as_str()) {
804                continue;
805            }
806            if depth > 0 && !rule.recursive {
807                continue;
808            }
809            deny_here |= rule.deny.contains(&op);
810            allow_here |= rule.allow.contains(&op);
811        }
812        if !deny_here && !allow_here {
813            continue;
814        }
815        let verdict = if deny_here {
816            Verdict::Deny
817        } else {
818            Verdict::Allow
819        };
820        let is_closer = match best {
821            Some((best_depth, _)) => depth < best_depth,
822            None => true,
823        };
824        if is_closer {
825            best = Some((depth, verdict));
826        }
827    }
828    match best {
829        Some((depth, verdict)) => Decision {
830            verdict,
831            decided_by: Some(DecidingRule::Folder {
832                folder_id: chain[depth].clone(),
833                depth,
834            }),
835        },
836        None => Decision {
837            verdict: op.default_policy(),
838            decided_by: None,
839        },
840    }
841}
842
843/// Combines per-parent [`Decision`]s into one, for a target with more than
844/// one current parent (a legacy multi-parent file — Drive no longer
845/// permits creating new ones).
846///
847/// Deny wins across parents, the same fail-closed direction every other
848/// tie-break in this module takes. Callers with a single parent (the
849/// common case) can call [`resolve`] directly instead; an *orphan* target
850/// (zero parents) should call `resolve(&[], op, rules)` directly too,
851/// rather than calling this with zero decisions — a target's chain
852/// degenerates to "only the default policy applies," not to a policy
853/// about *no* chain, so `first` requires at least one decision by
854/// construction.
855///
856/// Shared by `drive edit` (whose target's chain starts at its *current*
857/// parents, unioned) and `drive permissions check` (whose target may
858/// itself be a file).
859#[must_use]
860pub fn combine_across_parents(
861    first: Decision,
862    rest: impl IntoIterator<Item = Decision>,
863) -> Decision {
864    rest.into_iter().fold(first, |acc, next| {
865        if acc.verdict == Verdict::Deny {
866            acc
867        } else {
868            next
869        }
870    })
871}
872
873/// The `(folder_id, file_id, depth)` triple
874/// `crate::request_log::DriveMutationOutcome`'s `decided_by_folder_id`/
875/// `decided_by_file_id`/`decided_by_depth` fields expect.
876///
877/// A named struct rather than a tuple: three same-shaped `Option`s in a
878/// row is exactly the signature a caller silently mis-orders.
879#[derive(Debug, Clone, Default, PartialEq, Eq)]
880pub struct DecidedByLogFields {
881    /// The deciding folder rule's id, when a folder rule decided it.
882    pub folder_id: Option<String>,
883    /// The deciding file rule's id, when a file rule decided it.
884    pub file_id: Option<String>,
885    /// The deciding folder rule's depth. Always `None` for a file rule,
886    /// which matches the target itself rather than an ancestor.
887    pub depth: Option<usize>,
888}
889
890/// Splits an optional [`DecidingRule`] into the log's flat fields.
891///
892/// Shared by `create`/`upload`/`edit`/`sheets write`'s otherwise
893/// near-identical `record_attempt` functions — each still builds its own
894/// `DriveMutationOutcome` (the verb-specific fields genuinely differ), but
895/// this was the one piece of extraction logic that was byte-for-byte the
896/// same in all of them. Kept here (rather than in `crate::request_log`,
897/// which stays decoupled from any one integration's internal types) and
898/// pure, matching this module's zero-I/O contract — it does not itself
899/// call `crate::request_log::record_drive_mutation`.
900#[must_use]
901pub fn decided_by_log_fields(decided_by: Option<&DecidingRule>) -> DecidedByLogFields {
902    match decided_by {
903        Some(DecidingRule::Folder { folder_id, depth }) => DecidedByLogFields {
904            folder_id: Some(folder_id.clone()),
905            file_id: None,
906            depth: Some(*depth),
907        },
908        Some(DecidingRule::File { file_id }) => DecidedByLogFields {
909            folder_id: None,
910            file_id: Some(file_id.clone()),
911            depth: None,
912        },
913        None => DecidedByLogFields::default(),
914    }
915}
916
917/// Whether the rule(s) that decided `decided_by` require a Drive write
918/// lease ([ADR-0080](../../docs/adrs/adr-0080.md) §1/§13), in addition to
919/// this gate's own verdict.
920///
921/// Deliberately a separate lookup rather than a field threaded through
922/// [`resolve`]/[`resolve_file_rule`]/[`combine_across_parents`]: those are
923/// well-exercised and security-critical, and `require_lease` is
924/// orthogonal to the verdict they compute — a rule's lease requirement
925/// never changes *whether* an operation is allowed, only whether a caller
926/// must also present a valid lease before acting on an `Allow`. Re-deriving
927/// it from `decided_by` against the same rule set keeps that code
928/// untouched.
929///
930/// `decided_by: None` (the bare default policy decided it) is treated as
931/// requiring a lease — moot in practice, since every write operation
932/// defaults to `Deny` (`DriveOperation::default_policy`), so a `None`
933/// `decided_by` never accompanies an `Allow` verdict a caller would act on.
934/// When more than one configured rule matches `decided_by` itself (two
935/// rules naming the same folder and depth, or the same file id), **any**
936/// of them requiring a lease is enough — the same safe-direction tie-break
937/// every other ambiguity in this gate resolves with.
938///
939/// This function only ever sees the **one** `decided_by` its caller passes
940/// in — it cannot by itself account for a legacy multi-parent target,
941/// where [`combine_across_parents`] keeps only the winning parent's
942/// `decided_by` and discards the rest. A caller with more than one parent
943/// decision in hand (`crate::drive::folder_ancestry::resolve_decision_for_parents`)
944/// must call this once per parent and OR the results together *before*
945/// folding the decisions, or a losing parent's own lease requirement would
946/// be silently dropped along with its `decided_by`.
947///
948/// `op` narrows `matching` to rules that actually govern this operation
949/// (`rule.allow`/`rule.deny` contains it) — without it, an unrelated rule
950/// on the same folder/depth (or file id) governing a *different*
951/// operation would still contribute its own `require_lease`, silently
952/// overriding a same-scope rule that explicitly opted this operation out.
953#[must_use]
954pub fn decided_rule_requires_lease(
955    decided_by: Option<&DecidingRule>,
956    op: DriveOperation,
957    rules: &[FolderPermissionRule],
958) -> bool {
959    let governs_op =
960        |rule: &&FolderPermissionRule| rule.allow.contains(&op) || rule.deny.contains(&op);
961    let matching: Vec<&FolderPermissionRule> = match decided_by {
962        None => return true,
963        Some(DecidingRule::Folder { folder_id, depth }) => rules
964            .iter()
965            .filter(|rule| {
966                rule.folder_id.as_deref() == Some(folder_id.as_str())
967                    && (*depth == 0 || rule.recursive)
968            })
969            .filter(governs_op)
970            .collect(),
971        Some(DecidingRule::File { file_id }) => rules
972            .iter()
973            .filter(|rule| rule.file_id.as_deref() == Some(file_id.as_str()))
974            .filter(governs_op)
975            .collect(),
976    };
977    if matching.is_empty() {
978        return true;
979    }
980    matching.iter().any(|rule| rule.require_lease)
981}
982
983#[cfg(test)]
984#[allow(clippy::unwrap_used, clippy::expect_used)]
985mod tests {
986    use super::*;
987
988    fn rule(
989        folder_id: &str,
990        recursive: bool,
991        allow: &[DriveOperation],
992        deny: &[DriveOperation],
993    ) -> FolderPermissionRule {
994        FolderPermissionRule::folder(folder_id)
995            .recursive(recursive)
996            .allowing(allow.iter().copied())
997            .denying(deny.iter().copied())
998    }
999
1000    fn file_rule(
1001        file_id: &str,
1002        allow: &[DriveOperation],
1003        deny: &[DriveOperation],
1004    ) -> FolderPermissionRule {
1005        FolderPermissionRule::file(file_id)
1006            .allowing(allow.iter().copied())
1007            .denying(deny.iter().copied())
1008    }
1009
1010    fn folder_id_of(decision: &Decision) -> &str {
1011        match decision.decided_by.as_ref().unwrap() {
1012            DecidingRule::Folder { folder_id, .. } => folder_id,
1013            DecidingRule::File { .. } => panic!("expected a folder rule, got a file rule"),
1014        }
1015    }
1016
1017    fn chain(ids: &[&str]) -> Vec<String> {
1018        ids.iter().copied().map(ToString::to_string).collect()
1019    }
1020
1021    #[test]
1022    fn default_policy_allows_read_with_no_rules() {
1023        let decision = resolve(&chain(&["a"]), DriveOperation::Read, &[]);
1024        assert_eq!(decision.verdict, Verdict::Allow);
1025        assert_eq!(decision.decided_by, None);
1026    }
1027
1028    #[test]
1029    fn default_policy_denies_create_upload_edit_with_no_rules() {
1030        for op in [
1031            DriveOperation::Create,
1032            DriveOperation::Upload,
1033            DriveOperation::Edit,
1034        ] {
1035            let decision = resolve(&chain(&["a"]), op, &[]);
1036            assert_eq!(
1037                decision.verdict,
1038                Verdict::Deny,
1039                "{op:?} should default-deny"
1040            );
1041            assert_eq!(decision.decided_by, None);
1042        }
1043    }
1044
1045    #[test]
1046    fn ever_requires_lease_is_false_for_read_create_and_upload() {
1047        for op in [
1048            DriveOperation::Read,
1049            DriveOperation::Create,
1050            DriveOperation::Upload,
1051        ] {
1052            assert!(
1053                !op.ever_requires_lease(),
1054                "{op:?} should never take a lease"
1055            );
1056        }
1057    }
1058
1059    #[test]
1060    fn ever_requires_lease_is_true_for_every_content_mutating_operation() {
1061        for op in [
1062            DriveOperation::Edit,
1063            DriveOperation::SheetsWrite,
1064            DriveOperation::SheetsStructure,
1065            DriveOperation::SheetsDelete,
1066            DriveOperation::SheetsProtection,
1067            DriveOperation::DocsWrite,
1068        ] {
1069            assert!(op.ever_requires_lease(), "{op:?} should be lease-eligible");
1070        }
1071    }
1072
1073    #[test]
1074    fn recursive_rule_matches_deep_descendant() {
1075        let rules = [rule("root", true, &[DriveOperation::Create], &[])];
1076        let decision = resolve(
1077            &chain(&["child", "grandchild", "root"]),
1078            DriveOperation::Create,
1079            &rules,
1080        );
1081        assert_eq!(decision.verdict, Verdict::Allow);
1082        assert_eq!(folder_id_of(&decision), "root");
1083    }
1084
1085    #[test]
1086    fn non_recursive_rule_matches_own_folder_only() {
1087        let rules = [rule("target", false, &[DriveOperation::Create], &[])];
1088        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1089        assert_eq!(decision.verdict, Verdict::Allow);
1090    }
1091
1092    #[test]
1093    fn non_recursive_rule_does_not_match_child() {
1094        let rules = [rule("parent", false, &[DriveOperation::Create], &[])];
1095        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
1096        // Falls through to the default policy since the non-recursive rule
1097        // never matches at depth > 0.
1098        assert_eq!(decision.verdict, Verdict::Deny);
1099        assert_eq!(decision.decided_by, None);
1100    }
1101
1102    #[test]
1103    fn closest_ancestor_wins_deny_over_broader_allow() {
1104        let rules = [
1105            rule("child", true, &[], &[DriveOperation::Create]),
1106            rule("parent", true, &[DriveOperation::Create], &[]),
1107        ];
1108        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
1109        assert_eq!(decision.verdict, Verdict::Deny);
1110        assert_eq!(folder_id_of(&decision), "child");
1111    }
1112
1113    #[test]
1114    fn closest_ancestor_wins_allow_over_broader_deny() {
1115        // The inverse case — proves "closest wins" isn't secretly "deny
1116        // always wins": a closer *allow* beats a farther *deny*.
1117        let rules = [
1118            rule("child", true, &[DriveOperation::Create], &[]),
1119            rule("parent", true, &[], &[DriveOperation::Create]),
1120        ];
1121        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
1122        assert_eq!(decision.verdict, Verdict::Allow);
1123        assert_eq!(folder_id_of(&decision), "child");
1124    }
1125
1126    #[test]
1127    fn deny_beats_allow_at_equal_depth() {
1128        let rules = [
1129            rule("target", false, &[DriveOperation::Create], &[]),
1130            rule("target", false, &[], &[DriveOperation::Create]),
1131        ];
1132        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1133        assert_eq!(decision.verdict, Verdict::Deny);
1134    }
1135
1136    #[test]
1137    fn rule_on_unrelated_folder_does_not_apply() {
1138        let rules = [rule("unrelated", true, &[DriveOperation::Create], &[])];
1139        let decision = resolve(
1140            &chain(&["target", "parent"]),
1141            DriveOperation::Create,
1142            &rules,
1143        );
1144        assert_eq!(decision.verdict, Verdict::Deny);
1145        assert_eq!(decision.decided_by, None);
1146    }
1147
1148    #[test]
1149    fn empty_chain_orphan_file_uses_default_policy_only() {
1150        let rules = [rule("some-folder", true, &[DriveOperation::Create], &[])];
1151        let decision = resolve(&[], DriveOperation::Create, &rules);
1152        assert_eq!(decision.verdict, Verdict::Deny);
1153        assert_eq!(decision.decided_by, None);
1154    }
1155
1156    #[test]
1157    fn display_matches_the_serde_kebab_case_wire_form() {
1158        // Display is hand-written, so it can drift from the derive. Every
1159        // variant is asserted against a round-trip through serde rather
1160        // than a second hand-written literal, which would drift with it.
1161        for op in [
1162            DriveOperation::Read,
1163            DriveOperation::Create,
1164            DriveOperation::Upload,
1165            DriveOperation::Edit,
1166            DriveOperation::SheetsWrite,
1167            DriveOperation::SheetsStructure,
1168            DriveOperation::SheetsDelete,
1169            DriveOperation::SheetsProtection,
1170            DriveOperation::DocsWrite,
1171        ] {
1172            let wire = serde_json::to_string(&op).unwrap();
1173            assert_eq!(
1174                format!("\"{op}\""),
1175                wire,
1176                "Display drifted from serde for {op:?}"
1177            );
1178        }
1179        // The pre-existing spellings are pinned literally: switching
1180        // `rename_all` to kebab-case must not have moved any of them.
1181        assert_eq!(DriveOperation::Read.to_string(), "read");
1182        assert_eq!(DriveOperation::Create.to_string(), "create");
1183        assert_eq!(DriveOperation::Upload.to_string(), "upload");
1184        assert_eq!(DriveOperation::Edit.to_string(), "edit");
1185        assert_eq!(DriveOperation::SheetsWrite.to_string(), "sheets-write");
1186        assert_eq!(
1187            DriveOperation::SheetsStructure.to_string(),
1188            "sheets-structure"
1189        );
1190        assert_eq!(DriveOperation::SheetsDelete.to_string(), "sheets-delete");
1191        assert_eq!(
1192            DriveOperation::SheetsProtection.to_string(),
1193            "sheets-protection"
1194        );
1195        assert_eq!(DriveOperation::DocsWrite.to_string(), "docs-write");
1196    }
1197
1198    #[test]
1199    fn sheets_write_defaults_to_deny_like_every_other_write() {
1200        let decision = resolve(&chain(&["f"]), DriveOperation::SheetsWrite, &[]);
1201        assert_eq!(decision.verdict, Verdict::Deny);
1202        assert!(decision.decided_by.is_none());
1203    }
1204
1205    #[test]
1206    fn an_edit_rule_does_not_grant_sheets_write() {
1207        // The whole reason for a separate variant: an existing
1208        // `allow: ["edit"]` rule must not silently gain cell-write power.
1209        let rules = [rule("target", true, &[DriveOperation::Edit], &[])];
1210        let edit = resolve(&chain(&["target"]), DriveOperation::Edit, &rules);
1211        let sheets = resolve(&chain(&["target"]), DriveOperation::SheetsWrite, &rules);
1212        assert_eq!(edit.verdict, Verdict::Allow);
1213        assert_eq!(sheets.verdict, Verdict::Deny);
1214    }
1215
1216    #[test]
1217    fn sheets_structure_defaults_to_deny_like_every_other_write() {
1218        let decision = resolve(&chain(&["f"]), DriveOperation::SheetsStructure, &[]);
1219        assert_eq!(decision.verdict, Verdict::Deny);
1220        assert!(decision.decided_by.is_none());
1221    }
1222
1223    #[test]
1224    fn docs_write_defaults_to_deny_like_every_other_write() {
1225        let decision = resolve(&chain(&["f"]), DriveOperation::DocsWrite, &[]);
1226        assert_eq!(decision.verdict, Verdict::Deny);
1227        assert!(decision.decided_by.is_none());
1228    }
1229
1230    #[test]
1231    fn a_sheets_write_rule_does_not_grant_sheets_structure() {
1232        // The whole reason for a second Sheets variant (issue #1613): an
1233        // existing `allow: ["sheets-write"]` rule was written when structural
1234        // edits were impossible, so it must not silently gain the power to
1235        // add, rename or reshape sheets.
1236        let rules = [rule("target", true, &[DriveOperation::SheetsWrite], &[])];
1237        let write = resolve(&chain(&["target"]), DriveOperation::SheetsWrite, &rules);
1238        let structure = resolve(&chain(&["target"]), DriveOperation::SheetsStructure, &rules);
1239        assert_eq!(write.verdict, Verdict::Allow);
1240        assert_eq!(structure.verdict, Verdict::Deny);
1241    }
1242
1243    #[test]
1244    fn an_edit_rule_does_not_grant_sheets_structure() {
1245        let rules = [rule("target", true, &[DriveOperation::Edit], &[])];
1246        let structure = resolve(&chain(&["target"]), DriveOperation::SheetsStructure, &rules);
1247        assert_eq!(structure.verdict, Verdict::Deny);
1248    }
1249
1250    /// ADR-0076 §2 runs ADR-0073 §3's argument in **three** directions, so
1251    /// all are pinned: an `edit` grant predates Docs being reachable at
1252    /// all, and both Sheets grants are lexically about cells.
1253    #[test]
1254    fn neither_an_edit_nor_a_sheets_write_nor_a_sheets_structure_rule_grants_docs_write() {
1255        for granted in [
1256            DriveOperation::Edit,
1257            DriveOperation::SheetsWrite,
1258            DriveOperation::SheetsStructure,
1259        ] {
1260            let rules = [rule("target", true, &[granted], &[])];
1261            let held = resolve(&chain(&["target"]), granted, &rules);
1262            let docs = resolve(&chain(&["target"]), DriveOperation::DocsWrite, &rules);
1263            assert_eq!(held.verdict, Verdict::Allow, "{granted:?}");
1264            assert_eq!(
1265                docs.verdict,
1266                Verdict::Deny,
1267                "an allow:[{granted}] rule must not leak into docs-write"
1268            );
1269        }
1270    }
1271
1272    /// The converse, so the separation is not accidentally one-way: a
1273    /// `docs-write` grant must not confer cell writes, structural sheet
1274    /// edits, or raw content edits.
1275    #[test]
1276    fn a_docs_write_rule_grants_nothing_else() {
1277        let rules = [rule("target", true, &[DriveOperation::DocsWrite], &[])];
1278        assert_eq!(
1279            resolve(&chain(&["target"]), DriveOperation::DocsWrite, &rules).verdict,
1280            Verdict::Allow
1281        );
1282        for other in [
1283            DriveOperation::Edit,
1284            DriveOperation::SheetsWrite,
1285            DriveOperation::SheetsStructure,
1286            DriveOperation::Create,
1287            DriveOperation::Upload,
1288        ] {
1289            assert_eq!(
1290                resolve(&chain(&["target"]), other, &rules).verdict,
1291                Verdict::Deny,
1292                "an allow:[docs-write] rule must not leak into {other}"
1293            );
1294        }
1295    }
1296
1297    #[test]
1298    fn operations_on_one_rule_are_independent() {
1299        let rules = [rule("target", false, &[DriveOperation::Create], &[])];
1300        let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1301        let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
1302        assert_eq!(create.verdict, Verdict::Allow);
1303        assert_eq!(
1304            upload.verdict,
1305            Verdict::Deny,
1306            "an allow:[create] rule must not leak into upload"
1307        );
1308    }
1309
1310    #[test]
1311    fn deny_list_and_allow_list_on_same_rule_apply_to_different_ops_independently() {
1312        let rules = [rule(
1313            "target",
1314            false,
1315            &[DriveOperation::Create],
1316            &[DriveOperation::Edit],
1317        )];
1318        let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1319        let edit = resolve(&chain(&["target"]), DriveOperation::Edit, &rules);
1320        let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
1321        assert_eq!(create.verdict, Verdict::Allow);
1322        assert_eq!(edit.verdict, Verdict::Deny);
1323        assert_eq!(
1324            upload.verdict,
1325            Verdict::Deny,
1326            "no rule named upload; falls to default policy"
1327        );
1328    }
1329
1330    // ── combine_across_parents ────────────────────────────────────────
1331
1332    fn decision(verdict: Verdict) -> Decision {
1333        Decision {
1334            verdict,
1335            decided_by: None,
1336        }
1337    }
1338
1339    #[test]
1340    fn combine_across_parents_single_decision_returns_it_unchanged() {
1341        let combined = combine_across_parents(decision(Verdict::Allow), []);
1342        assert_eq!(combined.verdict, Verdict::Allow);
1343    }
1344
1345    #[test]
1346    fn combine_across_parents_deny_beats_allow_deny_first() {
1347        let combined = combine_across_parents(decision(Verdict::Deny), [decision(Verdict::Allow)]);
1348        assert_eq!(combined.verdict, Verdict::Deny);
1349    }
1350
1351    #[test]
1352    fn combine_across_parents_deny_beats_allow_allow_first() {
1353        let combined = combine_across_parents(decision(Verdict::Allow), [decision(Verdict::Deny)]);
1354        assert_eq!(combined.verdict, Verdict::Deny);
1355    }
1356
1357    // ── decided_by_log_fields ────────────────────────────────────────
1358
1359    #[test]
1360    fn decided_by_log_fields_none_yields_all_none() {
1361        assert_eq!(decided_by_log_fields(None), DecidedByLogFields::default());
1362    }
1363
1364    #[test]
1365    fn decided_by_log_fields_some_extracts_folder_id_and_depth() {
1366        let rule = DecidingRule::Folder {
1367            folder_id: "folder-1".to_string(),
1368            depth: 2,
1369        };
1370        assert_eq!(
1371            decided_by_log_fields(Some(&rule)),
1372            DecidedByLogFields {
1373                folder_id: Some("folder-1".to_string()),
1374                file_id: None,
1375                depth: Some(2),
1376            }
1377        );
1378    }
1379
1380    #[test]
1381    fn decided_by_log_fields_never_puts_a_file_id_in_the_folder_field() {
1382        // `omni-dev log --query decided_by_folder_id:X` must never start
1383        // matching file ids — that would silently reinterpret an existing
1384        // audit key.
1385        let rule = DecidingRule::File {
1386            file_id: "file-1".to_string(),
1387        };
1388        assert_eq!(
1389            decided_by_log_fields(Some(&rule)),
1390            DecidedByLogFields {
1391                folder_id: None,
1392                file_id: Some("file-1".to_string()),
1393                depth: None,
1394            }
1395        );
1396    }
1397
1398    #[test]
1399    fn decided_rule_requires_lease_defaults_true_with_no_decided_by() {
1400        assert!(decided_rule_requires_lease(None, DriveOperation::Edit, &[]));
1401    }
1402
1403    #[test]
1404    fn decided_rule_requires_lease_true_when_the_folder_rule_says_so() {
1405        let rules = [FolderPermissionRule::folder("f1").allowing([DriveOperation::Edit])];
1406        let decided_by = DecidingRule::Folder {
1407            folder_id: "f1".to_string(),
1408            depth: 0,
1409        };
1410        assert!(decided_rule_requires_lease(
1411            Some(&decided_by),
1412            DriveOperation::Edit,
1413            &rules
1414        ));
1415    }
1416
1417    #[test]
1418    fn decided_rule_requires_lease_false_when_the_folder_rule_opts_out() {
1419        let rules = [FolderPermissionRule::folder("f1")
1420            .allowing([DriveOperation::Edit])
1421            .requiring_lease(false)];
1422        let decided_by = DecidingRule::Folder {
1423            folder_id: "f1".to_string(),
1424            depth: 0,
1425        };
1426        assert!(!decided_rule_requires_lease(
1427            Some(&decided_by),
1428            DriveOperation::Edit,
1429            &rules
1430        ));
1431    }
1432
1433    #[test]
1434    fn decided_rule_requires_lease_true_when_the_file_rule_says_so() {
1435        let rules = [FolderPermissionRule::file("file-1").allowing([DriveOperation::Edit])];
1436        let decided_by = DecidingRule::File {
1437            file_id: "file-1".to_string(),
1438        };
1439        assert!(decided_rule_requires_lease(
1440            Some(&decided_by),
1441            DriveOperation::Edit,
1442            &rules
1443        ));
1444    }
1445
1446    #[test]
1447    fn decided_rule_requires_lease_any_matching_rule_opting_in_wins() {
1448        // Two rules on the same folder, both governing `Edit`: one opting
1449        // out and one not — the safe direction (any rule requiring a
1450        // lease is enough) wins.
1451        let rules = [
1452            FolderPermissionRule::folder("f1")
1453                .allowing([DriveOperation::Edit])
1454                .requiring_lease(false),
1455            FolderPermissionRule::folder("f1").allowing([DriveOperation::Edit]),
1456        ];
1457        let decided_by = DecidingRule::Folder {
1458            folder_id: "f1".to_string(),
1459            depth: 0,
1460        };
1461        assert!(decided_rule_requires_lease(
1462            Some(&decided_by),
1463            DriveOperation::Edit,
1464            &rules
1465        ));
1466    }
1467
1468    #[test]
1469    fn decided_rule_requires_lease_ignores_a_non_recursive_rule_on_an_ancestor() {
1470        // The depth-0 rule opted out; an unrelated non-recursive rule
1471        // naming the same folder id at a *different* depth must not be
1472        // consulted (mirrors `resolve`'s own recursive-match rule).
1473        let rules = [FolderPermissionRule::folder("f1")
1474            .allowing([DriveOperation::Edit])
1475            .requiring_lease(false)];
1476        let decided_by = DecidingRule::Folder {
1477            folder_id: "f1".to_string(),
1478            depth: 1,
1479        };
1480        // depth=1 with a non-recursive rule: the rule does not match at
1481        // this depth, so no matching rule is found and the fail-safe
1482        // default (true) applies.
1483        assert!(decided_rule_requires_lease(
1484            Some(&decided_by),
1485            DriveOperation::Edit,
1486            &rules
1487        ));
1488    }
1489
1490    #[test]
1491    fn decided_rule_requires_lease_ignores_a_rule_for_a_different_operation() {
1492        // Two rules on the same folder/depth, governing different
1493        // operations: the `Edit` rule opts out, and an unrelated
1494        // `SheetsWrite` rule (which does not opt out) must not contribute
1495        // its own `require_lease` to the `Edit` lookup — a rule that
1496        // doesn't govern the operation being checked isn't "matching".
1497        let rules = [
1498            FolderPermissionRule::folder("f1")
1499                .allowing([DriveOperation::Edit])
1500                .requiring_lease(false),
1501            FolderPermissionRule::folder("f1").allowing([DriveOperation::SheetsWrite]),
1502        ];
1503        let decided_by = DecidingRule::Folder {
1504            folder_id: "f1".to_string(),
1505            depth: 0,
1506        };
1507        assert!(!decided_rule_requires_lease(
1508            Some(&decided_by),
1509            DriveOperation::Edit,
1510            &rules
1511        ));
1512    }
1513
1514    #[test]
1515    fn combine_across_parents_all_allow_returns_allow() {
1516        let combined = combine_across_parents(
1517            decision(Verdict::Allow),
1518            [decision(Verdict::Allow), decision(Verdict::Allow)],
1519        );
1520        assert_eq!(combined.verdict, Verdict::Allow);
1521    }
1522
1523    // ── file rules (issue #1612) ──────────────────────────────────────
1524
1525    #[test]
1526    fn a_file_rule_grants_a_target_with_no_chain_at_all() {
1527        // The whole point of the feature: a Sheet shared by link or email
1528        // has no visible parent, so the chain is empty and no folder rule
1529        // could ever apply.
1530        let rules = [file_rule("shared", &[DriveOperation::SheetsWrite], &[])];
1531        let decision = resolve_for_file("shared", &[], DriveOperation::SheetsWrite, &rules);
1532        assert_eq!(decision.verdict, Verdict::Allow);
1533        assert_eq!(
1534            decision.decided_by,
1535            Some(DecidingRule::File {
1536                file_id: "shared".to_string()
1537            })
1538        );
1539    }
1540
1541    #[test]
1542    fn file_deny_beats_a_recursive_folder_allow() {
1543        let rules = [
1544            rule("root", true, &[DriveOperation::Edit], &[]),
1545            file_rule("target", &[], &[DriveOperation::Edit]),
1546        ];
1547        let decision = resolve_for_file(
1548            "target",
1549            &chain(&["parent", "root"]),
1550            DriveOperation::Edit,
1551            &rules,
1552        );
1553        assert_eq!(decision.verdict, Verdict::Deny);
1554        assert_eq!(decision.decided_by.unwrap().kind_label(), "file");
1555    }
1556
1557    #[test]
1558    fn file_allow_beats_a_folder_deny_on_the_immediate_parent() {
1559        // The inverse direction — proves depth −1 is "closest wins", not a
1560        // one-way "a file rule can only restrict" rule.
1561        let rules = [
1562            rule("parent", false, &[], &[DriveOperation::SheetsWrite]),
1563            file_rule("target", &[DriveOperation::SheetsWrite], &[]),
1564        ];
1565        let decision = resolve_for_file(
1566            "target",
1567            &chain(&["parent"]),
1568            DriveOperation::SheetsWrite,
1569            &rules,
1570        );
1571        assert_eq!(decision.verdict, Verdict::Allow);
1572    }
1573
1574    #[test]
1575    fn deny_beats_allow_among_file_rules_for_the_same_id() {
1576        let rules = [
1577            file_rule("target", &[DriveOperation::Edit], &[]),
1578            file_rule("target", &[], &[DriveOperation::Edit]),
1579        ];
1580        let decision = resolve_for_file("target", &[], DriveOperation::Edit, &rules);
1581        assert_eq!(decision.verdict, Verdict::Deny);
1582    }
1583
1584    #[test]
1585    fn resolve_file_rule_returns_none_rather_than_the_default_policy() {
1586        // Must be distinguishable from "decided deny", or the caller would
1587        // skip the ancestor walk that could still have granted it.
1588        let rules = [file_rule("target", &[DriveOperation::Edit], &[])];
1589        assert!(resolve_file_rule("target", DriveOperation::SheetsWrite, &rules).is_none());
1590        assert!(resolve_file_rule("other", DriveOperation::Edit, &rules).is_none());
1591    }
1592
1593    #[test]
1594    fn a_file_rule_is_invisible_to_the_ancestor_walk() {
1595        // A `file_id` rule whose id happens to equal a folder in the chain
1596        // must not match it: the two id spaces are not interchangeable.
1597        let rules = [file_rule("target", &[DriveOperation::Create], &[])];
1598        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
1599        assert_eq!(decision.verdict, Verdict::Deny);
1600        assert_eq!(decision.decided_by, None);
1601    }
1602
1603    #[test]
1604    fn a_folder_rule_is_invisible_to_the_file_lookup() {
1605        let rules = [rule("target", true, &[DriveOperation::Create], &[])];
1606        assert!(resolve_file_rule("target", DriveOperation::Create, &rules).is_none());
1607    }
1608
1609    #[test]
1610    fn resolve_for_file_falls_through_to_the_chain_when_no_file_rule_matches() {
1611        let rules = [rule("parent", true, &[DriveOperation::Edit], &[])];
1612        let decision =
1613            resolve_for_file("target", &chain(&["parent"]), DriveOperation::Edit, &rules);
1614        assert_eq!(decision.verdict, Verdict::Allow);
1615        assert_eq!(folder_id_of(&decision), "parent");
1616    }
1617
1618    // ── rule schema validation ────────────────────────────────────────
1619
1620    fn parse_rule(json: &str) -> Result<FolderPermissionRule, serde_json::Error> {
1621        serde_json::from_str(json)
1622    }
1623
1624    #[test]
1625    fn a_folder_rule_round_trips_byte_identically_to_the_pre_1612_shape() {
1626        let parsed =
1627            parse_rule(r#"{"folder_id":"f1","recursive":true,"allow":["create"]}"#).unwrap();
1628        assert_eq!(parsed.folder_id.as_deref(), Some("f1"));
1629        assert_eq!(parsed.file_id, None);
1630        assert!(parsed.recursive);
1631        let json: serde_json::Value = serde_json::to_value(&parsed).unwrap();
1632        assert_eq!(json["folder_id"], "f1");
1633        assert_eq!(json["recursive"], true);
1634        assert!(
1635            json.get("file_id").is_none(),
1636            "an absent file_id must not be serialized"
1637        );
1638    }
1639
1640    #[test]
1641    fn a_pre_lease_rule_with_no_require_lease_key_defaults_to_requiring_one() {
1642        // Pins `default_require_lease`'s return value against a
1643        // pre-existing `settings.json` written before ADR-0080 (issue
1644        // #1664): every operator's already-saved rule has no
1645        // `require_lease` key at all, and the backward-compatibility
1646        // guarantee is that it is still gated by the lease, not silently
1647        // exempted. Nothing else in this test file asserts this — a
1648        // refactor that broke `default_require_lease`'s body (e.g.
1649        // swapping `#[serde(default = "default_require_lease")]` for a
1650        // bare `#[serde(default)]`) would otherwise compile and pass every
1651        // other test while disabling Touch ID/backup enforcement crate-wide
1652        // for every such rule.
1653        let parsed =
1654            parse_rule(r#"{"folder_id":"f1","recursive":true,"allow":["create"]}"#).unwrap();
1655        assert!(parsed.require_lease);
1656    }
1657
1658    #[test]
1659    fn a_file_rule_parses_and_serializes_without_a_folder_id() {
1660        let parsed = parse_rule(r#"{"file_id":"x1","allow":["sheets-write"]}"#).unwrap();
1661        assert_eq!(parsed.file_id.as_deref(), Some("x1"));
1662        assert_eq!(parsed.folder_id, None);
1663        let json: serde_json::Value = serde_json::to_value(&parsed).unwrap();
1664        assert_eq!(json["file_id"], "x1");
1665        assert!(json.get("folder_id").is_none());
1666    }
1667
1668    #[test]
1669    fn a_rule_naming_neither_target_is_a_load_error() {
1670        let err = parse_rule(r#"{"allow":["create"]}"#)
1671            .unwrap_err()
1672            .to_string();
1673        assert!(err.contains("folder_id"), "{err}");
1674        assert!(err.contains("file_id"), "{err}");
1675    }
1676
1677    #[test]
1678    fn a_rule_naming_both_targets_is_a_load_error() {
1679        let err = parse_rule(r#"{"folder_id":"f","file_id":"x"}"#)
1680            .unwrap_err()
1681            .to_string();
1682        assert!(err.contains("not both"), "{err}");
1683    }
1684
1685    #[test]
1686    fn recursive_true_on_a_file_rule_is_a_load_error() {
1687        // Silently ignoring it would let an operator believe they had
1688        // granted a subtree when they had granted one file.
1689        let err = parse_rule(r#"{"file_id":"x","recursive":true}"#)
1690            .unwrap_err()
1691            .to_string();
1692        assert!(err.contains("recursive"), "{err}");
1693    }
1694
1695    #[test]
1696    fn recursive_false_on_a_file_rule_is_accepted() {
1697        // Redundant but not a misunderstanding, so it must not error.
1698        let parsed = parse_rule(r#"{"file_id":"x","recursive":false}"#).unwrap();
1699        assert!(!parsed.recursive);
1700    }
1701
1702    #[test]
1703    fn deciding_rule_accessors_render_both_kinds() {
1704        let folder = DecidingRule::Folder {
1705            folder_id: "f1".to_string(),
1706            depth: 2,
1707        };
1708        assert_eq!(folder.id(), "f1");
1709        assert_eq!(folder.kind_label(), "folder");
1710        assert_eq!(folder.depth_suffix(), " (depth 2)");
1711
1712        let file = DecidingRule::File {
1713            file_id: "x1".to_string(),
1714        };
1715        assert_eq!(file.id(), "x1");
1716        assert_eq!(file.kind_label(), "file");
1717        assert_eq!(file.depth_suffix(), "");
1718    }
1719}