rto_exec/lint_grant.rs
1//! ADR-0020 §6's grant: **may a linter run on this host?**
2//!
3//! `roteiro lint` runs the linter sandboxed by default, and the host is
4//! something a person opts into. This module is the whole of that rule — the
5//! layering, the precedence between layers, and the sentence each refusal shows
6//! the reader.
7//!
8//! # Why it compiles when the linter does not
9//!
10//! [`crate::lint`] needs `exec-subprocess`; this module needs nothing. That is
11//! deliberate rather than incidental. ADR-0020 spends its length refusing one
12//! specific failure — *the availability of a capability quietly deciding a
13//! question that was supposed to be decided on purpose* — and a policy that
14//! existed only in the builds able to act on it would be an instance of it. So
15//! the answer to "may this machine run builds?" is available to `roteiro config`
16//! and to the layering in a build with no linter at all, and it says the same
17//! thing there as everywhere else.
18//!
19//! @rto:0020
20
21use crate::guidance::{Guidance, Line};
22
23/// What the two **configuration** layers jointly say about running a linter on
24/// this host — the half of the grant that can be answered from files alone.
25///
26/// Built by [`ConfigGrant::from_layers`], which is this crate's single
27/// implementation of ADR-0020 §6's "a project may deny but never grant". Nothing
28/// else may re-derive that rule: the binary's config layering calls this, so the
29/// value `roteiro config` echoes and the value [`decide`] consults are the same
30/// value.
31///
32/// # Why the project layer cannot grant
33///
34/// `roteiro.toml` is committed and shared by design — ADR-0007's own reason for
35/// it existing. A merged line that starts running builds on every teammate's
36/// machine is consent by pull request: granted by someone else, noticed by
37/// nobody. Denial has none of those problems, so the project layer keeps it: a
38/// repository that wants the sandbox enforced for everyone can say so.
39///
40/// # Its twin in `rto-remote`, and the one place they part company
41///
42/// `rto_remote::ConfigGrant` implements the same inversion for ADR-0019's remote
43/// tier, and the two agree exactly on this half — a cross-check in the binary's
44/// test suite pins all nine layer combinations against each other, because two
45/// implementations of one rule are a rule that will drift.
46///
47/// They are two implementations rather than one because `rto-remote` is an
48/// optional, off-by-default crate and `lint` ships in the default feature set,
49/// so this could not depend on that.
50///
51/// Where they deliberately differ is the **invocation**, and that difference is
52/// in [`decide`] rather than here: ADR-0019 needs the user layer *and* the flag,
53/// ADR-0020 §6 needs *either*. Remote egress sends your source elsewhere and is
54/// worth re-consenting to per run; building on your own machine is a standing
55/// preference somebody may reasonably express once. Do not "make them
56/// consistent" — requiring both here would make the config key useless, since
57/// you would still type the flag every run.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub struct ConfigGrant {
60 /// The project file said `false`.
61 project_denied: bool,
62 /// The project file said `true`, and it was discarded.
63 project_grant_ignored: bool,
64 /// What the user layer said, verbatim.
65 user: Option<bool>,
66}
67
68impl ConfigGrant {
69 /// Read the two config layers, applying the inversion.
70 ///
71 /// `project` is `roteiro.toml`'s `[lint] allow_unsandboxed`; `user` is
72 /// `~/.roteiro/config.toml`'s. Both are `None` when the key is absent.
73 #[must_use]
74 pub fn from_layers(project: Option<bool>, user: Option<bool>) -> Self {
75 Self {
76 project_denied: project == Some(false),
77 project_grant_ignored: project == Some(true),
78 user,
79 }
80 }
81
82 /// The value `roteiro config` should echo for `[lint] allow_unsandboxed` —
83 /// the config layers' *effective* contribution, with the invocation still
84 /// outstanding.
85 ///
86 /// `Some(false)` when the project denied, otherwise the user layer's own
87 /// value. A project grant never appears here, because it never becomes
88 /// effective; [`ConfigGrant::project_grant_ignored`] is how it is reported
89 /// instead.
90 #[must_use]
91 pub fn as_effective(self) -> Option<bool> {
92 if self.project_denied {
93 return Some(false);
94 }
95 self.user
96 }
97
98 /// Whether a committed project file tried to grant host execution and was
99 /// overruled.
100 #[must_use]
101 pub fn project_grant_ignored(self) -> bool {
102 self.project_grant_ignored
103 }
104
105 /// Whether the project file denied host execution for everyone using this
106 /// repository.
107 #[must_use]
108 pub fn project_denied(self) -> bool {
109 self.project_denied
110 }
111}
112
113/// What the invocation asked for.
114///
115/// Named `Requested` rather than `Invocation` only because that name is already
116/// taken in this crate by an analyzer's argv ([`crate::Invocation`]); it is the
117/// same concept as `rto_remote::Invocation`, minus the prompt form, because a
118/// linter is run non-interactively far more often than it is not and a prompt
119/// that a script cannot answer is a hang rather than a gate.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
121pub enum Requested {
122 /// Neither flag was passed. **Not a grant** — the default is the sandbox.
123 #[default]
124 Unset,
125 /// `--allow-unsandboxed`: run it here.
126 Host,
127 /// `--sandboxed`: run it in the sandbox, or not at all.
128 Sandbox,
129}
130
131/// Which backend a decision selects.
132///
133/// The value that used to be a boolean called *granted*, and the rename is the
134/// substance rather than the style. While conditions 1-2 were unbuilt there were
135/// only two outcomes — run on the host, or refuse — so "granted" answered the
136/// whole question. Now there are two *backends*, and the layers choose between
137/// them rather than choosing between running and not.
138///
139/// Nothing in this type says whether the chosen backend is **available**. That
140/// is deliberate: availability is a property of the machine and the build, not
141/// of the policy, and a selection that quietly became a host run because a
142/// hypervisor was missing is the silent downgrade ADR-0020 §6 exists to prevent.
143/// The runner asks; this module only ever says which one to ask.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145#[non_exhaustive]
146pub enum Backend {
147 /// A digest-pinned image in a microVM. The default.
148 Sandbox,
149 /// This machine, with this user's toolchain, filesystem and credentials.
150 Host,
151}
152
153/// Which layer decided, and therefore what the person should be told.
154///
155/// Every variant carries one sentence of explanation, because a decision the
156/// person did not make is one they have to be able to account for — most of all
157/// when it overrules something they *did* say. `--allow-unsandboxed` in a
158/// repository that denies host execution runs sandboxed, and that has to be a
159/// sentence rather than a silence.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161#[non_exhaustive]
162pub enum Reason {
163 /// Host execution was granted by `--allow-unsandboxed` on this run.
164 GrantedByInvocation,
165 /// Host execution was granted by the user's own config, with no flag needed.
166 GrantedByUserLayer,
167 /// Nobody asked for anything — **the default**, and the common case.
168 SandboxByDefault,
169 /// `--sandboxed` was passed.
170 SandboxByInvocation,
171 /// The user's own config says `false`, so the host is off the table.
172 SandboxByUserLayer,
173 /// This repository's `roteiro.toml` denied host execution. Nothing
174 /// overrides this, including `--allow-unsandboxed`.
175 SandboxByProjectDenial,
176}
177
178impl Reason {
179 /// Which backend this reason selects.
180 #[must_use]
181 pub fn backend(self) -> Backend {
182 match self {
183 Self::GrantedByInvocation | Self::GrantedByUserLayer => Backend::Host,
184 Self::SandboxByDefault
185 | Self::SandboxByInvocation
186 | Self::SandboxByUserLayer
187 | Self::SandboxByProjectDenial => Backend::Sandbox,
188 }
189 }
190
191 /// Whether the linter may run on this host.
192 #[must_use]
193 pub fn granted(self) -> bool {
194 self.backend() == Backend::Host
195 }
196
197 /// One sentence naming which layer decided, for the line printed before the
198 /// run.
199 ///
200 /// Phrased to complete "running clippy …", so the two backends' disclosures
201 /// read alike and a person can tell at a glance which one they got.
202 #[must_use]
203 pub fn explanation(self) -> &'static str {
204 match self {
205 Self::GrantedByInvocation => "on this host, granted by `--allow-unsandboxed`",
206 Self::GrantedByUserLayer => {
207 "on this host, granted by `[lint] allow_unsandboxed` in your own config"
208 }
209 Self::SandboxByDefault => "sandboxed, which is the default",
210 Self::SandboxByInvocation => "sandboxed, as `--sandboxed` asked",
211 Self::SandboxByUserLayer => {
212 "sandboxed — your `~/.roteiro/config.toml` sets `[lint] allow_unsandboxed = false`"
213 }
214 Self::SandboxByProjectDenial => {
215 "sandboxed — this repository's `roteiro.toml` sets `[lint] allow_unsandboxed = \
216 false`, which denies host execution for everyone working in it and is not \
217 overridden by `--allow-unsandboxed`"
218 }
219 }
220 }
221
222 /// How this person could run on the host instead, if the sandbox cannot be
223 /// had — or `None` when they could not.
224 ///
225 /// Consulted only by a refusal, and it exists so that a refusal names a way
226 /// forward **that would actually work for this reason**. Telling someone in
227 /// a repository that denies host execution to pass `--allow-unsandboxed`
228 /// would waste their time, and telling someone who is already on the host
229 /// how to get there would be nonsense. Both were live bugs in the shape this
230 /// replaces (#426's refusals rule).
231 ///
232 /// A [`Guidance`] rather than a `&'static str`, because this text is
233 /// multi-line and carries commands people paste. Written as one wrapped
234 /// literal it leaked its own source indentation into shipped output; written
235 /// as lines and fragments it cannot.
236 #[must_use]
237 pub fn host_escape(self) -> Option<Guidance> {
238 match self {
239 // Two reasons, one answer, and both are "there is nothing to offer":
240 //
241 // - a granted run is **already** on the host, so an escape to it
242 // would be nonsense;
243 // - a project denial **cannot** be escaped, so offering
244 // `--allow-unsandboxed` would waste the reader's time. The way
245 // forward there is a change to the repository, not to a machine.
246 //
247 // They share an arm because clippy is right that the bodies are
248 // identical; they are listed separately above because a future
249 // editor changing one must not silently change the other.
250 Self::GrantedByInvocation | Self::GrantedByUserLayer | Self::SandboxByProjectDenial => {
251 None
252 }
253 Self::SandboxByDefault => Some(Guidance::new(&[
254 Line::Note(&[
255 "Or accept an unisolated run instead. `cargo clippy` would then compile",
256 "this tree here, executing its build scripts and loading its proc macros",
257 "with your filesystem and your credentials. In your own repository that is",
258 "the build you were going to run anyway; in a branch you are reviewing it",
259 "is somebody else's code.",
260 ]),
261 Line::Note(&["Either one of these is enough — you do not need both:"]),
262 // Aligned on purpose, and rendered verbatim so the alignment
263 // survives. These are the two lines people copy.
264 Line::Command("for this run: roteiro lint <analyzer> --allow-unsandboxed"),
265 Line::Command(
266 "standing: add `[lint] allow_unsandboxed = true` to ~/.roteiro/config.toml",
267 ),
268 // Kept from the refusal this replaces. Someone who has just been
269 // shown a config key will reach for the file they already edit,
270 // and `roteiro.toml` is the wrong one — silently, since a
271 // committed grant is read and discarded.
272 Line::Note(&[
273 "A project's `roteiro.toml` cannot grant it — a committed file may deny",
274 "host execution and never grant it, because a merged line would otherwise",
275 "start running builds on every teammate's machine (ADR-0020 §6).",
276 ]),
277 ])),
278 Self::SandboxByUserLayer => Some(Guidance::new(&[Line::Note(&[
279 "Or override your own `[lint] allow_unsandboxed = false` for this run with",
280 "`--allow-unsandboxed`, accepting that the tree is then compiled on this host.",
281 ])])),
282 Self::SandboxByInvocation => Some(Guidance::new(&[Line::Note(&[
283 "Or drop `--sandboxed` and pass `--allow-unsandboxed`, accepting that the tree",
284 "is then compiled on this host.",
285 ])])),
286 }
287 }
288}
289
290/// The outcome of consulting every layer.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub struct Decision {
293 /// Which layer decided. [`Reason::granted`] iff the linter may run here.
294 pub reason: Reason,
295 /// A project file's `[lint] allow_unsandboxed = true` was read and ignored.
296 ///
297 /// Independent of [`Decision::reason`]: an ignored project grant cannot
298 /// change the outcome by definition, so it is reported alongside rather than
299 /// folded in. Dropping it silently would leave a team wondering why their
300 /// committed setting does nothing.
301 pub project_grant_ignored: bool,
302}
303
304impl Decision {
305 /// Which backend this run uses. Never *whether it is available* — see
306 /// [`Backend`].
307 #[must_use]
308 pub fn backend(self) -> Backend {
309 self.reason.backend()
310 }
311
312 /// Whether this run may execute the linter on this host.
313 #[must_use]
314 pub fn granted(self) -> bool {
315 self.reason.granted()
316 }
317
318 /// The note printed beside a decision when a committed grant was discarded,
319 /// or `None` when there was nothing to discard.
320 #[must_use]
321 pub fn ignored_project_grant_note(self) -> Option<&'static str> {
322 self.project_grant_ignored.then_some(
323 "note: this repository's `roteiro.toml` sets `[lint] allow_unsandboxed = true`, which \
324 was read and ignored. A committed file may deny host execution but never grant it, \
325 because a merged line would otherwise start running builds on every teammate's \
326 machine (ADR-0020 §6)",
327 )
328 }
329}
330
331/// Decide whether this run may execute the linter on this host.
332///
333/// # The order the layers are consulted, and why it is this order
334///
335/// 1. **The project's denial outranks everything.** A locked-down repository is
336/// a legitimate thing to express, and someone working in one must not be told
337/// to edit their user config, because it would not help.
338/// 2. **Then the invocation**, in both directions. This is ADR-0007's ordinary
339/// *flag beats config* rule, and it is why `--allow-unsandboxed` overrides a
340/// user layer that said `false`: the standing preference is the user's own,
341/// and a person may override their own standing preference for one run.
342/// 3. **Then the user layer**, which grants on its own — no flag needed. This is
343/// where ADR-0020 §6 parts company with ADR-0019 §3 deliberately: **either**
344/// the user layer or the invocation suffices, rather than both. See
345/// [`ConfigGrant`] for why, and do not reconcile them.
346/// 4. **Otherwise ungranted**, which is the default and the common case.
347///
348/// A project *grant* appears nowhere in that list, because it never becomes
349/// effective — it is reported through [`Decision::project_grant_ignored`].
350#[must_use]
351pub fn decide(config: ConfigGrant, requested: Requested) -> Decision {
352 let reason = match (config.project_denied(), requested, config.as_effective()) {
353 (true, _, _) => Reason::SandboxByProjectDenial,
354 (false, Requested::Sandbox, _) => Reason::SandboxByInvocation,
355 (false, Requested::Host, _) => Reason::GrantedByInvocation,
356 (false, Requested::Unset, Some(true)) => Reason::GrantedByUserLayer,
357 (false, Requested::Unset, Some(false)) => Reason::SandboxByUserLayer,
358 (false, Requested::Unset, None) => Reason::SandboxByDefault,
359 };
360 debug_assert_eq!(
361 reason.granted(),
362 !config.project_denied()
363 && (requested == Requested::Host
364 || (requested == Requested::Unset && config.as_effective() == Some(true))),
365 "either the user layer or the invocation grants, and the project may always deny"
366 );
367 Decision {
368 reason,
369 project_grant_ignored: config.project_grant_ignored(),
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::{Backend, ConfigGrant, Decision, Reason, Requested, decide};
376
377 fn at(project: Option<bool>, user: Option<bool>, requested: Requested) -> Decision {
378 decide(ConfigGrant::from_layers(project, user), requested)
379 }
380
381 /// The default, and the reason this module exists: saying nothing asks for
382 /// the sandbox.
383 ///
384 /// It used to assert that saying nothing *refused*, which was true only
385 /// while conditions 1-2 were unbuilt. The layers never said "refuse" — they
386 /// said "sandbox", and refusing was what the sandbox amounted to when there
387 /// was not one. Now there is, and the same table means what it always said.
388 #[test]
389 fn saying_nothing_selects_the_sandbox() {
390 let decision = at(None, None, Requested::Unset);
391 assert_eq!(decision.reason, Reason::SandboxByDefault);
392 assert_eq!(decision.backend(), Backend::Sandbox);
393 assert!(!decision.granted());
394 }
395
396 /// ADR-0020 §6's table, one assertion per cell, over every combination of
397 /// the three layers. Written exhaustively rather than as spot-checks because
398 /// the table *is* the decision — a rule with an untested row is a rule with
399 /// a row somebody will change.
400 #[test]
401 fn every_layer_combination_matches_the_adr_table() {
402 for requested in [Requested::Unset, Requested::Host, Requested::Sandbox] {
403 for user in [None, Some(true), Some(false)] {
404 // The project layer may deny host execution, and its denial is
405 // absolute. What it denies is the *host*, never the run: the
406 // sandbox is what everyone in that repository gets.
407 let denied = at(Some(false), user, requested);
408 assert_eq!(
409 denied.reason,
410 Reason::SandboxByProjectDenial,
411 "project denial must outrank user={user:?} requested={requested:?}"
412 );
413 assert_eq!(denied.backend(), Backend::Sandbox);
414 assert!(!denied.granted());
415
416 // The project layer may never grant: with `project = Some(true)`
417 // the outcome must be identical to the key being absent.
418 assert_eq!(
419 at(Some(true), user, requested).reason,
420 at(None, user, requested).reason,
421 "a project grant must change nothing (user={user:?} requested={requested:?})"
422 );
423 }
424 }
425 }
426
427 /// **Either** suffices — the one place ADR-0020 §6 parts company with
428 /// ADR-0019 §3, and the part the ADR says not to reconcile.
429 #[test]
430 fn either_the_user_layer_or_the_invocation_grants_alone() {
431 assert_eq!(
432 at(None, Some(true), Requested::Unset).reason,
433 Reason::GrantedByUserLayer,
434 "a standing preference needs no flag, or the key would be useless"
435 );
436 assert_eq!(
437 at(None, None, Requested::Host).reason,
438 Reason::GrantedByInvocation,
439 "a flag needs no standing preference"
440 );
441 // And both together is still a grant, reported as the flag — the more
442 // recent and more specific of the two acts.
443 assert_eq!(
444 at(None, Some(true), Requested::Host).reason,
445 Reason::GrantedByInvocation
446 );
447 }
448
449 /// ADR-0007's ordinary rule, which this key does *not* invert: a flag beats
450 /// the config. It is your own standing preference, and you may override it
451 /// for one run without editing a file.
452 #[test]
453 fn the_flag_overrides_a_denial_by_the_user_but_never_by_the_project() {
454 assert_eq!(
455 at(None, Some(false), Requested::Host).reason,
456 Reason::GrantedByInvocation
457 );
458 assert_eq!(
459 at(Some(false), Some(false), Requested::Host).reason,
460 Reason::SandboxByProjectDenial
461 );
462 }
463
464 /// Asking for the sandbox denies the host, and it outranks a standing grant:
465 /// `--sandboxed` is how someone with the key set opts *one* run back out.
466 #[test]
467 fn asking_for_the_sandbox_denies_the_host_whatever_the_config_says() {
468 for user in [None, Some(true), Some(false)] {
469 let decision = at(None, user, Requested::Sandbox);
470 assert_eq!(
471 decision.reason,
472 Reason::SandboxByInvocation,
473 "user={user:?}"
474 );
475 assert_eq!(decision.backend(), Backend::Sandbox);
476 assert!(!decision.granted());
477 }
478 }
479
480 /// An ignored project grant is reported whichever way the decision went, and
481 /// never confused with the decision itself.
482 #[test]
483 fn an_ignored_project_grant_is_reported_beside_the_outcome_not_folded_into_it() {
484 let sandboxed = at(Some(true), None, Requested::Unset);
485 assert!(!sandboxed.granted());
486 assert!(sandboxed.project_grant_ignored);
487 assert!(sandboxed.ignored_project_grant_note().is_some());
488
489 // Also reported when the run went to the host for an unrelated reason:
490 // the committed line still did nothing, and the team still needs telling.
491 let granted = at(Some(true), None, Requested::Host);
492 assert!(granted.granted());
493 assert!(granted.project_grant_ignored);
494
495 assert!(
496 at(None, Some(true), Requested::Unset)
497 .ignored_project_grant_note()
498 .is_none(),
499 "there was nothing to discard"
500 );
501 }
502
503 /// The config half, as `roteiro config` echoes it. A project grant must not
504 /// appear here — echoing it would tell a team their committed line worked.
505 #[test]
506 fn the_effective_config_value_never_shows_a_project_grant() {
507 assert_eq!(
508 ConfigGrant::from_layers(Some(true), None).as_effective(),
509 None
510 );
511 assert_eq!(
512 ConfigGrant::from_layers(Some(true), Some(false)).as_effective(),
513 Some(false)
514 );
515 // A denial does show, because it took effect.
516 assert_eq!(
517 ConfigGrant::from_layers(Some(false), Some(true)).as_effective(),
518 Some(false)
519 );
520 assert_eq!(
521 ConfigGrant::from_layers(None, Some(true)).as_effective(),
522 Some(true)
523 );
524 assert_eq!(ConfigGrant::from_layers(None, None).as_effective(), None);
525 }
526
527 /// Every reason is printed before a run, so every reason has to have
528 /// something to print — and it has to name the layer, because a decision
529 /// nobody typed is one the person has to be able to account for.
530 #[test]
531 fn every_reason_explains_which_layer_decided() {
532 for reason in [
533 Reason::GrantedByInvocation,
534 Reason::GrantedByUserLayer,
535 Reason::SandboxByDefault,
536 Reason::SandboxByInvocation,
537 Reason::SandboxByUserLayer,
538 Reason::SandboxByProjectDenial,
539 ] {
540 let explanation = reason.explanation();
541 assert!(!explanation.trim().is_empty(), "{reason:?} says nothing");
542 let names_the_layer = explanation.contains("--allow-unsandboxed")
543 || explanation.contains("--sandboxed")
544 || explanation.contains("config")
545 || explanation.contains("roteiro.toml")
546 || explanation.contains("default");
547 assert!(
548 names_the_layer,
549 "{reason:?} does not say who decided: {explanation}"
550 );
551 // The two backends must be told apart at a glance, since this is the
552 // sentence a person reads to know what just happened to their tree.
553 match reason.backend() {
554 Backend::Host => assert!(
555 explanation.contains("on this host"),
556 "{reason:?}: {explanation}"
557 ),
558 Backend::Sandbox => assert!(
559 explanation.contains("sandboxed"),
560 "{reason:?}: {explanation}"
561 ),
562 }
563 }
564 }
565
566 /// A refusal names a way forward **that would work for this person**, and
567 /// the one person it must never offer `--allow-unsandboxed` to is the one in
568 /// a repository that denies it (#426).
569 ///
570 /// The property that used to be `remedy()`. It moved because what a refusal
571 /// has to say changed: while the sandbox was unbuilt, *every* selection of
572 /// it was a refusal and needed a remedy. Now a sandbox selection is an
573 /// ordinary run, and the escape is consulted only when the boundary cannot
574 /// be had.
575 #[test]
576 fn the_host_escape_is_offered_only_to_someone_who_could_take_it() {
577 // Nothing overrides a project denial, so there is no escape to name.
578 assert!(
579 Reason::SandboxByProjectDenial.host_escape().is_none(),
580 "a project denial cannot be escaped, so offering a flag wastes the reader's time"
581 );
582 // Already on the host: there is nothing to escape to.
583 for granted in [Reason::GrantedByInvocation, Reason::GrantedByUserLayer] {
584 assert!(granted.host_escape().is_none(), "{granted:?}");
585 assert!(granted.granted());
586 }
587 // And every reason that *can* be escaped says how, in a way that fits
588 // what that person actually did.
589 let default = Reason::SandboxByDefault
590 .host_escape()
591 .expect("escape")
592 .to_string();
593 assert!(default.contains("--allow-unsandboxed"), "{default}");
594 assert!(
595 default.contains("[lint] allow_unsandboxed = true"),
596 "{default}"
597 );
598 assert!(
599 default.contains("~/.roteiro/config.toml"),
600 "and where it goes: {default}"
601 );
602 assert!(
603 default.contains("build scripts"),
604 "and what is being accepted: {default}"
605 );
606 // The asymmetry with ADR-0019, in the one place a user meets it. Two
607 // forms listed without this sentence read as two *steps*, and someone
608 // who set the config key would go on typing the flag forever — which is
609 // the outcome ADR-0020 §6 gives as its reason for the asymmetry.
610 assert!(
611 default.contains("do not need both"),
612 "the escape must say either one suffices: {default}"
613 );
614 assert!(
615 default.contains("cannot grant"),
616 "and that the committed file is not the place to put it: {default}"
617 );
618
619 let user = Reason::SandboxByUserLayer
620 .host_escape()
621 .expect("escape")
622 .to_string();
623 assert!(user.contains("--allow-unsandboxed"), "{user}");
624 assert!(
625 user.contains("your own"),
626 "your own denial is yours to override: {user}"
627 );
628
629 let sandboxed = Reason::SandboxByInvocation
630 .host_escape()
631 .expect("escape")
632 .to_string();
633 assert!(sandboxed.contains("--sandboxed"), "{sandboxed}");
634 assert!(sandboxed.contains("--allow-unsandboxed"), "{sandboxed}");
635 }
636
637 /// The default escape is copy-pasteable, so its shape is pinned: an editor
638 /// that reflows the string must not silently turn the two forms into prose,
639 /// and `--allow-unsandboxed` must never be left dangling at a wrap point
640 /// where a copy would lose it.
641 #[test]
642 fn the_default_escape_keeps_each_form_on_its_own_line() {
643 let escape = Reason::SandboxByDefault
644 .host_escape()
645 .expect("escape")
646 .to_string();
647 let lines: Vec<&str> = escape.lines().map(str::trim).collect();
648 assert!(
649 lines.contains(&"for this run: roteiro lint <analyzer> --allow-unsandboxed"),
650 "{escape}"
651 );
652 assert!(
653 lines.contains(
654 &"standing: add `[lint] allow_unsandboxed = true` to ~/.roteiro/config.toml"
655 ),
656 "{escape}"
657 );
658 }
659
660 /// `granted()` and `backend()` are two spellings of one fact, and a type
661 /// where they could disagree is a type where a caller checks the wrong one.
662 #[test]
663 fn granted_and_backend_can_never_disagree() {
664 for requested in [Requested::Unset, Requested::Host, Requested::Sandbox] {
665 for user in [None, Some(true), Some(false)] {
666 for project in [None, Some(true), Some(false)] {
667 let decision = at(project, user, requested);
668 assert_eq!(
669 decision.granted(),
670 decision.backend() == Backend::Host,
671 "project={project:?} user={user:?} requested={requested:?}"
672 );
673 }
674 }
675 }
676 }
677}