1use std::fs::File;
28use std::io::{self, Write as _};
29use std::path::{Path, PathBuf};
30
31const RENAME_TRIES: u32 = 5;
35const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(40);
36
37#[derive(Debug, Clone)]
39pub struct Workdir {
40 base: PathBuf,
41 canonical: PathBuf,
42}
43
44fn refused(path: &Path, why: impl std::fmt::Display) -> io::Error {
45 io::Error::new(io::ErrorKind::PermissionDenied, format!("refused to write {}: {why}", path.display()))
46}
47
48fn is_symlink(path: &Path) -> bool {
49 std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
50}
51
52impl Workdir {
53 pub fn open(base: &Path) -> io::Result<Workdir> {
55 let canonical = base.canonicalize()?;
56 Ok(Workdir { base: base.to_path_buf(), canonical })
57 }
58
59 pub fn base(&self) -> &Path {
61 &self.base
62 }
63
64 pub fn resolve(&self, rel: &str) -> io::Result<PathBuf> {
67 let path = crate::backend::base::safe_join_within_base(&self.base, rel)
68 .map_err(|e| refused(&self.base.join(rel), e))?;
69 crate::backend::base::verify_no_symlink_escape(&path, &self.canonical).map_err(|e| refused(&path, e))?;
70 if is_symlink(&path) {
71 return Err(refused(&path, "it is a symlink"));
72 }
73 Ok(path)
74 }
75
76 fn ensure_parent(&self, path: &Path) -> io::Result<()> {
78 let Some(parent) = path.parent() else { return Ok(()) };
79 if parent.as_os_str().is_empty() || parent.exists() {
80 return Ok(());
81 }
82 std::fs::create_dir_all(parent)?;
83 crate::backend::base::verify_no_symlink_escape(path, &self.canonical).map_err(|e| refused(path, e))
86 }
87
88 pub fn write(&self, rel: &str, bytes: &[u8], owner_only: bool) -> io::Result<()> {
93 let path = self.resolve(rel)?;
94 self.ensure_parent(&path)?;
95 #[cfg(unix)]
96 let keep_mode = if owner_only { None } else { std::fs::metadata(&path).ok().map(|m| m.permissions()) };
97 let tmp = temp_path(&path);
98 let mut opts = std::fs::OpenOptions::new();
99 opts.write(true).create_new(true);
100 #[cfg(unix)]
101 if owner_only {
102 use std::os::unix::fs::OpenOptionsExt as _;
103 opts.mode(0o600);
104 }
105 #[cfg(not(unix))]
106 let _ = owner_only;
107 let written = (|| {
108 let mut f = opts.open(&tmp)?;
109 #[cfg(unix)]
110 if let Some(perms) = keep_mode {
111 f.set_permissions(perms)?;
112 }
113 f.write_all(bytes)?;
114 f.sync_all()
115 })();
116 let result = written.and_then(|()| rename_with_retry(&tmp, &path));
117 if result.is_err() {
118 let _ = std::fs::remove_file(&tmp);
119 }
120 result
121 }
122
123 pub fn create_new(&self, rel: &str, bytes: &[u8]) -> io::Result<bool> {
130 let path = self.resolve(rel)?;
131 self.ensure_parent(&path)?;
132 let tmp = temp_path(&path);
133 let staged = (|| {
134 let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?;
135 f.write_all(bytes)?;
136 f.sync_all()
137 })();
138 let linked = staged.and_then(|()| std::fs::hard_link(&tmp, &path));
139 let _ = std::fs::remove_file(&tmp);
140 match linked {
141 Ok(()) => return Ok(true),
142 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Ok(false),
143 Err(_) => {}
147 }
148 let mut f = match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
149 Ok(f) => f,
150 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Ok(false),
151 Err(e) => return Err(e),
152 };
153 if let Err(e) = f.write_all(bytes).and_then(|()| f.sync_all()) {
154 drop(f);
155 let _ = std::fs::remove_file(&path);
156 return Err(e);
157 }
158 Ok(true)
159 }
160
161 pub fn append(&self, rel: &str, bytes: &[u8]) -> io::Result<()> {
164 let path = self.resolve(rel)?;
165 std::fs::OpenOptions::new().append(true).open(&path)?.write_all(bytes)
166 }
167
168 pub fn remove_file(&self, rel: &str) -> io::Result<()> {
171 let path = self.resolve(rel)?;
172 std::fs::remove_file(path)
173 }
174
175 pub fn remove_dir(&self, rel: &str) -> io::Result<()> {
177 let path = self.resolve(rel)?;
178 std::fs::remove_dir(path)
179 }
180
181 pub fn open_lock_file(&self, rel: &str) -> io::Result<File> {
184 let path = self.resolve(rel)?;
185 self.ensure_parent(&path)?;
186 #[allow(clippy::suspicious_open_options)]
187 std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)
188 }
189}
190
191fn temp_path(path: &Path) -> PathBuf {
194 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
195 path.with_file_name(format!(".{file_name}.{}.agentmux-tmp", uuid::Uuid::new_v4()))
196}
197
198fn rename_with_retry(from: &Path, to: &Path) -> io::Result<()> {
201 let mut tries = 0;
202 loop {
203 match std::fs::rename(from, to) {
204 Ok(()) => return Ok(()),
205 Err(e) if tries + 1 < RENAME_TRIES && e.kind() == io::ErrorKind::PermissionDenied => {
206 tries += 1;
207 std::thread::sleep(RENAME_RETRY_DELAY);
208 }
209 Err(e) => return Err(e),
210 }
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn workdir() -> (tempfile::TempDir, Workdir) {
219 let dir = tempfile::tempdir().unwrap();
220 let wd = Workdir::open(dir.path()).unwrap();
221 (dir, wd)
222 }
223
224 fn symlink_file(target: &Path, link: &Path) -> bool {
227 #[cfg(unix)]
228 return std::os::unix::fs::symlink(target, link).is_ok();
229 #[cfg(windows)]
230 return std::os::windows::fs::symlink_file(target, link).is_ok();
231 }
232
233 fn symlink_dir(target: &Path, link: &Path) -> bool {
234 #[cfg(unix)]
235 return std::os::unix::fs::symlink(target, link).is_ok();
236 #[cfg(windows)]
237 return std::os::windows::fs::symlink_dir(target, link).is_ok();
238 }
239
240 fn is_refusal(r: io::Result<impl std::fmt::Debug>) -> bool {
241 matches!(r, Err(e) if e.kind() == io::ErrorKind::PermissionDenied)
242 }
243
244 #[test]
245 fn writes_create_parents_and_replace_whole() {
246 let (dir, wd) = workdir();
247 wd.write(".claude/skills/x/SKILL.md", b"one", false).unwrap();
248 wd.write(".claude/skills/x/SKILL.md", b"two", false).unwrap();
249 assert_eq!(std::fs::read(dir.path().join(".claude/skills/x/SKILL.md")).unwrap(), b"two");
250 let leftovers: Vec<_> = std::fs::read_dir(dir.path().join(".claude/skills/x"))
251 .unwrap()
252 .flatten()
253 .filter(|e| e.file_name().to_string_lossy().ends_with(".agentmux-tmp"))
254 .collect();
255 assert!(leftovers.is_empty(), "no temp file left behind");
256 }
257
258 #[cfg(unix)]
259 #[test]
260 fn owner_only_writes_are_0600() {
261 use std::os::unix::fs::PermissionsExt as _;
262 let (dir, wd) = workdir();
263 wd.write(".mcp.json", b"{}", true).unwrap();
264 let mode = std::fs::metadata(dir.path().join(".mcp.json")).unwrap().permissions().mode();
265 assert_eq!(mode & 0o777, 0o600);
266 }
267
268 #[cfg(unix)]
270 #[test]
271 fn a_replaced_file_keeps_its_mode() {
272 use std::os::unix::fs::PermissionsExt as _;
273 let (dir, wd) = workdir();
274 let path = dir.path().join("CLAUDE.md");
275 std::fs::write(&path, "old").unwrap();
276 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
277 wd.write("CLAUDE.md", b"new", false).unwrap();
278 assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
279 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
280 }
281
282 #[test]
283 fn paths_outside_the_workdir_are_refused() {
284 let (_dir, wd) = workdir();
285 assert!(is_refusal(wd.write("../escape.md", b"x", false)));
286 assert!(is_refusal(wd.write("/etc/x", b"x", false)));
287 assert!(is_refusal(wd.write("", b"x", false)));
288 }
289
290 #[test]
291 fn a_symlinked_file_is_never_written_through_live_or_dangling() {
292 let (dir, wd) = workdir();
293 let outside = tempfile::tempdir().unwrap();
294 let target = outside.path().join("target.md");
295 std::fs::write(&target, b"theirs").unwrap();
296 if !symlink_file(&target, &dir.path().join("CLAUDE.md")) {
297 return;
298 }
299 assert!(is_refusal(wd.write("CLAUDE.md", b"ours", false)));
300 assert!(is_refusal(wd.append("CLAUDE.md", b"ours")));
301 assert_eq!(std::fs::read(&target).unwrap(), b"theirs");
302
303 let gone = outside.path().join("gone.md");
304 if symlink_file(&gone, &dir.path().join("AGENTS.md")) {
305 assert!(is_refusal(wd.write("AGENTS.md", b"ours", false)));
306 assert!(is_refusal(wd.create_new("AGENTS.md", b"ours")));
307 assert!(!gone.exists(), "nothing created at a dangling link's target");
308 }
309 }
310
311 #[test]
312 fn a_folder_linking_outside_is_refused() {
313 let (dir, wd) = workdir();
314 let outside = tempfile::tempdir().unwrap();
315 if !symlink_dir(outside.path(), &dir.path().join(".claude")) {
316 return;
317 }
318 assert!(is_refusal(wd.write(".claude/settings.json", b"{}", false)));
319 assert!(is_refusal(wd.open_lock_file(".claude/.lock")));
320 assert!(std::fs::read_dir(outside.path()).unwrap().next().is_none(), "nothing written outside");
321 }
322
323 #[test]
324 fn create_new_never_overwrites() {
325 let (dir, wd) = workdir();
326 assert!(wd.create_new(".claude/marker.json", b"first").unwrap());
327 assert!(!wd.create_new(".claude/marker.json", b"second").unwrap());
328 assert_eq!(std::fs::read(dir.path().join(".claude/marker.json")).unwrap(), b"first");
329 let leftovers: Vec<_> = std::fs::read_dir(dir.path().join(".claude"))
330 .unwrap()
331 .flatten()
332 .filter(|e| e.file_name().to_string_lossy().ends_with(".agentmux-tmp"))
333 .collect();
334 assert!(leftovers.is_empty(), "no temp file left behind");
335 }
336
337 #[test]
338 fn append_adds_to_the_end() {
339 let (dir, wd) = workdir();
340 std::fs::write(dir.path().join("CLAUDE.md"), b"mine\n").unwrap();
341 wd.append("CLAUDE.md", b"@import\n").unwrap();
342 assert_eq!(std::fs::read(dir.path().join("CLAUDE.md")).unwrap(), b"mine\n@import\n");
343 assert!(wd.append("missing.md", b"x").is_err(), "append never creates");
344 }
345
346 #[test]
347 fn removing_refuses_a_link_and_removes_a_file() {
348 let (dir, wd) = workdir();
349 wd.write(".claude/commands/a.md", b"x", false).unwrap();
350 wd.remove_file(".claude/commands/a.md").unwrap();
351 wd.remove_dir(".claude/commands").unwrap();
352 assert!(!dir.path().join(".claude/commands").exists());
353
354 let outside = tempfile::tempdir().unwrap();
355 let target = outside.path().join("keep.md");
356 std::fs::write(&target, b"keep").unwrap();
357 if symlink_file(&target, &dir.path().join("link.md")) {
358 assert!(is_refusal(wd.remove_file("link.md")));
359 assert!(target.exists());
360 }
361 }
362
363 #[test]
364 fn a_lock_file_is_created_and_never_truncated() {
365 let (dir, wd) = workdir();
366 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
367 std::fs::write(dir.path().join(".claude/.lock"), b"held").unwrap();
368 let _f = wd.open_lock_file(".claude/.lock").unwrap();
369 assert_eq!(std::fs::read(dir.path().join(".claude/.lock")).unwrap(), b"held");
370 let _g = wd.open_lock_file(".claude/new.lock").unwrap();
371 assert!(dir.path().join(".claude/new.lock").exists());
372 }
373}