use crate::guidance::{Guidance, Line};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ConfigGrant {
project_denied: bool,
project_grant_ignored: bool,
user: Option<bool>,
}
impl ConfigGrant {
#[must_use]
pub fn from_layers(project: Option<bool>, user: Option<bool>) -> Self {
Self {
project_denied: project == Some(false),
project_grant_ignored: project == Some(true),
user,
}
}
#[must_use]
pub fn as_effective(self) -> Option<bool> {
if self.project_denied {
return Some(false);
}
self.user
}
#[must_use]
pub fn project_grant_ignored(self) -> bool {
self.project_grant_ignored
}
#[must_use]
pub fn project_denied(self) -> bool {
self.project_denied
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Requested {
#[default]
Unset,
Host,
Sandbox,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Backend {
Sandbox,
Host,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Reason {
GrantedByInvocation,
GrantedByUserLayer,
SandboxByDefault,
SandboxByInvocation,
SandboxByUserLayer,
SandboxByProjectDenial,
}
impl Reason {
#[must_use]
pub fn backend(self) -> Backend {
match self {
Self::GrantedByInvocation | Self::GrantedByUserLayer => Backend::Host,
Self::SandboxByDefault
| Self::SandboxByInvocation
| Self::SandboxByUserLayer
| Self::SandboxByProjectDenial => Backend::Sandbox,
}
}
#[must_use]
pub fn granted(self) -> bool {
self.backend() == Backend::Host
}
#[must_use]
pub fn explanation(self) -> &'static str {
match self {
Self::GrantedByInvocation => "on this host, granted by `--allow-unsandboxed`",
Self::GrantedByUserLayer => {
"on this host, granted by `[lint] allow_unsandboxed` in your own config"
}
Self::SandboxByDefault => "sandboxed, which is the default",
Self::SandboxByInvocation => "sandboxed, as `--sandboxed` asked",
Self::SandboxByUserLayer => {
"sandboxed — your `~/.roteiro/config.toml` sets `[lint] allow_unsandboxed = false`"
}
Self::SandboxByProjectDenial => {
"sandboxed — this repository's `roteiro.toml` sets `[lint] allow_unsandboxed = \
false`, which denies host execution for everyone working in it and is not \
overridden by `--allow-unsandboxed`"
}
}
}
#[must_use]
pub fn host_escape(self) -> Option<Guidance> {
match self {
Self::GrantedByInvocation | Self::GrantedByUserLayer | Self::SandboxByProjectDenial => {
None
}
Self::SandboxByDefault => Some(Guidance::new(&[
Line::Note(&[
"Or accept an unisolated run instead. `cargo clippy` would then compile",
"this tree here, executing its build scripts and loading its proc macros",
"with your filesystem and your credentials. In your own repository that is",
"the build you were going to run anyway; in a branch you are reviewing it",
"is somebody else's code.",
]),
Line::Note(&["Either one of these is enough — you do not need both:"]),
Line::Command("for this run: roteiro lint <analyzer> --allow-unsandboxed"),
Line::Command(
"standing: add `[lint] allow_unsandboxed = true` to ~/.roteiro/config.toml",
),
Line::Note(&[
"A project's `roteiro.toml` cannot grant it — a committed file may deny",
"host execution and never grant it, because a merged line would otherwise",
"start running builds on every teammate's machine (ADR-0020 §6).",
]),
])),
Self::SandboxByUserLayer => Some(Guidance::new(&[Line::Note(&[
"Or override your own `[lint] allow_unsandboxed = false` for this run with",
"`--allow-unsandboxed`, accepting that the tree is then compiled on this host.",
])])),
Self::SandboxByInvocation => Some(Guidance::new(&[Line::Note(&[
"Or drop `--sandboxed` and pass `--allow-unsandboxed`, accepting that the tree",
"is then compiled on this host.",
])])),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Decision {
pub reason: Reason,
pub project_grant_ignored: bool,
}
impl Decision {
#[must_use]
pub fn backend(self) -> Backend {
self.reason.backend()
}
#[must_use]
pub fn granted(self) -> bool {
self.reason.granted()
}
#[must_use]
pub fn ignored_project_grant_note(self) -> Option<&'static str> {
self.project_grant_ignored.then_some(
"note: this repository's `roteiro.toml` sets `[lint] allow_unsandboxed = true`, which \
was read and ignored. A committed file may deny host execution but never grant it, \
because a merged line would otherwise start running builds on every teammate's \
machine (ADR-0020 §6)",
)
}
}
#[must_use]
pub fn decide(config: ConfigGrant, requested: Requested) -> Decision {
let reason = match (config.project_denied(), requested, config.as_effective()) {
(true, _, _) => Reason::SandboxByProjectDenial,
(false, Requested::Sandbox, _) => Reason::SandboxByInvocation,
(false, Requested::Host, _) => Reason::GrantedByInvocation,
(false, Requested::Unset, Some(true)) => Reason::GrantedByUserLayer,
(false, Requested::Unset, Some(false)) => Reason::SandboxByUserLayer,
(false, Requested::Unset, None) => Reason::SandboxByDefault,
};
debug_assert_eq!(
reason.granted(),
!config.project_denied()
&& (requested == Requested::Host
|| (requested == Requested::Unset && config.as_effective() == Some(true))),
"either the user layer or the invocation grants, and the project may always deny"
);
Decision {
reason,
project_grant_ignored: config.project_grant_ignored(),
}
}
#[cfg(test)]
mod tests {
use super::{Backend, ConfigGrant, Decision, Reason, Requested, decide};
fn at(project: Option<bool>, user: Option<bool>, requested: Requested) -> Decision {
decide(ConfigGrant::from_layers(project, user), requested)
}
#[test]
fn saying_nothing_selects_the_sandbox() {
let decision = at(None, None, Requested::Unset);
assert_eq!(decision.reason, Reason::SandboxByDefault);
assert_eq!(decision.backend(), Backend::Sandbox);
assert!(!decision.granted());
}
#[test]
fn every_layer_combination_matches_the_adr_table() {
for requested in [Requested::Unset, Requested::Host, Requested::Sandbox] {
for user in [None, Some(true), Some(false)] {
let denied = at(Some(false), user, requested);
assert_eq!(
denied.reason,
Reason::SandboxByProjectDenial,
"project denial must outrank user={user:?} requested={requested:?}"
);
assert_eq!(denied.backend(), Backend::Sandbox);
assert!(!denied.granted());
assert_eq!(
at(Some(true), user, requested).reason,
at(None, user, requested).reason,
"a project grant must change nothing (user={user:?} requested={requested:?})"
);
}
}
}
#[test]
fn either_the_user_layer_or_the_invocation_grants_alone() {
assert_eq!(
at(None, Some(true), Requested::Unset).reason,
Reason::GrantedByUserLayer,
"a standing preference needs no flag, or the key would be useless"
);
assert_eq!(
at(None, None, Requested::Host).reason,
Reason::GrantedByInvocation,
"a flag needs no standing preference"
);
assert_eq!(
at(None, Some(true), Requested::Host).reason,
Reason::GrantedByInvocation
);
}
#[test]
fn the_flag_overrides_the_users_own_denial_but_never_the_projects() {
assert_eq!(
at(None, Some(false), Requested::Host).reason,
Reason::GrantedByInvocation
);
assert_eq!(
at(Some(false), Some(false), Requested::Host).reason,
Reason::SandboxByProjectDenial
);
}
#[test]
fn asking_for_the_sandbox_denies_the_host_whatever_the_config_says() {
for user in [None, Some(true), Some(false)] {
let decision = at(None, user, Requested::Sandbox);
assert_eq!(
decision.reason,
Reason::SandboxByInvocation,
"user={user:?}"
);
assert_eq!(decision.backend(), Backend::Sandbox);
assert!(!decision.granted());
}
}
#[test]
fn an_ignored_project_grant_is_reported_beside_the_outcome_not_folded_into_it() {
let sandboxed = at(Some(true), None, Requested::Unset);
assert!(!sandboxed.granted());
assert!(sandboxed.project_grant_ignored);
assert!(sandboxed.ignored_project_grant_note().is_some());
let granted = at(Some(true), None, Requested::Host);
assert!(granted.granted());
assert!(granted.project_grant_ignored);
assert!(
at(None, Some(true), Requested::Unset)
.ignored_project_grant_note()
.is_none(),
"there was nothing to discard"
);
}
#[test]
fn the_effective_config_value_never_shows_a_project_grant() {
assert_eq!(
ConfigGrant::from_layers(Some(true), None).as_effective(),
None
);
assert_eq!(
ConfigGrant::from_layers(Some(true), Some(false)).as_effective(),
Some(false)
);
assert_eq!(
ConfigGrant::from_layers(Some(false), Some(true)).as_effective(),
Some(false)
);
assert_eq!(
ConfigGrant::from_layers(None, Some(true)).as_effective(),
Some(true)
);
assert_eq!(ConfigGrant::from_layers(None, None).as_effective(), None);
}
#[test]
fn every_reason_explains_which_layer_decided() {
for reason in [
Reason::GrantedByInvocation,
Reason::GrantedByUserLayer,
Reason::SandboxByDefault,
Reason::SandboxByInvocation,
Reason::SandboxByUserLayer,
Reason::SandboxByProjectDenial,
] {
let explanation = reason.explanation();
assert!(!explanation.trim().is_empty(), "{reason:?} says nothing");
let names_the_layer = explanation.contains("--allow-unsandboxed")
|| explanation.contains("--sandboxed")
|| explanation.contains("config")
|| explanation.contains("roteiro.toml")
|| explanation.contains("default");
assert!(
names_the_layer,
"{reason:?} does not say who decided: {explanation}"
);
match reason.backend() {
Backend::Host => assert!(
explanation.contains("on this host"),
"{reason:?}: {explanation}"
),
Backend::Sandbox => assert!(
explanation.contains("sandboxed"),
"{reason:?}: {explanation}"
),
}
}
}
#[test]
fn the_host_escape_is_offered_only_to_someone_who_could_take_it() {
assert!(
Reason::SandboxByProjectDenial.host_escape().is_none(),
"a project denial cannot be escaped, so offering a flag wastes the reader's time"
);
for granted in [Reason::GrantedByInvocation, Reason::GrantedByUserLayer] {
assert!(granted.host_escape().is_none(), "{granted:?}");
assert!(granted.granted());
}
let default = Reason::SandboxByDefault
.host_escape()
.expect("escape")
.to_string();
assert!(default.contains("--allow-unsandboxed"), "{default}");
assert!(
default.contains("[lint] allow_unsandboxed = true"),
"{default}"
);
assert!(
default.contains("~/.roteiro/config.toml"),
"and where it goes: {default}"
);
assert!(
default.contains("build scripts"),
"and what is being accepted: {default}"
);
assert!(
default.contains("do not need both"),
"the escape must say either one suffices: {default}"
);
assert!(
default.contains("cannot grant"),
"and that the committed file is not the place to put it: {default}"
);
let user = Reason::SandboxByUserLayer
.host_escape()
.expect("escape")
.to_string();
assert!(user.contains("--allow-unsandboxed"), "{user}");
assert!(
user.contains("your own"),
"your own denial is yours to override: {user}"
);
let sandboxed = Reason::SandboxByInvocation
.host_escape()
.expect("escape")
.to_string();
assert!(sandboxed.contains("--sandboxed"), "{sandboxed}");
assert!(sandboxed.contains("--allow-unsandboxed"), "{sandboxed}");
}
#[test]
fn the_default_escape_keeps_each_form_on_its_own_line() {
let escape = Reason::SandboxByDefault
.host_escape()
.expect("escape")
.to_string();
let lines: Vec<&str> = escape.lines().map(str::trim).collect();
assert!(
lines.contains(&"for this run: roteiro lint <analyzer> --allow-unsandboxed"),
"{escape}"
);
assert!(
lines.contains(
&"standing: add `[lint] allow_unsandboxed = true` to ~/.roteiro/config.toml"
),
"{escape}"
);
}
#[test]
fn granted_and_backend_can_never_disagree() {
for requested in [Requested::Unset, Requested::Host, Requested::Sandbox] {
for user in [None, Some(true), Some(false)] {
for project in [None, Some(true), Some(false)] {
let decision = at(project, user, requested);
assert_eq!(
decision.granted(),
decision.backend() == Backend::Host,
"project={project:?} user={user:?} requested={requested:?}"
);
}
}
}
}
}