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)]
67pub struct PrivateFile {
68 #[expect(dead_code, reason = "kept alive so that dropping it cleans up")]
71 dir: tempfile::TempDir,
72 path: std::path::PathBuf,
73}
74
75impl PrivateFile {
76 pub fn reserved(name: &str) -> Result<Self, ExecError> {
78 let mut builder = tempfile::Builder::new();
79 builder.prefix("tailscale-mcp-");
80 #[cfg(unix)]
81 {
82 use std::os::unix::fs::PermissionsExt as _;
83 builder.permissions(std::fs::Permissions::from_mode(0o700));
84 }
85 let dir = builder.tempdir().map_err(ExecError::SecretFile)?;
86 let path = dir.path().join(name);
87 Ok(Self { dir, path })
88 }
89
90 pub fn written(name: &str, contents: &[u8]) -> Result<Self, ExecError> {
92 let file = Self::reserved(name)?;
93 std::fs::write(&file.path, contents).map_err(ExecError::SecretFile)?;
94 Ok(file)
95 }
96
97 pub fn path(&self) -> &Path {
98 &self.path
99 }
100
101 pub fn arg(&self) -> String {
104 self.path.display().to_string()
105 }
106
107 pub fn read(&self) -> Result<Vec<u8>, ExecError> {
109 std::fs::read(&self.path).map_err(ExecError::SecretFile)
110 }
111
112 #[cfg(test)]
114 fn directory(&self) -> &Path {
115 self.path.parent().unwrap_or(&self.path)
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn a_reserved_name_is_free_for_the_client_to_create() {
125 let file = PrivateFile::reserved("serve-config.json").expect("a private directory");
126 assert!(
127 !file.path().exists(),
128 "the client refuses a path that is already taken"
129 );
130 std::fs::write(file.path(), b"{}").expect("the client can create it");
131 assert_eq!(file.read().expect("readable"), b"{}");
132 }
133
134 #[cfg(unix)]
135 #[test]
136 fn nobody_else_can_enter_the_directory_the_file_lives_in() {
137 use std::os::unix::fs::PermissionsExt as _;
138 let file = PrivateFile::written("serve-config.json", b"{}").expect("a private directory");
139 let mode = std::fs::metadata(file.directory())
140 .expect("metadata")
141 .permissions()
142 .mode();
143 assert_eq!(mode & 0o077, 0, "group and other must have no access");
144 }
145
146 #[test]
147 fn the_file_and_its_directory_go_away_with_the_call() {
148 let (path, dir) = {
149 let file = PrivateFile::written("serve-config.json", b"{}").expect("a private file");
150 (file.path().to_path_buf(), file.directory().to_path_buf())
151 };
152 assert!(!path.exists(), "{} outlived its guard", path.display());
153 assert!(!dir.exists(), "{} outlived its guard", dir.display());
154 }
155
156 #[test]
157 fn the_secret_reaches_the_cli_by_reference_not_by_value() {
158 let secret = "tskey-auth-example-notreal";
159 let file = SecretFile::new(secret).expect("a temporary file");
160 let arg = file.arg();
161 assert!(arg.starts_with("file:"));
162 assert!(
163 !arg.contains(secret),
164 "the argument must not carry the secret: {arg}"
165 );
166 assert_eq!(
167 std::fs::read_to_string(file.path()).expect("readable"),
168 secret
169 );
170 }
171
172 #[cfg(unix)]
173 #[test]
174 fn the_file_is_readable_only_by_this_user() {
175 use std::os::unix::fs::PermissionsExt as _;
176 let file = SecretFile::new("tskey-auth-example-notreal").expect("a temporary file");
177 let mode = std::fs::metadata(file.path())
178 .expect("metadata")
179 .permissions()
180 .mode();
181 assert_eq!(mode & 0o077, 0, "group and other must have no access");
182 }
183
184 #[test]
185 fn the_file_goes_away_with_the_call() {
186 let path = {
187 let file = SecretFile::new("tskey-auth-example-notreal").expect("a temporary file");
188 file.path().to_path_buf()
189 };
190 assert!(!path.exists(), "{} outlived its guard", path.display());
191 }
192}