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