1use 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 tailscale_file_cp => FileCpParams, file_cp,
52 toolset: LocalFiles, tier: Write;
53
54 tailscale_file_targets => NoParams, file_targets,
57 toolset: LocalFiles, tier: Read, idempotent: true;
58
59 tailscale_file_get => FileGetParams, file_get,
64 toolset: LocalFiles, tier: Write;
65
66 tailscale_cert => CertParams, cert,
71 toolset: LocalFiles, tier: Write;
72
73 tailscale_metrics_write => MetricsWriteParams, metrics_write,
78 toolset: LocalFiles, tier: Write, idempotent: true, since: "1.78";
79
80 tailscale_configure_kubeconfig => KubeconfigParams, configure_kubeconfig,
84 toolset: LocalFiles, tier: Write, idempotent: true;
85
86 tailscale_syspolicy_reload => NoParams, syspolicy_reload,
90 toolset: LocalFiles, tier: Write, idempotent: true, since: "1.72";
91
92 tailscale_drive_list => NoParams, drive_list,
95 toolset: LocalFiles, tier: Read, idempotent: true;
96
97 tailscale_drive_share => DriveShareParams, drive_share,
101 toolset: LocalFiles, tier: Write, idempotent: true;
102
103 tailscale_drive_rename => DriveRenameParams, drive_rename,
106 toolset: LocalFiles, tier: Write;
107
108 tailscale_drive_unshare => DriveUnshareParams, drive_unshare,
112 toolset: LocalFiles, tier: Destructive;
113}
114
115const DEFAULT_TRANSFER_TIMEOUT: u64 = 300;
123
124const DEFAULT_CERT_TIMEOUT: u64 = 120;
128
129const MAX_LONG_TIMEOUT: u64 = 600;
131
132fn budget(requested: Option<u64>, default: u64) -> (u64, Duration) {
134 bounded_wait(requested, default, MAX_LONG_TIMEOUT)
135}
136
137const NO_PROGRESS: &str = "--update-interval=0";
141
142const NO_TAILDRIVE: &str = "Taildrive CLI commands are not supported";
146
147#[derive(Debug, Deserialize, JsonSchema)]
149pub struct NoParams {}
150
151#[derive(Debug, Default, Deserialize, JsonSchema)]
156pub struct FileCpParams {
157 pub files: Vec<String>,
160 pub target: String,
164 #[serde(default)]
166 pub name: Option<String>,
167 #[serde(default)]
169 pub verbose: Option<bool>,
170 #[serde(default)]
172 pub timeout_seconds: Option<u64>,
173}
174
175#[derive(Debug, Default, Clone, Copy, Deserialize, JsonSchema)]
178#[serde(rename_all = "snake_case")]
179pub enum Conflict {
180 #[default]
184 Skip,
185 Overwrite,
187 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 pub directory: String,
206 #[serde(default)]
209 pub conflict: Conflict,
210 #[serde(default)]
212 pub verbose: Option<bool>,
213}
214
215#[derive(Debug, Default, Deserialize, JsonSchema)]
216pub struct CertParams {
217 pub domain: String,
220 pub cert_file: String,
223 pub key_file: String,
226 #[serde(default)]
229 pub min_validity_seconds: Option<u64>,
230 #[serde(default)]
232 pub timeout_seconds: Option<u64>,
233}
234
235#[derive(Debug, Default, Deserialize, JsonSchema)]
236pub struct MetricsWriteParams {
237 pub path: String,
240}
241
242#[derive(Debug, Default, Deserialize, JsonSchema)]
243pub struct KubeconfigParams {
244 pub hostname: String,
246 #[serde(default)]
249 pub http: Option<bool>,
250}
251
252#[derive(Debug, Default, Deserialize, JsonSchema)]
253pub struct DriveShareParams {
254 pub name: String,
256 pub path: String,
258}
259
260#[derive(Debug, Default, Deserialize, JsonSchema)]
261pub struct DriveRenameParams {
262 pub name: String,
264 pub new_name: String,
266}
267
268#[derive(Debug, Default, Deserialize, JsonSchema)]
269pub struct DriveUnshareParams {
270 pub name: String,
272}
273
274#[derive(Debug, Serialize, JsonSchema)]
280pub struct TransferReport {
281 pub target: String,
283 pub files: Vec<String>,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub delivered_as: Option<String>,
288 pub timeout_seconds: u64,
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub printed: Option<String>,
293}
294
295#[derive(Debug, Serialize, JsonSchema)]
297pub struct Target {
298 pub address: String,
300 pub hostname: String,
302 #[serde(skip_serializing_if = "Option::is_none")]
305 pub status: Option<String>,
306}
307
308#[derive(Debug, Serialize, JsonSchema)]
309pub struct TargetsReport {
310 pub targets: Vec<Target>,
312}
313
314#[derive(Debug, Serialize, JsonSchema)]
316pub struct InboxReport {
317 pub directory: String,
319 pub conflict: String,
321 #[serde(skip_serializing_if = "Option::is_none")]
324 pub printed: Option<String>,
325}
326
327#[derive(Debug, Serialize, JsonSchema)]
330pub struct CertificateReport {
331 pub domain: String,
333 pub cert_file: String,
335 pub key_file: String,
337 pub timeout_seconds: u64,
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub printed: Option<String>,
342}
343
344#[derive(Debug, Serialize, JsonSchema)]
346pub struct OutcomeReport {
347 pub subject: String,
349 pub outcome: String,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub note: Option<String>,
354}
355
356#[derive(Debug, Serialize, JsonSchema)]
358pub struct Share {
359 pub name: String,
361 pub path: String,
363 pub as_user: String,
365}
366
367#[derive(Debug, Serialize, JsonSchema)]
368pub struct SharesReport {
369 pub shares: Vec<Share>,
371}
372
373fn 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
394async 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
422async 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(¶ms.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 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
475fn 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", ¶ms.directory)?;
498
499 let args = vec![
500 "file".to_owned(),
501 "get".to_owned(),
502 format!("--conflict={}", params.conflict.as_str()),
503 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
520async fn cert(ctx: &ToolContext, params: CertParams) -> ToolResult<Value> {
525 let meta = &metas::tailscale_cert;
526 let cert_file = real_path(ctx, "cert_file", ¶ms.cert_file)?;
527 let key_file = real_path(ctx, "key_file", ¶ms.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
558async fn metrics_write(ctx: &ToolContext, params: MetricsWriteParams) -> ToolResult<Value> {
563 let meta = &metas::tailscale_metrics_write;
564 let path = real_path(ctx, "path", ¶ms.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
605async 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
617fn 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
633fn 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
655fn parse_shares(text: &str) -> Vec<Share> {
664 let mut rows = text.lines().filter(|line| !line.trim().is_empty());
665 let Some(header) = rows.next() else {
668 return Vec::new();
669 };
670 let starts = column_starts(header);
671 rows
672 .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", ¶ms.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", ¶ms.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", ¶ms.name, ¶ms.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", ¶ms.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 const TARGETS: &str = "100.64.0.2\tworkstation\n\
760 100.64.0.3\tlaptop\toffline; last seen 66h38m0s ago\n";
761
762 const SHARES: &str = "name path as\n\
764 ---- ---- --\n\
765 docs /srv/shared docs alice\n\
766 photos /srv/photos alice\n";
767
768 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 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 fn only(argv: &[Vec<String>]) -> &[String] {
801 assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
802 &argv[0]
803 }
804
805 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1304 fn nothing_here_is_reachable_from_a_read_only_session_except_the_two_readers() {
1305 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 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}