use std::path::Path;
use std::time::Duration;
use serde::Serialize;
use serde_json::{Value, json};
use tailscale_cli::{Invocation, Output, SecretFile};
use crate::cli;
use crate::context::ToolContext;
use crate::error::{ErrorCode, ToolError, ToolResult};
use crate::meta::ToolMeta;
pub fn flag(name: &str, value: bool) -> String {
format!("--{name}={value}")
}
pub fn push_bool(args: &mut Vec<String>, name: &str, value: Option<bool>) {
if let Some(value) = value {
args.push(flag(name, value));
}
}
pub fn push_text(args: &mut Vec<String>, name: &str, value: Option<&str>) {
if let Some(value) = value {
args.push(format!("--{name}={value}"));
}
}
pub fn push_list(args: &mut Vec<String>, name: &str, value: Option<&[String]>) {
if let Some(values) = value {
args.push(format!("--{name}={}", values.join(",")));
}
}
pub fn note(ctx: &ToolContext, stderr: &str) -> Option<String> {
let redacted = ctx.redactor.apply(stderr);
let trimmed = redacted.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
pub fn pretty(value: &serde_json::Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
}
pub fn printed(ctx: &ToolContext, output: &Output) -> Option<String> {
let stdout = output.stdout_str();
let joined = [stdout.as_ref(), output.stderr.as_str()]
.iter()
.map(|part| part.trim())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("\n");
note(ctx, &joined)
}
const GRACE: u64 = 5;
pub fn bounded_wait(requested: Option<u64>, default: u64, longest: u64) -> (u64, Duration) {
let seconds = requested.unwrap_or(default).clamp(1, longest);
(seconds, Duration::from_secs(seconds + GRACE))
}
pub fn lines(text: &str) -> impl DoubleEndedIterator<Item = &str> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
}
pub fn tokens_with_prefix(text: &str, prefixes: &[&str]) -> Vec<String> {
text.split_whitespace()
.filter(|word| prefixes.iter().any(|prefix| word.starts_with(prefix)))
.map(|word| word.trim_end_matches(['.', ',']).to_owned())
.collect()
}
pub fn find_url(text: &str) -> Option<String> {
tokens_with_prefix(text, &["https://", "http://"])
.into_iter()
.next()
}
pub fn real_path(ctx: &ToolContext, what: &str, path: &str) -> ToolResult<String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(ToolError::invalid_args(format!("`{what}` cannot be empty")));
}
if trimmed == "-" {
return Err(ToolError::invalid_args(format!(
"`{what}` has to be a path on this machine; `-` means a stream, which a tool call has none of"
)));
}
if !ctx.paths.permits(Path::new(trimmed)) {
return Err(ToolError::new(
ErrorCode::NotPermitted,
format!("`{what}` is outside the paths this server may use"),
)
.with_hint("Name a path under one of the server's configured roots."));
}
Ok(trimmed.to_owned())
}
pub fn secret_value(what: &str, value: &str) -> ToolResult<(String, Option<SecretFile>)> {
if value.starts_with("file:") {
return Ok((value.to_owned(), None));
}
let file = SecretFile::new(value).map_err(|e| {
ToolError::new(
ErrorCode::CliFailed,
format!("the {what} could not be written to a private file: {e}"),
)
})?;
Ok((file.arg(), Some(file)))
}
pub fn only_on(setting: &str, platforms: &[&str]) -> ToolResult<()> {
if platforms.contains(&std::env::consts::OS) {
return Ok(());
}
Err(ToolError::new(
ErrorCode::UnsupportedPlatform,
format!(
"`{setting}` is a {} preference, and this node runs {}",
platforms.join(" or "),
std::env::consts::OS
),
))
}
pub fn path_segment(what: &str, value: &str) -> ToolResult<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ToolError::invalid_args(format!("`{what}` cannot be empty")));
}
let allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '@');
if let Some(bad) = trimmed.chars().find(|c| !allowed(*c)) {
return Err(ToolError::invalid_args(format!(
"`{what}` contains {bad:?}, which cannot appear in an identifier"
)));
}
if trimmed.chars().all(|c| c == '.') {
return Err(ToolError::invalid_args(format!(
"`{what}` is `{trimmed}`, which is not an identifier"
)));
}
Ok(trimmed.to_owned())
}
#[derive(Debug, Serialize)]
pub struct Done {
done: &'static str,
#[serde(flatten)]
about: serde_json::Map<String, Value>,
}
impl Done {
pub fn new(done: &'static str) -> Self {
Self {
done,
about: serde_json::Map::new(),
}
}
pub fn about(mut self, name: &str, value: impl Into<Value>) -> Self {
self.about.insert(name.to_owned(), value.into());
self
}
}
pub fn one_of(what: &str, value: &str, allowed: &[&str]) -> ToolResult<String> {
if allowed.contains(&value) {
return Ok(value.to_owned());
}
Err(ToolError::invalid_args(format!(
"`{what}` is one of {}; `{value}` is none of them",
allowed.join(", ")
)))
}
pub fn each_present(what: &str, given: Vec<String>) -> ToolResult<Vec<String>> {
given
.into_iter()
.map(|entry| {
let trimmed = entry.trim();
if trimmed.is_empty() {
return Err(ToolError::invalid_args(format!(
"`{what}` has an empty entry; send `[]` to remove everything"
)));
}
Ok(trimmed.to_owned())
})
.collect()
}
pub fn answered_or(answer: Value, otherwise: Done) -> ToolResult<Value> {
match answer {
Value::Null => report(otherwise),
answer => Ok(answer),
}
}
pub fn report(value: impl Serialize) -> ToolResult<Value> {
serde_json::to_value(value).map_err(|e| {
ToolError::new(
ErrorCode::CliFailed,
format!("the report did not build: {e}"),
)
})
}
pub async fn document(
ctx: &ToolContext,
meta: &ToolMeta,
invocation: Invocation,
) -> ToolResult<Value> {
let display = invocation.display();
let output = cli::run_tolerant(ctx, meta, invocation).await?;
let stdout = output.stdout_str();
match serde_json::from_str::<Value>(stdout.trim()) {
Ok(value) => Ok(value),
Err(_) if !output.success() => Err(cli::command_failure(ctx, meta, &display, &output)),
Err(e) => Err(ToolError::new(
ErrorCode::CliFailed,
format!("`{display}` did not print JSON: {e}"),
)),
}
}
pub async fn object(
ctx: &ToolContext,
meta: &ToolMeta,
invocation: Invocation,
) -> ToolResult<Value> {
let value = document(ctx, meta, invocation).await?;
Ok(if value.is_object() {
value
} else {
json!({ "document": value })
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Excluded {
pub path: &'static str,
pub reason: &'static str,
}
pub fn require_destructive(ctx: &ToolContext, what: &str) -> ToolResult<()> {
if ctx.max_tier < crate::meta::Tier::Destructive {
return Err(ToolError::not_permitted(what, "--allow-destructive"));
}
Ok(())
}
#[derive(Debug, Default, serde::Deserialize, rmcp::schemars::JsonSchema)]
pub struct SelfConfirmation {
#[serde(default)]
pub confirm: Option<bool>,
}
pub async fn not_at_ourselves(
ctx: &ToolContext,
what: &str,
target: &str,
confirmation: &SelfConfirmation,
) -> ToolResult<()> {
if confirmation.confirm == Some(true) || !ctx.names_us(target).await {
return Ok(());
}
Err(ToolError::new(
ErrorCode::ConfirmationRequired,
format!(
"`{target}` is the device this server runs on, so {what} can cut this \
session off from it, and this call did not say it meant to"
),
)
.with_hint("Pass `confirm: true` to do it anyway."))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_dot_segment_is_not_an_identifier() {
for bad in [".", "..", "...", " .. "] {
let error = path_segment("device_id", bad).expect_err("a dot segment");
assert_eq!(
serde_json::to_value(&error).expect("reportable")["code"],
serde_json::json!("invalid_args"),
"{bad:?}"
);
}
}
#[test]
fn an_identifier_containing_a_dot_is_still_an_identifier() {
for good in [
"n1111111CNTRL",
"123456789",
"laptop.example-tailnet.ts.net",
"custom:a.b",
] {
assert_eq!(
path_segment("device_id", good).expect("a valid identifier"),
good
);
}
}
}