1use std::io::Write;
15use std::path::Path;
16
17use crate::exec::ExecError;
18
19#[derive(Debug)]
27pub struct SecretFile {
28 file: tempfile::NamedTempFile,
29}
30
31impl SecretFile {
32 pub fn new(secret: &str) -> Result<Self, ExecError> {
34 let mut builder = tempfile::Builder::new();
35 builder.prefix("tailscale-mcp-").suffix(".key");
36 #[cfg(unix)]
37 {
38 use std::os::unix::fs::PermissionsExt as _;
39 let _ = std::fs::Permissions::from_mode(0o600);
40 builder.permissions(std::fs::Permissions::from_mode(0o600));
41 }
42 let mut file = builder.tempfile().map_err(ExecError::SecretFile)?;
43 file.write_all(secret.as_bytes())
44 .and_then(|()| file.flush())
45 .map_err(ExecError::SecretFile)?;
46 Ok(Self { file })
47 }
48
49 pub fn path(&self) -> &Path {
50 self.file.path()
51 }
52
53 pub fn arg(&self) -> String {
55 format!("file:{}", self.file.path().display())
56 }
57}
58
59#[derive(Debug)]
64pub struct PrivateFile {
65 #[expect(dead_code, reason = "kept alive so that dropping it cleans up")]
68 dir: tempfile::TempDir,
69 path: std::path::PathBuf,
70}
71
72impl PrivateFile {
73 pub fn written(name: &str, contents: &[u8]) -> Result<Self, ExecError> {
75 let mut builder = tempfile::Builder::new();
76 builder.prefix("tailscale-mcp-");
77 #[cfg(unix)]
78 {
79 use std::os::unix::fs::PermissionsExt as _;
80 builder.permissions(std::fs::Permissions::from_mode(0o700));
81 }
82 let dir = builder.tempdir().map_err(ExecError::SecretFile)?;
83 let path = dir.path().join(name);
84 std::fs::write(&path, contents).map_err(ExecError::SecretFile)?;
85 Ok(Self { dir, path })
86 }
87
88 pub fn path(&self) -> &Path {
89 &self.path
90 }
91
92 pub fn arg(&self) -> String {
95 self.path.display().to_string()
96 }
97
98 #[cfg(test)]
100 fn directory(&self) -> &Path {
101 self.path.parent().unwrap_or(&self.path)
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[cfg(unix)]
110 #[test]
111 fn nobody_else_can_enter_the_directory_the_file_lives_in() {
112 use std::os::unix::fs::PermissionsExt as _;
113 let file = PrivateFile::written("serve-config.json", b"{}").expect("a private directory");
114 let mode = std::fs::metadata(file.directory())
115 .expect("metadata")
116 .permissions()
117 .mode();
118 assert_eq!(mode & 0o077, 0, "group and other must have no access");
119 }
120
121 #[test]
122 fn the_file_and_its_directory_go_away_with_the_call() {
123 let (path, dir) = {
124 let file = PrivateFile::written("serve-config.json", b"{}").expect("a private file");
125 (file.path().to_path_buf(), file.directory().to_path_buf())
126 };
127 assert!(!path.exists(), "{} outlived its guard", path.display());
128 assert!(!dir.exists(), "{} outlived its guard", dir.display());
129 }
130
131 #[test]
132 fn the_secret_reaches_the_cli_by_reference_not_by_value() {
133 let secret = "tskey-auth-example-notreal";
134 let file = SecretFile::new(secret).expect("a temporary file");
135 let arg = file.arg();
136 assert!(arg.starts_with("file:"));
137 assert!(
138 !arg.contains(secret),
139 "the argument must not carry the secret: {arg}"
140 );
141 assert_eq!(
142 std::fs::read_to_string(file.path()).expect("readable"),
143 secret
144 );
145 }
146
147 #[cfg(unix)]
148 #[test]
149 fn the_file_is_readable_only_by_this_user() {
150 use std::os::unix::fs::PermissionsExt as _;
151 let file = SecretFile::new("tskey-auth-example-notreal").expect("a temporary file");
152 let mode = std::fs::metadata(file.path())
153 .expect("metadata")
154 .permissions()
155 .mode();
156 assert_eq!(mode & 0o077, 0, "group and other must have no access");
157 }
158
159 #[test]
160 fn the_file_goes_away_with_the_call() {
161 let path = {
162 let file = SecretFile::new("tskey-auth-example-notreal").expect("a temporary file");
163 file.path().to_path_buf()
164 };
165 assert!(!path.exists(), "{} outlived its guard", path.display());
166 }
167}