1use std::collections::BTreeSet;
13use std::fmt;
14
15use thiserror::Error;
16
17use crate::meta::{Surface, Tier, ToolMeta, Toolset};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Preset {
25 Minimal,
28 #[default]
31 Core,
32 Full,
36}
37
38impl Preset {
39 pub const ALL: &'static [Preset] = &[Self::Minimal, Self::Core, Self::Full];
40
41 pub const fn as_str(self) -> &'static str {
42 match self {
43 Self::Minimal => "minimal",
44 Self::Core => "core",
45 Self::Full => "full",
46 }
47 }
48
49 pub fn parse(s: &str) -> Result<Self, ConfigError> {
50 Self::ALL
51 .iter()
52 .copied()
53 .find(|p| p.as_str() == s)
54 .ok_or_else(|| ConfigError::UnknownPreset(s.to_owned()))
55 }
56
57 pub fn toolsets(self) -> BTreeSet<Toolset> {
59 use Toolset::*;
60 let minimal = [LocalStatus, TailnetDevices, TailnetPolicy, TailnetDns];
61 match self {
62 Self::Minimal => minimal.into_iter().collect(),
63 Self::Core => minimal
64 .into_iter()
65 .chain([
66 LocalPrefs,
67 LocalServe,
68 LocalFiles,
69 TailnetKeys,
70 TailnetUsers,
71 TailnetInvites,
72 TailnetWebhooks,
73 TailnetSettings,
74 TailnetServices,
75 ])
76 .collect(),
77 Self::Full => Toolset::ALL
78 .iter()
79 .copied()
80 .filter(|t| !matches!(t, LocalDebug | LocalPassthrough))
81 .collect(),
82 }
83 }
84}
85
86impl fmt::Display for Preset {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 f.write_str(self.as_str())
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Error)]
98pub enum ConfigError {
99 #[error("unknown preset `{0}`; expected one of minimal, core, full")]
100 UnknownPreset(String),
101
102 #[error("unknown toolset `{0}`")]
103 UnknownToolset(String),
104
105 #[error(
106 "toolset list mixes replacement and adjustment: write either a bare list \
107 of toolsets, or a list where every entry begins with `+` or `-`"
108 )]
109 MixedToolsetSyntax,
110
111 #[error(
112 "the selected toolsets and tier leave no tools enabled; \
113 widen the preset, the toolsets, or the tier"
114 )]
115 NoToolsEnabled,
116
117 #[error("`{setting}` was set to `{value}`, which is not {expected}")]
118 InvalidValue {
119 setting: String,
120 value: String,
121 expected: &'static str,
122 },
123}
124
125pub fn apply_toolset_modifiers(
135 base: BTreeSet<Toolset>,
136 spec: &str,
137) -> Result<BTreeSet<Toolset>, ConfigError> {
138 let entries: Vec<&str> = spec
139 .split(',')
140 .map(str::trim)
141 .filter(|s| !s.is_empty())
142 .collect();
143 if entries.is_empty() {
144 return Ok(base);
145 }
146
147 let adjusting = entries[0].starts_with(['+', '-']);
148 if entries
149 .iter()
150 .any(|e| e.starts_with(['+', '-']) != adjusting)
151 {
152 return Err(ConfigError::MixedToolsetSyntax);
153 }
154
155 let mut selected = if adjusting { base } else { BTreeSet::new() };
156 for entry in entries {
157 let (remove, name) = match entry.strip_prefix('-') {
158 Some(name) => (true, name),
159 None => (false, entry.strip_prefix('+').unwrap_or(entry)),
160 };
161 let toolset =
162 Toolset::parse(name).ok_or_else(|| ConfigError::UnknownToolset(name.to_owned()))?;
163 if remove {
164 selected.remove(&toolset);
165 } else {
166 selected.insert(toolset);
167 }
168 }
169 Ok(selected)
170}
171
172#[derive(Debug, Clone)]
174pub struct Gate {
175 toolsets: BTreeSet<Toolset>,
176 max_tier: Tier,
177 unavailable: BTreeSet<Surface>,
181}
182
183impl Gate {
184 pub fn new(
190 toolsets: BTreeSet<Toolset>,
191 max_tier: Tier,
192 unavailable: BTreeSet<Surface>,
193 all_tools: &[ToolMeta],
194 ) -> Result<Self, ConfigError> {
195 let gate = Self {
196 toolsets,
197 max_tier,
198 unavailable,
199 };
200 if !all_tools.iter().any(|t| gate.permits(t)) {
201 return Err(ConfigError::NoToolsEnabled);
202 }
203 Ok(gate)
204 }
205
206 pub fn unchecked(
209 toolsets: BTreeSet<Toolset>,
210 max_tier: Tier,
211 unavailable: BTreeSet<Surface>,
212 ) -> Self {
213 Self {
214 toolsets,
215 max_tier,
216 unavailable,
217 }
218 }
219
220 pub fn permits(&self, tool: &ToolMeta) -> bool {
221 self.toolsets.contains(&tool.toolset)
222 && tool.tier <= self.max_tier
223 && !self.unavailable.contains(&tool.surface())
224 }
225
226 pub fn needs(&self, tool: &ToolMeta) -> String {
229 if self.unavailable.contains(&tool.surface()) {
230 return match tool.surface() {
231 Surface::Local => "a working `tailscale` binary".to_owned(),
232 Surface::Tailnet => "a control-plane credential".to_owned(),
233 };
234 }
235 let mut needs = Vec::new();
236 if !self.toolsets.contains(&tool.toolset) {
237 needs.push(format!("`--toolsets +{}`", tool.toolset));
238 }
239 if tool.tier > self.max_tier
240 && let Some(flag) = tool.tier.flag()
241 {
242 needs.push(format!("`{flag}`"));
243 }
244 if needs.is_empty() {
245 return "no additional permission".to_owned();
248 }
249 needs.join(" and ")
250 }
251
252 pub fn toolsets(&self) -> &BTreeSet<Toolset> {
253 &self.toolsets
254 }
255
256 pub fn offers(&self, surface: Surface) -> bool {
266 !self.unavailable.contains(&surface) && self.toolsets.iter().any(|t| t.surface() == surface)
267 }
268
269 pub fn offered_toolsets(&self) -> impl Iterator<Item = Toolset> + '_ {
278 self.toolsets
279 .iter()
280 .copied()
281 .filter(|toolset| self.offers(toolset.surface()))
282 }
283
284 pub fn max_tier(&self) -> Tier {
285 self.max_tier
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::meta::Toolset::*;
293
294 fn tool(name: &'static str, toolset: Toolset, tier: Tier) -> ToolMeta {
295 ToolMeta {
296 name,
297 toolset,
298 tier,
299 summary: "",
300 self_severing: false,
301 severs_local_node: false,
302 requires_confirmation: false,
303 idempotent: true,
304 varying_tier: false,
305 min_version: None,
306 platforms: None,
307 }
308 }
309
310 #[test]
311 fn a_mixed_list_is_refused_rather_than_guessed_at() {
312 assert_eq!(
313 apply_toolset_modifiers(Preset::Core.toolsets(), "local-status,-tailnet-dns"),
314 Err(ConfigError::MixedToolsetSyntax)
315 );
316 }
317
318 #[test]
319 fn an_empty_spec_leaves_the_preset_alone() {
320 let base = Preset::Core.toolsets();
321 assert_eq!(
322 apply_toolset_modifiers(base.clone(), " ,, ").expect("empty is fine"),
323 base
324 );
325 }
326
327 #[test]
328 fn the_gate_hides_by_toolset_tier_and_surface() {
329 let gate = Gate::unchecked(
330 BTreeSet::from([LocalStatus, TailnetDevices]),
331 Tier::Write,
332 BTreeSet::from([Surface::Tailnet]),
333 );
334 assert!(gate.permits(&tool("a", LocalStatus, Tier::Read)));
335 assert!(gate.permits(&tool("b", LocalStatus, Tier::Write)));
336 assert!(!gate.permits(&tool("c", LocalStatus, Tier::Destructive)));
338 assert!(!gate.permits(&tool("d", LocalServe, Tier::Read)));
340 assert!(!gate.permits(&tool("e", TailnetDevices, Tier::Read)));
342 }
343
344 #[test]
345 fn the_gate_explains_what_would_enable_a_tool() {
346 let gate = Gate::unchecked(BTreeSet::from([LocalStatus]), Tier::Read, BTreeSet::new());
347 assert_eq!(
348 gate.needs(&tool("a", LocalServe, Tier::Destructive)),
349 "`--toolsets +local-serve` and `--allow-destructive`"
350 );
351 assert_eq!(
352 gate.needs(&tool("b", LocalStatus, Tier::Write)),
353 "`--allow-write`"
354 );
355
356 let no_backend = Gate::unchecked(
357 BTreeSet::from([TailnetDevices]),
358 Tier::Read,
359 BTreeSet::from([Surface::Tailnet]),
360 );
361 assert_eq!(
362 no_backend.needs(&tool("c", TailnetDevices, Tier::Read)),
363 "a control-plane credential"
364 );
365 }
366}