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}