use std::collections::BTreeSet;
use std::fmt;
use thiserror::Error;
use crate::meta::{Surface, Tier, ToolMeta, Toolset};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Preset {
Minimal,
#[default]
Core,
Full,
}
impl Preset {
pub const ALL: &'static [Preset] = &[Self::Minimal, Self::Core, Self::Full];
pub const fn as_str(self) -> &'static str {
match self {
Self::Minimal => "minimal",
Self::Core => "core",
Self::Full => "full",
}
}
pub fn parse(s: &str) -> Result<Self, ConfigError> {
Self::ALL
.iter()
.copied()
.find(|p| p.as_str() == s)
.ok_or_else(|| ConfigError::UnknownPreset(s.to_owned()))
}
pub fn toolsets(self) -> BTreeSet<Toolset> {
use Toolset::*;
let minimal = [LocalStatus, TailnetDevices, TailnetPolicy, TailnetDns];
match self {
Self::Minimal => minimal.into_iter().collect(),
Self::Core => minimal
.into_iter()
.chain([
LocalPrefs,
LocalServe,
LocalFiles,
TailnetKeys,
TailnetUsers,
TailnetInvites,
TailnetWebhooks,
TailnetSettings,
TailnetServices,
])
.collect(),
Self::Full => Toolset::ALL
.iter()
.copied()
.filter(|t| !matches!(t, LocalDebug | LocalPassthrough))
.collect(),
}
}
}
impl fmt::Display for Preset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ConfigError {
#[error("unknown preset `{0}`; expected one of minimal, core, full")]
UnknownPreset(String),
#[error("unknown toolset `{0}`")]
UnknownToolset(String),
#[error(
"toolset list mixes replacement and adjustment: write either a bare list \
of toolsets, or a list where every entry begins with `+` or `-`"
)]
MixedToolsetSyntax,
#[error(
"the selected toolsets and tier leave no tools enabled; \
widen the preset, the toolsets, or the tier"
)]
NoToolsEnabled,
#[error("`{setting}` was set to `{value}`, which is not {expected}")]
InvalidValue {
setting: String,
value: String,
expected: &'static str,
},
}
pub fn apply_toolset_modifiers(
base: BTreeSet<Toolset>,
spec: &str,
) -> Result<BTreeSet<Toolset>, ConfigError> {
let entries: Vec<&str> = spec
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if entries.is_empty() {
return Ok(base);
}
let adjusting = entries[0].starts_with(['+', '-']);
if entries
.iter()
.any(|e| e.starts_with(['+', '-']) != adjusting)
{
return Err(ConfigError::MixedToolsetSyntax);
}
let mut selected = if adjusting { base } else { BTreeSet::new() };
for entry in entries {
let (remove, name) = match entry.strip_prefix('-') {
Some(name) => (true, name),
None => (false, entry.strip_prefix('+').unwrap_or(entry)),
};
let toolset =
Toolset::parse(name).ok_or_else(|| ConfigError::UnknownToolset(name.to_owned()))?;
if remove {
selected.remove(&toolset);
} else {
selected.insert(toolset);
}
}
Ok(selected)
}
#[derive(Debug, Clone)]
pub struct Gate {
toolsets: BTreeSet<Toolset>,
max_tier: Tier,
unavailable: BTreeSet<Surface>,
}
impl Gate {
pub fn new(
toolsets: BTreeSet<Toolset>,
max_tier: Tier,
unavailable: BTreeSet<Surface>,
all_tools: &[ToolMeta],
) -> Result<Self, ConfigError> {
let gate = Self {
toolsets,
max_tier,
unavailable,
};
if !all_tools.iter().any(|t| gate.permits(t)) {
return Err(ConfigError::NoToolsEnabled);
}
Ok(gate)
}
pub fn unchecked(
toolsets: BTreeSet<Toolset>,
max_tier: Tier,
unavailable: BTreeSet<Surface>,
) -> Self {
Self {
toolsets,
max_tier,
unavailable,
}
}
pub fn permits(&self, tool: &ToolMeta) -> bool {
self.toolsets.contains(&tool.toolset)
&& tool.tier <= self.max_tier
&& !self.unavailable.contains(&tool.surface())
}
pub fn needs(&self, tool: &ToolMeta) -> String {
if self.unavailable.contains(&tool.surface()) {
return match tool.surface() {
Surface::Local => "a working `tailscale` binary".to_owned(),
Surface::Tailnet => "a control-plane credential".to_owned(),
};
}
let mut needs = Vec::new();
if !self.toolsets.contains(&tool.toolset) {
needs.push(format!("`--toolsets +{}`", tool.toolset));
}
if tool.tier > self.max_tier
&& let Some(flag) = tool.tier.flag()
{
needs.push(format!("`{flag}`"));
}
if needs.is_empty() {
return "no additional permission".to_owned();
}
needs.join(" and ")
}
pub fn toolsets(&self) -> &BTreeSet<Toolset> {
&self.toolsets
}
pub fn offers(&self, surface: Surface) -> bool {
!self.unavailable.contains(&surface) && self.toolsets.iter().any(|t| t.surface() == surface)
}
pub fn offered_toolsets(&self) -> impl Iterator<Item = Toolset> + '_ {
self.toolsets
.iter()
.copied()
.filter(|toolset| self.offers(toolset.surface()))
}
pub fn max_tier(&self) -> Tier {
self.max_tier
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::Toolset::*;
fn tool(name: &'static str, toolset: Toolset, tier: Tier) -> ToolMeta {
ToolMeta {
name,
toolset,
tier,
summary: "",
self_severing: false,
severs_local_node: false,
requires_confirmation: false,
idempotent: true,
varying_tier: false,
min_version: None,
platforms: None,
}
}
#[test]
fn a_mixed_list_is_refused_rather_than_guessed_at() {
assert_eq!(
apply_toolset_modifiers(Preset::Core.toolsets(), "local-status,-tailnet-dns"),
Err(ConfigError::MixedToolsetSyntax)
);
}
#[test]
fn an_empty_spec_leaves_the_preset_alone() {
let base = Preset::Core.toolsets();
assert_eq!(
apply_toolset_modifiers(base.clone(), " ,, ").expect("empty is fine"),
base
);
}
#[test]
fn the_gate_hides_by_toolset_tier_and_surface() {
let gate = Gate::unchecked(
BTreeSet::from([LocalStatus, TailnetDevices]),
Tier::Write,
BTreeSet::from([Surface::Tailnet]),
);
assert!(gate.permits(&tool("a", LocalStatus, Tier::Read)));
assert!(gate.permits(&tool("b", LocalStatus, Tier::Write)));
assert!(!gate.permits(&tool("c", LocalStatus, Tier::Destructive)));
assert!(!gate.permits(&tool("d", LocalServe, Tier::Read)));
assert!(!gate.permits(&tool("e", TailnetDevices, Tier::Read)));
}
#[test]
fn the_gate_explains_what_would_enable_a_tool() {
let gate = Gate::unchecked(BTreeSet::from([LocalStatus]), Tier::Read, BTreeSet::new());
assert_eq!(
gate.needs(&tool("a", LocalServe, Tier::Destructive)),
"`--toolsets +local-serve` and `--allow-destructive`"
);
assert_eq!(
gate.needs(&tool("b", LocalStatus, Tier::Write)),
"`--allow-write`"
);
let no_backend = Gate::unchecked(
BTreeSet::from([TailnetDevices]),
Tier::Read,
BTreeSet::from([Surface::Tailnet]),
);
assert_eq!(
no_backend.needs(&tool("c", TailnetDevices, Tier::Read)),
"a control-plane credential"
);
}
}