Skip to main content

tailscale_mcp/tools/
local_files.rs

1//! The tools that touch the local filesystem.
2//!
3//! Everything else on the local surface reads or writes state inside
4//! `tailscaled`. Nine of the eleven here read and write *files on the local
5//! filesystem*: they send files to a peer and take files out of the Taildrop
6//! inbox, write a TLS certificate and a metrics dump to paths the caller names,
7//! point `kubectl` at a cluster, and share a directory over Taildrive. Each of
8//! those descriptions says so, because the caller is choosing a path on a
9//! machine it cannot see. The other two touch no file: `file_targets` lists the
10//! peers a file can go to, and `syspolicy_reload` rereads settings the node
11//! already has.
12//!
13//! Paths are taken as the caller gives them, at the write tier and no higher:
14//! the tier is what confines them in this release, so a read-only session
15//! reaches none of this. The allow-list that would confine them to a configured
16//! root is [`PathPolicy`](crate::context::PathPolicy), which every path here is
17//! already checked against and which ships as `Unrestricted`; switching it on
18//! is a matter of populating it rather than of finding the places that must ask.
19//!
20//! Two rules the certificate tool exists to enforce. Naming neither output path
21//! makes the client write `DOMAIN.crt` and `DOMAIN.key` into whatever directory
22//! it happens to be in, so both paths are required here. Naming `-` makes it
23//! write the certificate — and the private key — to standard output, which
24//! would put key material into the answer, the transcript and the log, so `-`
25//! is refused before anything runs.
26//!
27//! Taildrop and ACME are both slow by nature, so `tailscale_file_cp` and
28//! `tailscale_cert` carry the longer budgets agreed in DECISIONS Q29 rather
29//! than the ordinary 30 seconds.
30
31use std::time::Duration;
32
33use rmcp::schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36use tailscale_cli::{Invocation, Output};
37
38use crate::cli;
39use crate::context::ToolContext;
40use crate::error::{ErrorCode, ToolError, ToolResult};
41use crate::meta::ToolMeta;
42use crate::tools::common::{
43    bounded_wait, flag, note, object, printed, push_bool, push_text, real_path, report,
44};
45
46crate::tools! {
47    /// Send files from **the local filesystem** to another node over
48    /// Taildrop. The files are read from the paths given here; the peer takes
49    /// delivery with `tailscale_file_get` on its own machine. Transfers are
50    /// slow, so this call allows several minutes.
51    tailscale_file_cp => FileCpParams, file_cp,
52        toolset: LocalFiles, tier: Write;
53
54    /// List the nodes this one may send files to with `tailscale_file_cp`,
55    /// with the address and name to use for each. Reads only.
56    tailscale_file_targets => NoParams, file_targets,
57        toolset: LocalFiles, tier: Read, idempotent: true;
58
59    /// Move files waiting in this node's Taildrop inbox into a directory on
60    /// **the local filesystem**, emptying the inbox. Returns straight away
61    /// whether or not anything was waiting: it never blocks for a file to
62    /// arrive and never runs on in a loop.
63    tailscale_file_get => FileGetParams, file_get,
64        toolset: LocalFiles, tier: Write;
65
66    /// Obtain a TLS certificate for a name in this tailnet and **write it to
67    /// two files on the local filesystem**. Both output paths are required, and neither
68    /// may be `-`: the private key is never printed into the answer. Issuance
69    /// talks to a certificate authority, so this call allows a minute or two.
70    tailscale_cert => CertParams, cert,
71        toolset: LocalFiles, tier: Write;
72
73    /// Write this node's client metrics, in Prometheus text format, to a file
74    /// on **the local filesystem** — the form a node exporter's textfile
75    /// collector reads. Use `tailscale_metrics_print` to read the same numbers
76    /// without writing anything.
77    tailscale_metrics_write => MetricsWriteParams, metrics_write,
78        toolset: LocalFiles, tier: Write, idempotent: true, since: "1.78";
79
80    /// Add a context to the **local kubectl configuration file** for a Kubernetes
81    /// cluster reached through a Tailscale auth proxy running on the named
82    /// peer. Alpha in the client, and it edits the kubeconfig in place.
83    tailscale_configure_kubeconfig => KubeconfigParams, configure_kubeconfig,
84        toolset: LocalFiles, tier: Write, idempotent: true;
85
86    /// Reload the local node's MDM and system policy settings even when nothing has
87    /// changed, and report what the reload produced. Use
88    /// `tailscale_syspolicy_list` to read the settings without reloading.
89    tailscale_syspolicy_reload => NoParams, syspolicy_reload,
90        toolset: LocalFiles, tier: Write, idempotent: true, since: "1.72";
91
92    /// List the directories on **the local filesystem** that Taildrive shares with the
93    /// tailnet, with the local user each is served as. Reads only.
94    tailscale_drive_list => NoParams, drive_list,
95        toolset: LocalFiles, tier: Read, idempotent: true;
96
97    /// Share a directory on **the local filesystem** with the tailnet over
98    /// Taildrive, under a name peers will see. Sharing the same name and path
99    /// again changes nothing.
100    tailscale_drive_share => DriveShareParams, drive_share,
101        toolset: LocalFiles, tier: Write, idempotent: true;
102
103    /// Rename an existing Taildrive share. Peers see the new name; the
104    /// directory on the local filesystem is untouched.
105    tailscale_drive_rename => DriveRenameParams, drive_rename,
106        toolset: LocalFiles, tier: Write;
107
108    /// Stop sharing a directory over Taildrive. Every peer loses access at
109    /// once; the files stay on disk. Restoring it needs
110    /// `tailscale_drive_share` and the original path.
111    tailscale_drive_unshare => DriveUnshareParams, drive_unshare,
112        toolset: LocalFiles, tier: Destructive;
113}
114
115// ---------------------------------------------------------------------------
116// Budgets
117// ---------------------------------------------------------------------------
118
119/// How long a Taildrop transfer may run before the call gives up. Far above
120/// the ordinary budget, because a large file over a relayed path takes minutes
121/// (DECISIONS Q29).
122const DEFAULT_TRANSFER_TIMEOUT: u64 = 300;
123
124/// How long certificate issuance may run. Shorter than a transfer: an ACME
125/// exchange that has not finished in two minutes has gone wrong rather than
126/// slow.
127const DEFAULT_CERT_TIMEOUT: u64 = 120;
128
129/// The most either of them may be asked to wait for.
130const MAX_LONG_TIMEOUT: u64 = 600;
131
132/// Bound a caller's wait against the cap these longer commands share.
133fn budget(requested: Option<u64>, default: u64) -> (u64, Duration) {
134    bounded_wait(requested, default, MAX_LONG_TIMEOUT)
135}
136
137/// Repainting a progress line is for a terminal. Nothing here has one, and the
138/// escape sequences would be the bulk of what the caller read back, so the
139/// display is switched off rather than captured.
140const NO_PROGRESS: &str = "--update-interval=0";
141
142/// What the client prints when it can talk to Taildrive but this build does
143/// not let the command line configure it — the macOS GUI packaging, where the
144/// application owns the share list.
145const NO_TAILDRIVE: &str = "Taildrive CLI commands are not supported";
146
147/// A tool that takes nothing.
148#[derive(Debug, Deserialize, JsonSchema)]
149pub struct NoParams {}
150
151// ---------------------------------------------------------------------------
152// Parameters
153// ---------------------------------------------------------------------------
154
155#[derive(Debug, Default, Deserialize, JsonSchema)]
156pub struct FileCpParams {
157    /// Paths on the local filesystem to send. At least one, and none may be `-`:
158    /// a tool call has no standard input to read a file from.
159    pub files: Vec<String>,
160    /// The node to send to, by name, FQDN or Tailscale IP, as
161    /// `tailscale_file_targets` reports it. A trailing colon is added if it is
162    /// missing.
163    pub target: String,
164    /// Deliver under this name instead of the name on disk.
165    #[serde(default)]
166    pub name: Option<String>,
167    /// Report each file as it goes.
168    #[serde(default)]
169    pub verbose: Option<bool>,
170    /// How long to allow, in seconds. Default 300, capped at 600.
171    #[serde(default)]
172    pub timeout_seconds: Option<u64>,
173}
174
175/// What to do about a file in the inbox whose name is already taken in the
176/// target directory.
177#[derive(Debug, Default, Clone, Copy, Deserialize, JsonSchema)]
178#[serde(rename_all = "snake_case")]
179pub enum Conflict {
180    /// Leave the conflicting file in the inbox and report it, taking every
181    /// other file. The client's own default, and the only one that destroys
182    /// nothing.
183    #[default]
184    Skip,
185    /// Replace the file already in the directory.
186    Overwrite,
187    /// Write to a new name with a number appended.
188    Rename,
189}
190
191impl Conflict {
192    const fn as_str(self) -> &'static str {
193        match self {
194            Self::Skip => "skip",
195            Self::Overwrite => "overwrite",
196            Self::Rename => "rename",
197        }
198    }
199}
200
201#[derive(Debug, Default, Deserialize, JsonSchema)]
202pub struct FileGetParams {
203    /// The directory on the local filesystem to move the waiting files into. It has to
204    /// exist already.
205    pub directory: String,
206    /// What to do about a name that is already taken there. Defaults to
207    /// leaving the conflicting file in the inbox.
208    #[serde(default)]
209    pub conflict: Conflict,
210    /// Report each file as it is moved.
211    #[serde(default)]
212    pub verbose: Option<bool>,
213}
214
215#[derive(Debug, Default, Deserialize, JsonSchema)]
216pub struct CertParams {
217    /// The name to certify. It has to be a name in this tailnet, with HTTPS
218    /// enabled for the tailnet; `tailscale_status` reports this node's own.
219    pub domain: String,
220    /// Where on the local filesystem to write the certificate. Required, and `-` is
221    /// refused.
222    pub cert_file: String,
223    /// Where on the local filesystem to write the private key. Required, and `-` is
224    /// refused: a key printed to standard output would end up in the answer.
225    pub key_file: String,
226    /// Renew if the existing certificate has less than this long to run, in
227    /// seconds. Omitted means take whatever lifetime the authority gives.
228    #[serde(default)]
229    pub min_validity_seconds: Option<u64>,
230    /// How long to allow, in seconds. Default 120, capped at 600.
231    #[serde(default)]
232    pub timeout_seconds: Option<u64>,
233}
234
235#[derive(Debug, Default, Deserialize, JsonSchema)]
236pub struct MetricsWriteParams {
237    /// The file on the local filesystem to write the metrics to. It is replaced, not
238    /// appended to.
239    pub path: String,
240}
241
242#[derive(Debug, Default, Deserialize, JsonSchema)]
243pub struct KubeconfigParams {
244    /// The Tailscale hostname or FQDN of the peer running the auth proxy.
245    pub hostname: String,
246    /// Reach the proxy over plain HTTP. Ignored when the hostname already
247    /// carries a scheme.
248    #[serde(default)]
249    pub http: Option<bool>,
250}
251
252#[derive(Debug, Default, Deserialize, JsonSchema)]
253pub struct DriveShareParams {
254    /// The name peers will see the share under.
255    pub name: String,
256    /// The directory on the local filesystem to share.
257    pub path: String,
258}
259
260#[derive(Debug, Default, Deserialize, JsonSchema)]
261pub struct DriveRenameParams {
262    /// The share's current name.
263    pub name: String,
264    /// The name to give it.
265    pub new_name: String,
266}
267
268#[derive(Debug, Default, Deserialize, JsonSchema)]
269pub struct DriveUnshareParams {
270    /// The name of the share to withdraw.
271    pub name: String,
272}
273
274// ---------------------------------------------------------------------------
275// Reports
276// ---------------------------------------------------------------------------
277
278/// The answer to a call that sent files somewhere.
279#[derive(Debug, Serialize, JsonSchema)]
280pub struct TransferReport {
281    /// The node the files went to, as the client was asked for it.
282    pub target: String,
283    /// The paths that were sent.
284    pub files: Vec<String>,
285    /// The name they were delivered under, when it was not the name on disk.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub delivered_as: Option<String>,
288    /// How long the call was prepared to wait, after bounding.
289    pub timeout_seconds: u64,
290    /// Anything the client said while transferring.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub printed: Option<String>,
293}
294
295/// One node this one may send files to.
296#[derive(Debug, Serialize, JsonSchema)]
297pub struct Target {
298    /// The Tailscale address to send to.
299    pub address: String,
300    /// The node's name.
301    pub hostname: String,
302    /// What the client noted about it, such as how long it has been offline.
303    /// Absent for a node that is up.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub status: Option<String>,
306}
307
308#[derive(Debug, Serialize, JsonSchema)]
309pub struct TargetsReport {
310    /// Every node that would accept a file from this one.
311    pub targets: Vec<Target>,
312}
313
314/// The answer to emptying the inbox.
315#[derive(Debug, Serialize, JsonSchema)]
316pub struct InboxReport {
317    /// Where the files were moved to.
318    pub directory: String,
319    /// The rule that was applied to names already taken there.
320    pub conflict: String,
321    /// What the client said, which is where the file names appear. Absent when
322    /// the inbox was empty, which is not an error.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub printed: Option<String>,
325}
326
327/// The answer to issuing a certificate. The material itself is on disk and is
328/// deliberately not here.
329#[derive(Debug, Serialize, JsonSchema)]
330pub struct CertificateReport {
331    /// The name that was certified.
332    pub domain: String,
333    /// Where the certificate was written.
334    pub cert_file: String,
335    /// Where the private key was written. Its contents are never reported.
336    pub key_file: String,
337    /// How long the call was prepared to wait, after bounding.
338    pub timeout_seconds: u64,
339    /// Anything the client said.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub printed: Option<String>,
342}
343
344/// The answer to a call that did one thing to one named subject.
345#[derive(Debug, Serialize, JsonSchema)]
346pub struct OutcomeReport {
347    /// What was acted on: a path, a hostname, a share name.
348    pub subject: String,
349    /// What was done to it.
350    pub outcome: String,
351    /// Anything the client said.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub note: Option<String>,
354}
355
356/// One directory shared from the local filesystem over Taildrive.
357#[derive(Debug, Serialize, JsonSchema)]
358pub struct Share {
359    /// The name peers see.
360    pub name: String,
361    /// The directory on the local filesystem.
362    pub path: String,
363    /// The local user the share is served as.
364    pub as_user: String,
365}
366
367#[derive(Debug, Serialize, JsonSchema)]
368pub struct SharesReport {
369    /// Every directory shared over Taildrive, empty when none are.
370    pub shares: Vec<Share>,
371}
372
373// ---------------------------------------------------------------------------
374// Argument helpers
375// ---------------------------------------------------------------------------
376
377/// The trailing colon `file cp` insists on, added when the caller left it off.
378///
379/// The client refuses a target without one — "final argument to 'tailscale file
380/// cp' must end in colon" — which is a syntax detail of the command line rather
381/// than anything about the node, so it is settled here.
382fn transfer_target(target: &str) -> ToolResult<String> {
383    let trimmed = target.trim();
384    if trimmed.is_empty() || trimmed == ":" {
385        return Err(ToolError::invalid_args("`target` has to name a node"));
386    }
387    Ok(if trimmed.ends_with(':') {
388        trimmed.to_owned()
389    } else {
390        format!("{trimmed}:")
391    })
392}
393
394/// Run a Taildrive command, telling "this build cannot" apart from "it failed".
395///
396/// The macOS GUI packaging carries the subcommands and refuses them, because
397/// the application owns the share list. That is a fact about the build rather
398/// than about the request, so it is reported as `unsupported_platform` and not
399/// as a command failure. It is detected in the client's own words rather than
400/// declared as a platform gate on the tool, because a Mac running the
401/// `tailscaled` packaging supports every one of these (DECISIONS Q31).
402async fn drive(ctx: &ToolContext, meta: &ToolMeta, invocation: Invocation) -> ToolResult<Output> {
403    let display = invocation.display();
404    let output = cli::run_tolerant(ctx, meta, invocation).await?;
405    if output.success() {
406        return Ok(output);
407    }
408    let said = ctx.redactor.apply(&output.stderr);
409    if said.contains(NO_TAILDRIVE) {
410        return Err(ToolError::new(
411            ErrorCode::UnsupportedPlatform,
412            format!(
413                "Taildrive is not configurable through the client on this node: {}",
414                said.trim()
415            ),
416        )
417        .with_hint("Configure Taildrive in the Tailscale application on this node instead."));
418    }
419    Err(cli::command_failure(ctx, meta, &display, &output))
420}
421
422// ---------------------------------------------------------------------------
423// Taildrop
424// ---------------------------------------------------------------------------
425
426async fn file_cp(ctx: &ToolContext, params: FileCpParams) -> ToolResult<Value> {
427    let meta = &metas::tailscale_file_cp;
428    if params.files.is_empty() {
429        return Err(ToolError::invalid_args("`files` needs at least one path"));
430    }
431    let files = params
432        .files
433        .iter()
434        .map(|file| real_path(ctx, "files", file))
435        .collect::<ToolResult<Vec<String>>>()?;
436    let target = transfer_target(&params.target)?;
437    let (seconds, timeout) = budget(params.timeout_seconds, DEFAULT_TRANSFER_TIMEOUT);
438
439    let mut args = vec!["file".to_owned(), "cp".to_owned(), NO_PROGRESS.to_owned()];
440    push_text(&mut args, "name", params.name.as_deref());
441    push_bool(&mut args, "verbose", params.verbose);
442    // Last, because Go stops reading flags at the first positional, and the
443    // target has to be the very last of those.
444    args.extend(files.iter().cloned());
445    args.push(target.clone());
446
447    let output = cli::run(
448        ctx,
449        meta,
450        Invocation::mutate_shared(args).with_timeout(timeout),
451    )
452    .await?;
453    report(TransferReport {
454        target,
455        files,
456        delivered_as: params.name,
457        timeout_seconds: seconds,
458        printed: printed(ctx, &output),
459    })
460}
461
462async fn file_targets(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
463    let meta = &metas::tailscale_file_targets;
464    let text = cli::run_text(
465        ctx,
466        meta,
467        Invocation::read(["file", "cp", &flag("targets", true)]),
468    )
469    .await?;
470    report(TargetsReport {
471        targets: parse_targets(&text),
472    })
473}
474
475/// The tab-separated table `file cp --targets` prints: address, name, and for
476/// a node that is not up, a note saying so.
477fn parse_targets(text: &str) -> Vec<Target> {
478    text.lines()
479        .map(str::trim_end)
480        .filter(|line| !line.trim().is_empty())
481        .filter_map(|line| {
482            let mut columns = line.split('\t').map(str::trim);
483            let address = columns.next()?;
484            let hostname = columns.next().unwrap_or_default();
485            let status = columns.next().filter(|note| !note.is_empty());
486            Some(Target {
487                address: address.to_owned(),
488                hostname: hostname.to_owned(),
489                status: status.map(str::to_owned),
490            })
491        })
492        .collect()
493}
494
495async fn file_get(ctx: &ToolContext, params: FileGetParams) -> ToolResult<Value> {
496    let meta = &metas::tailscale_file_get;
497    let directory = real_path(ctx, "directory", &params.directory)?;
498
499    let args = vec![
500        "file".to_owned(),
501        "get".to_owned(),
502        format!("--conflict={}", params.conflict.as_str()),
503        // Named rather than left to their defaults, so that the promise the
504        // tool description makes — this call never blocks and never runs on —
505        // is visible in what was actually run.
506        flag("wait", false),
507        flag("loop", false),
508        flag("verbose", params.verbose.unwrap_or(false)),
509        directory.clone(),
510    ];
511
512    let output = cli::run(ctx, meta, Invocation::mutate_shared(args)).await?;
513    report(InboxReport {
514        directory,
515        conflict: params.conflict.as_str().to_owned(),
516        printed: printed(ctx, &output),
517    })
518}
519
520// ---------------------------------------------------------------------------
521// Certificates
522// ---------------------------------------------------------------------------
523
524async fn cert(ctx: &ToolContext, params: CertParams) -> ToolResult<Value> {
525    let meta = &metas::tailscale_cert;
526    let cert_file = real_path(ctx, "cert_file", &params.cert_file)?;
527    let key_file = real_path(ctx, "key_file", &params.key_file)?;
528    if params.domain.trim().is_empty() {
529        return Err(ToolError::invalid_args("`domain` cannot be empty"));
530    }
531    let (seconds, timeout) = budget(params.timeout_seconds, DEFAULT_CERT_TIMEOUT);
532
533    let mut args = vec![
534        "cert".to_owned(),
535        format!("--cert-file={cert_file}"),
536        format!("--key-file={key_file}"),
537    ];
538    if let Some(validity) = params.min_validity_seconds {
539        args.push(format!("--min-validity={validity}s"));
540    }
541    args.push(params.domain.clone());
542
543    let output = cli::run(
544        ctx,
545        meta,
546        Invocation::mutate_shared(args).with_timeout(timeout),
547    )
548    .await?;
549    report(CertificateReport {
550        domain: params.domain,
551        cert_file,
552        key_file,
553        timeout_seconds: seconds,
554        printed: printed(ctx, &output),
555    })
556}
557
558// ---------------------------------------------------------------------------
559// Host configuration
560// ---------------------------------------------------------------------------
561
562async fn metrics_write(ctx: &ToolContext, params: MetricsWriteParams) -> ToolResult<Value> {
563    let meta = &metas::tailscale_metrics_write;
564    let path = real_path(ctx, "path", &params.path)?;
565    let output = cli::run(
566        ctx,
567        meta,
568        Invocation::mutate_shared(["metrics", "write", &path]),
569    )
570    .await?;
571    report(OutcomeReport {
572        subject: path,
573        outcome: "client metrics written in Prometheus text format".to_owned(),
574        note: note(ctx, &output.stderr),
575    })
576}
577
578async fn configure_kubeconfig(ctx: &ToolContext, params: KubeconfigParams) -> ToolResult<Value> {
579    let meta = &metas::tailscale_configure_kubeconfig;
580    if params.hostname.trim().is_empty() {
581        return Err(ToolError::invalid_args("`hostname` cannot be empty"));
582    }
583    let mut args = vec!["configure".to_owned(), "kubeconfig".to_owned()];
584    push_bool(&mut args, "http", params.http);
585    args.push(params.hostname.clone());
586
587    let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
588    report(OutcomeReport {
589        subject: params.hostname,
590        outcome: "added to the local kubectl configuration".to_owned(),
591        note: printed(ctx, &output),
592    })
593}
594
595async fn syspolicy_reload(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
596    let meta = &metas::tailscale_syspolicy_reload;
597    object(
598        ctx,
599        meta,
600        Invocation::mutate(["syspolicy", "reload", &flag("json", true)]),
601    )
602    .await
603}
604
605// ---------------------------------------------------------------------------
606// Taildrive
607// ---------------------------------------------------------------------------
608
609async fn drive_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
610    let meta = &metas::tailscale_drive_list;
611    let output = drive(ctx, meta, Invocation::read(["drive", "list"])).await?;
612    report(SharesReport {
613        shares: parse_shares(&output.stdout_str()),
614    })
615}
616
617/// Where each column of a padded table starts, counted in characters.
618///
619/// A column begins wherever a run of spaces ends, which is what the header row
620/// of `drive list` describes for every row beneath it.
621fn column_starts(header: &str) -> Vec<usize> {
622    let mut starts = Vec::new();
623    let mut previous = ' ';
624    for (index, character) in header.chars().enumerate() {
625        if previous == ' ' && character != ' ' {
626            starts.push(index);
627        }
628        previous = character;
629    }
630    starts
631}
632
633/// Cut a row at the offsets its header established and trim the padding off.
634///
635/// Offsets are counted in characters, not bytes, because Go pads these columns
636/// by rune count. A cell the row is too short to reach is empty rather than
637/// missing, which is how a share with no `as` user reads.
638fn cells(row: &str, starts: &[usize]) -> Vec<String> {
639    let characters: Vec<char> = row.chars().collect();
640    starts
641        .iter()
642        .enumerate()
643        .map(|(nth, &from)| {
644            let to = starts.get(nth + 1).copied().unwrap_or(characters.len());
645            let cell: String = characters
646                .get(from..to.min(characters.len()))
647                .unwrap_or_default()
648                .iter()
649                .collect();
650            cell.trim().to_owned()
651        })
652        .collect()
653}
654
655/// The padded table `drive list` prints: a `name path as` header, a row of
656/// dashes, then one row per share.
657///
658/// Every row is cut at the offsets the header gives rather than split on
659/// whitespace, because splitting cannot tell a column gap from a path that
660/// contains spaces, and because it silently loses any row that does not yield
661/// the expected number of columns — which is every row on a platform that
662/// cannot share as another user, where the last column is blank.
663fn parse_shares(text: &str) -> Vec<Share> {
664    let mut rows = text.lines().filter(|line| !line.trim().is_empty());
665    // No header means no table: an empty listing and a missing one look the
666    // same, which is what a caller can act on either way.
667    let Some(header) = rows.next() else {
668        return Vec::new();
669    };
670    let starts = column_starts(header);
671    rows
672        // The dashes under the header, dropped by what the row is rather than
673        // by counting, so that a share is never dropped for being first.
674        .filter(|row| !row.chars().all(|c| c == '-' || c == ' '))
675        .map(|row| {
676            let cells = cells(row, &starts);
677            Share {
678                name: cells.first().cloned().unwrap_or_default(),
679                path: cells.get(1).cloned().unwrap_or_default(),
680                as_user: cells.get(2).cloned().unwrap_or_default(),
681            }
682        })
683        .filter(|share| !share.name.is_empty())
684        .collect()
685}
686
687async fn drive_share(ctx: &ToolContext, params: DriveShareParams) -> ToolResult<Value> {
688    let meta = &metas::tailscale_drive_share;
689    let path = real_path(ctx, "path", &params.path)?;
690    if params.name.trim().is_empty() {
691        return Err(ToolError::invalid_args("`name` cannot be empty"));
692    }
693    let output = drive(
694        ctx,
695        meta,
696        Invocation::mutate(["drive", "share", &params.name, &path]),
697    )
698    .await?;
699    report(OutcomeReport {
700        subject: params.name,
701        outcome: format!("`{path}` shared with the tailnet over Taildrive"),
702        note: printed(ctx, &output),
703    })
704}
705
706async fn drive_rename(ctx: &ToolContext, params: DriveRenameParams) -> ToolResult<Value> {
707    let meta = &metas::tailscale_drive_rename;
708    if params.name.trim().is_empty() || params.new_name.trim().is_empty() {
709        return Err(ToolError::invalid_args(
710            "`name` and `new_name` both have to be given",
711        ));
712    }
713    let output = drive(
714        ctx,
715        meta,
716        Invocation::mutate(["drive", "rename", &params.name, &params.new_name]),
717    )
718    .await?;
719    report(OutcomeReport {
720        subject: params.name,
721        outcome: format!("renamed to `{}`", params.new_name),
722        note: printed(ctx, &output),
723    })
724}
725
726async fn drive_unshare(ctx: &ToolContext, params: DriveUnshareParams) -> ToolResult<Value> {
727    let meta = &metas::tailscale_drive_unshare;
728    if params.name.trim().is_empty() {
729        return Err(ToolError::invalid_args("`name` cannot be empty"));
730    }
731    let output = drive(
732        ctx,
733        meta,
734        Invocation::mutate(["drive", "unshare", &params.name]),
735    )
736    .await?;
737    report(OutcomeReport {
738        subject: params.name,
739        outcome: "no longer shared over Taildrive; the files are untouched".to_owned(),
740        note: printed(ctx, &output),
741    })
742}
743
744#[cfg(test)]
745mod tests {
746    use std::path::PathBuf;
747    use std::sync::Arc;
748
749    use serde_json::json;
750
751    use super::*;
752    use crate::context::PathPolicy;
753
754    use crate::meta::Tier;
755    use crate::testing::{Reply, StubBackend, context};
756
757    /// What `file cp --targets` prints, with this tailnet's own names replaced
758    /// by documentation ones.
759    const TARGETS: &str = "100.64.0.2\tworkstation\n\
760        100.64.0.3\tlaptop\toffline; last seen 66h38m0s ago\n";
761
762    /// What `drive list` prints, padded the way the client pads it.
763    const SHARES: &str = "name       path                  as\n\
764        ----       ----                  --\n\
765        docs       /srv/shared docs      alice\n\
766        photos     /srv/photos           alice\n";
767
768    /// Run a handler against a scripted client and report both what it answered
769    /// and what it ran.
770    async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
771    where
772        F: FnOnce(ToolContext, P) -> Fut,
773        Fut: Future<Output = ToolResult<Value>>,
774    {
775        let backend = Arc::new(StubBackend::always(reply));
776        let ctx = context(Arc::clone(&backend));
777        let value = handler(ctx, params).await.expect("the handler succeeds");
778        (value, backend.argv())
779    }
780
781    /// The same for a call that should be refused, which is only interesting
782    /// alongside the fact that nothing was run.
783    async fn refused<F, P, Fut>(handler: F, params: P) -> ToolError
784    where
785        F: FnOnce(ToolContext, P) -> Fut,
786        Fut: Future<Output = ToolResult<Value>>,
787    {
788        let backend = Arc::new(StubBackend::always(Reply::ok("")));
789        let ctx = context(Arc::clone(&backend));
790        let error = handler(ctx, params).await.expect_err("the handler refuses");
791        assert!(
792            backend.argv().is_empty(),
793            "nothing should have run: {:?}",
794            backend.argv()
795        );
796        error
797    }
798
799    /// The argument list of the single command a handler ran.
800    fn only(argv: &[Vec<String>]) -> &[String] {
801        assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
802        &argv[0]
803    }
804
805    // -- Taildrop ------------------------------------------------------------
806
807    #[tokio::test]
808    async fn sending_a_file_puts_the_target_last_and_gives_it_a_colon() {
809        let (answer, argv) = against(
810            Reply::ok("workstation.crt: 1.2 kB\n"),
811            |ctx, p| async move { file_cp(&ctx, p).await },
812            FileCpParams {
813                files: vec!["/tmp/notes.txt".to_owned()],
814                target: "laptop".to_owned(),
815                ..FileCpParams::default()
816            },
817        )
818        .await;
819        assert_eq!(
820            only(&argv),
821            [
822                "file",
823                "cp",
824                "--update-interval=0",
825                "/tmp/notes.txt",
826                "laptop:"
827            ]
828        );
829        assert_eq!(answer["target"], json!("laptop:"));
830        assert_eq!(answer["timeout_seconds"], json!(DEFAULT_TRANSFER_TIMEOUT));
831    }
832
833    #[tokio::test]
834    async fn a_target_that_already_ends_in_a_colon_is_left_alone() {
835        let (_, argv) = against(
836            Reply::ok(""),
837            |ctx, p| async move { file_cp(&ctx, p).await },
838            FileCpParams {
839                files: vec!["/tmp/notes.txt".to_owned()],
840                target: "laptop:".to_owned(),
841                ..FileCpParams::default()
842            },
843        )
844        .await;
845        assert_eq!(only(&argv).last().expect("a target"), "laptop:");
846    }
847
848    #[tokio::test]
849    async fn every_flag_precedes_every_path() {
850        let (_, argv) = against(
851            Reply::ok(""),
852            |ctx, p| async move { file_cp(&ctx, p).await },
853            FileCpParams {
854                files: vec!["/tmp/a".to_owned(), "/tmp/b".to_owned()],
855                target: "laptop".to_owned(),
856                name: Some("bundle".to_owned()),
857                verbose: Some(true),
858                ..FileCpParams::default()
859            },
860        )
861        .await;
862        let args = only(&argv);
863        let first_path = args
864            .iter()
865            .position(|arg| arg == "/tmp/a")
866            .expect("the first file");
867        assert!(
868            args[2..first_path].iter().all(|arg| arg.starts_with("--")),
869            "Go stops reading flags at the first positional: {args:?}"
870        );
871        assert_eq!(&args[first_path..], ["/tmp/a", "/tmp/b", "laptop:"]);
872    }
873
874    #[tokio::test]
875    async fn sending_nothing_is_refused() {
876        let error = refused(
877            |ctx, p| async move { file_cp(&ctx, p).await },
878            FileCpParams {
879                target: "laptop".to_owned(),
880                ..FileCpParams::default()
881            },
882        )
883        .await;
884        assert_eq!(error.code, ErrorCode::InvalidArgs);
885    }
886
887    #[tokio::test]
888    async fn a_transfer_may_be_lengthened_but_only_so_far() {
889        let (answer, _) = against(
890            Reply::ok(""),
891            |ctx, p| async move { file_cp(&ctx, p).await },
892            FileCpParams {
893                files: vec!["/tmp/big.iso".to_owned()],
894                target: "laptop".to_owned(),
895                timeout_seconds: Some(9_999),
896                ..FileCpParams::default()
897            },
898        )
899        .await;
900        assert_eq!(answer["timeout_seconds"], json!(MAX_LONG_TIMEOUT));
901    }
902
903    #[test]
904    fn targets_are_read_column_by_column() {
905        let targets = parse_targets(TARGETS);
906        assert_eq!(targets.len(), 2);
907        assert_eq!(targets[0].hostname, "workstation");
908        assert_eq!(targets[0].status, None);
909        assert_eq!(targets[1].address, "100.64.0.3");
910        assert_eq!(
911            targets[1].status.as_deref(),
912            Some("offline; last seen 66h38m0s ago")
913        );
914    }
915
916    #[tokio::test]
917    async fn listing_targets_asks_for_them_and_sends_no_file() {
918        let (answer, argv) = against(
919            Reply::ok(TARGETS),
920            |ctx, p| async move { file_targets(&ctx, p).await },
921            NoParams {},
922        )
923        .await;
924        assert_eq!(only(&argv), ["file", "cp", "--targets=true"]);
925        assert_eq!(answer["targets"].as_array().expect("a list").len(), 2);
926    }
927
928    #[tokio::test]
929    async fn receiving_files_never_waits_and_never_loops() {
930        // The acceptance criterion, asserted where a caller could observe it:
931        // the two flags that would make this call outlive the request are
932        // switched off by name.
933        let (answer, argv) = against(
934            Reply::ok("notes.txt\n"),
935            |ctx, p| async move { file_get(&ctx, p).await },
936            FileGetParams {
937                directory: "/tmp/inbox".to_owned(),
938                ..FileGetParams::default()
939            },
940        )
941        .await;
942        assert_eq!(
943            only(&argv),
944            [
945                "file",
946                "get",
947                "--conflict=skip",
948                "--wait=false",
949                "--loop=false",
950                "--verbose=false",
951                "/tmp/inbox"
952            ]
953        );
954        assert_eq!(answer["directory"], json!("/tmp/inbox"));
955        assert_eq!(answer["conflict"], json!("skip"));
956    }
957
958    #[tokio::test]
959    async fn the_conflict_rule_reaches_the_client_and_the_answer() {
960        let (answer, argv) = against(
961            Reply::ok(""),
962            |ctx, p| async move { file_get(&ctx, p).await },
963            FileGetParams {
964                directory: "/tmp/inbox".to_owned(),
965                conflict: Conflict::Rename,
966                verbose: Some(true),
967            },
968        )
969        .await;
970        let args = only(&argv);
971        assert!(args.contains(&"--conflict=rename".to_owned()), "{args:?}");
972        assert!(args.contains(&"--verbose=true".to_owned()), "{args:?}");
973        assert_eq!(answer["conflict"], json!("rename"));
974    }
975
976    #[tokio::test]
977    async fn an_empty_inbox_is_an_answer_rather_than_a_failure() {
978        let (answer, _) = against(
979            Reply::ok(""),
980            |ctx, p| async move { file_get(&ctx, p).await },
981            FileGetParams {
982                directory: "/tmp/inbox".to_owned(),
983                ..FileGetParams::default()
984            },
985        )
986        .await;
987        assert_eq!(answer["printed"], json!(null));
988    }
989
990    // -- certificates --------------------------------------------------------
991
992    #[tokio::test]
993    async fn issuing_a_certificate_names_both_files() {
994        let (answer, argv) = against(
995            Reply::ok(""),
996            |ctx, p| async move { cert(&ctx, p).await },
997            CertParams {
998                domain: "workstation.example-tailnet.ts.net".to_owned(),
999                cert_file: "/etc/ssl/node.crt".to_owned(),
1000                key_file: "/etc/ssl/node.key".to_owned(),
1001                ..CertParams::default()
1002            },
1003        )
1004        .await;
1005        assert_eq!(
1006            only(&argv),
1007            [
1008                "cert",
1009                "--cert-file=/etc/ssl/node.crt",
1010                "--key-file=/etc/ssl/node.key",
1011                "workstation.example-tailnet.ts.net"
1012            ]
1013        );
1014        assert_eq!(answer["timeout_seconds"], json!(DEFAULT_CERT_TIMEOUT));
1015    }
1016
1017    #[tokio::test]
1018    async fn a_transfer_and_a_certificate_bound_the_process_too() {
1019        let backend = Arc::new(StubBackend::always(Reply::ok("")));
1020        let ctx = context(Arc::clone(&backend));
1021        file_cp(
1022            &ctx,
1023            FileCpParams {
1024                files: vec!["/tmp/notes.txt".to_owned()],
1025                target: "laptop".to_owned(),
1026                ..FileCpParams::default()
1027            },
1028        )
1029        .await
1030        .expect("the transfer succeeds");
1031        cert(
1032            &ctx,
1033            CertParams {
1034                domain: "workstation.example-tailnet.ts.net".to_owned(),
1035                cert_file: "/etc/ssl/node.crt".to_owned(),
1036                key_file: "/etc/ssl/node.key".to_owned(),
1037                ..CertParams::default()
1038            },
1039        )
1040        .await
1041        .expect("the certificate is issued");
1042        let bounds: Vec<Duration> = backend.calls().iter().map(|call| call.timeout).collect();
1043        assert_eq!(
1044            bounds,
1045            [
1046                Duration::from_secs(DEFAULT_TRANSFER_TIMEOUT + 5),
1047                Duration::from_secs(DEFAULT_CERT_TIMEOUT + 5)
1048            ]
1049        );
1050    }
1051
1052    #[tokio::test]
1053    async fn no_certificate_path_may_be_standard_output() {
1054        // The acceptance criterion the tool exists to hold: `-` is how the
1055        // client is told to print the private key, and no call can ask for it.
1056        for (cert_file, key_file) in [("-", "/etc/ssl/node.key"), ("/etc/ssl/node.crt", "-")] {
1057            let error = refused(
1058                |ctx, p| async move { cert(&ctx, p).await },
1059                CertParams {
1060                    domain: "workstation.example-tailnet.ts.net".to_owned(),
1061                    cert_file: cert_file.to_owned(),
1062                    key_file: key_file.to_owned(),
1063                    ..CertParams::default()
1064                },
1065            )
1066            .await;
1067            assert_eq!(error.code, ErrorCode::InvalidArgs, "{cert_file} {key_file}");
1068        }
1069    }
1070
1071    #[tokio::test]
1072    async fn a_certificate_needs_both_paths_to_be_real() {
1073        let error = refused(
1074            |ctx, p| async move { cert(&ctx, p).await },
1075            CertParams {
1076                domain: "workstation.example-tailnet.ts.net".to_owned(),
1077                cert_file: "/etc/ssl/node.crt".to_owned(),
1078                key_file: "   ".to_owned(),
1079                ..CertParams::default()
1080            },
1081        )
1082        .await;
1083        assert_eq!(error.code, ErrorCode::InvalidArgs);
1084    }
1085
1086    #[tokio::test]
1087    async fn a_minimum_validity_is_rendered_as_a_duration() {
1088        let (_, argv) = against(
1089            Reply::ok(""),
1090            |ctx, p| async move { cert(&ctx, p).await },
1091            CertParams {
1092                domain: "workstation.example-tailnet.ts.net".to_owned(),
1093                cert_file: "/etc/ssl/node.crt".to_owned(),
1094                key_file: "/etc/ssl/node.key".to_owned(),
1095                min_validity_seconds: Some(604_800),
1096                ..CertParams::default()
1097            },
1098        )
1099        .await;
1100        let args = only(&argv);
1101        assert!(
1102            args.contains(&"--min-validity=604800s".to_owned()),
1103            "{args:?}"
1104        );
1105    }
1106
1107    // -- local configuration -------------------------------------------------
1108
1109    #[tokio::test]
1110    async fn writing_metrics_names_the_file_it_wrote() {
1111        let (answer, argv) = against(
1112            Reply::ok(""),
1113            |ctx, p| async move { metrics_write(&ctx, p).await },
1114            MetricsWriteParams {
1115                path: "/var/lib/node-exporter/tailscaled.prom".to_owned(),
1116            },
1117        )
1118        .await;
1119        assert_eq!(
1120            only(&argv),
1121            ["metrics", "write", "/var/lib/node-exporter/tailscaled.prom"]
1122        );
1123        assert_eq!(
1124            answer["subject"],
1125            json!("/var/lib/node-exporter/tailscaled.prom")
1126        );
1127    }
1128
1129    #[tokio::test]
1130    async fn kubeconfig_puts_its_flag_before_the_hostname() {
1131        let (_, argv) = against(
1132            Reply::ok(""),
1133            |ctx, p| async move { configure_kubeconfig(&ctx, p).await },
1134            KubeconfigParams {
1135                hostname: "cluster".to_owned(),
1136                http: Some(true),
1137            },
1138        )
1139        .await;
1140        assert_eq!(
1141            only(&argv),
1142            ["configure", "kubeconfig", "--http=true", "cluster"]
1143        );
1144    }
1145
1146    #[tokio::test]
1147    async fn reloading_policy_asks_for_json_and_forwards_the_document() {
1148        let (answer, argv) = against(
1149            Reply::ok(r#"{"Summary":{"Scope":"Device"},"Settings":{}}"#),
1150            |ctx, p| async move { syspolicy_reload(&ctx, p).await },
1151            NoParams {},
1152        )
1153        .await;
1154        assert_eq!(only(&argv), ["syspolicy", "reload", "--json=true"]);
1155        assert_eq!(answer["Summary"]["Scope"], json!("Device"));
1156    }
1157
1158    // -- Taildrive -----------------------------------------------------------
1159
1160    #[test]
1161    fn shares_survive_a_path_with_a_space_in_it() {
1162        let shares = parse_shares(SHARES);
1163        assert_eq!(shares.len(), 2);
1164        assert_eq!(shares[0].name, "docs");
1165        assert_eq!(shares[0].path, "/srv/shared docs");
1166        assert_eq!(shares[0].as_user, "alice");
1167        assert_eq!(shares[1].path, "/srv/photos");
1168    }
1169
1170    // -- The path allow-list seam -------------------------------------------
1171
1172    /// Populating the policy is the whole of switching it on: every tool that
1173    /// takes a path already asks it.
1174    #[test]
1175    fn a_populated_allow_list_confines_a_path_to_its_roots() {
1176        let mut ctx = context(Arc::new(StubBackend::ok("")));
1177        ctx.paths = PathPolicy::Within(vec![PathBuf::from("/srv/exports")]);
1178
1179        assert!(real_path(&ctx, "path", "/srv/exports/report.txt").is_ok());
1180
1181        let refused = real_path(&ctx, "path", "/etc/shadow").unwrap_err();
1182        assert_eq!(refused.code, ErrorCode::NotPermitted);
1183    }
1184
1185    /// A root check that resolved nothing would be walked straight out of.
1186    #[test]
1187    fn a_parent_directory_step_cannot_leave_an_allowed_root() {
1188        let mut ctx = context(Arc::new(StubBackend::ok("")));
1189        ctx.paths = PathPolicy::Within(vec![PathBuf::from("/srv/exports")]);
1190
1191        let refused = real_path(&ctx, "path", "/srv/exports/../../etc/shadow").unwrap_err();
1192        assert_eq!(refused.code, ErrorCode::NotPermitted);
1193    }
1194
1195    /// On a platform that cannot share as another user the client leaves the
1196    /// last column blank, and the row must still be reported.
1197    #[test]
1198    fn a_share_with_no_as_user_is_still_a_share() {
1199        let shares = parse_shares(
1200            "name       path                  as\n\
1201             ----       ----                  --\n\
1202             docs       /srv/shared docs      \n\
1203             photos     /srv/photos\n",
1204        );
1205        assert_eq!(shares.len(), 2);
1206        assert_eq!(shares[0].path, "/srv/shared docs");
1207        assert_eq!(shares[0].as_user, "");
1208        assert_eq!(shares[1].name, "photos");
1209        assert_eq!(shares[1].as_user, "");
1210    }
1211
1212    /// Two spaces inside a path are a path, not a column break: splitting on
1213    /// whitespace would have lost the row entirely.
1214    #[test]
1215    fn a_path_may_hold_consecutive_spaces() {
1216        let shares = parse_shares(
1217            "name       path                  as\n\
1218             ----       ----                  --\n\
1219             docs       /srv/two  spaces      alice\n",
1220        );
1221        assert_eq!(shares.len(), 1);
1222        assert_eq!(shares[0].path, "/srv/two  spaces");
1223        assert_eq!(shares[0].as_user, "alice");
1224    }
1225
1226    /// The header is dropped for being the header, so a share is not dropped
1227    /// for happening to be called one of its column names.
1228    #[test]
1229    fn a_share_called_name_is_not_mistaken_for_the_header() {
1230        let shares = parse_shares(
1231            "name       path                  as\n\
1232             ----       ----                  --\n\
1233             name       /srv/awkward          alice\n",
1234        );
1235        assert_eq!(shares.len(), 1);
1236        assert_eq!(shares[0].name, "name");
1237        assert_eq!(shares[0].path, "/srv/awkward");
1238    }
1239
1240    #[tokio::test]
1241    async fn listing_shares_reads_the_table() {
1242        let (answer, argv) = against(
1243            Reply::ok(SHARES),
1244            |ctx, p| async move { drive_list(&ctx, p).await },
1245            NoParams {},
1246        )
1247        .await;
1248        assert_eq!(only(&argv), ["drive", "list"]);
1249        assert_eq!(answer["shares"].as_array().expect("a list").len(), 2);
1250    }
1251
1252    #[tokio::test]
1253    async fn sharing_a_directory_passes_the_name_then_the_path() {
1254        let (answer, argv) = against(
1255            Reply::ok(""),
1256            |ctx, p| async move { drive_share(&ctx, p).await },
1257            DriveShareParams {
1258                name: "docs".to_owned(),
1259                path: "/srv/docs".to_owned(),
1260            },
1261        )
1262        .await;
1263        assert_eq!(only(&argv), ["drive", "share", "docs", "/srv/docs"]);
1264        assert_eq!(answer["subject"], json!("docs"));
1265    }
1266
1267    #[tokio::test]
1268    async fn renaming_passes_the_old_name_then_the_new() {
1269        let (_, argv) = against(
1270            Reply::ok(""),
1271            |ctx, p| async move { drive_rename(&ctx, p).await },
1272            DriveRenameParams {
1273                name: "docs".to_owned(),
1274                new_name: "handbook".to_owned(),
1275            },
1276        )
1277        .await;
1278        assert_eq!(only(&argv), ["drive", "rename", "docs", "handbook"]);
1279    }
1280
1281    #[tokio::test]
1282    async fn unsharing_names_only_the_share() {
1283        let (answer, argv) = against(
1284            Reply::ok(""),
1285            |ctx, p| async move { drive_unshare(&ctx, p).await },
1286            DriveUnshareParams {
1287                name: "docs".to_owned(),
1288            },
1289        )
1290        .await;
1291        assert_eq!(only(&argv), ["drive", "unshare", "docs"]);
1292        assert!(
1293            answer["outcome"]
1294                .as_str()
1295                .expect("an outcome")
1296                .contains("untouched"),
1297            "{answer:?}"
1298        );
1299    }
1300
1301    // -- the table -----------------------------------------------------------
1302
1303    #[test]
1304    fn nothing_here_is_reachable_from_a_read_only_session_except_the_two_readers() {
1305        // Every tool in this toolset touches the local filesystem, so the
1306        // read tier holds only the two that read and change nothing.
1307        let readers: Vec<&str> = entries()
1308            .iter()
1309            .filter(|entry| entry.meta.tier == Tier::Read)
1310            .map(|entry| entry.meta.name)
1311            .collect();
1312        assert_eq!(readers, ["tailscale_file_targets", "tailscale_drive_list"]);
1313    }
1314
1315    #[test]
1316    fn withdrawing_a_share_is_the_one_destructive_tool() {
1317        let destructive: Vec<&str> = entries()
1318            .iter()
1319            .filter(|entry| entry.meta.tier == Tier::Destructive)
1320            .map(|entry| entry.meta.name)
1321            .collect();
1322        assert_eq!(destructive, ["tailscale_drive_unshare"]);
1323    }
1324
1325    #[test]
1326    fn every_description_says_which_files_it_touches() {
1327        // The acceptance criterion a caller relies on: a tool that reads or
1328        // writes the local filesystem says so where the tool is chosen, not
1329        // only where it fails.
1330        //
1331        // `syspolicy_reload` and `file_targets` are the two tools here that
1332        // take no path and touch no file of the caller's choosing. One reloads
1333        // settings the node already has, the other asks `tailscaled` which
1334        // peers it may send to, so neither is asked to claim otherwise.
1335        //
1336        // A bare "file" is not a claim: `tailscale file cp` has it in its name.
1337        const CLAIMS: [&str; 3] = ["local filesystem", "on disk", "configuration file"];
1338        for entry in entries().iter().filter(|entry| {
1339            !["tailscale_syspolicy_reload", "tailscale_file_targets"].contains(&entry.meta.name)
1340        }) {
1341            let summary = entry.meta.summary.to_lowercase();
1342            assert!(
1343                CLAIMS.iter().any(|claim| summary.contains(claim)),
1344                "`{}` does not say what it touches: {}",
1345                entry.meta.name,
1346                entry.meta.summary
1347            );
1348        }
1349    }
1350}