Skip to main content

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_the_users_own_denial_but_never_the_projects() {
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}