返回 CodeWhale
utils.rs
根目录 / crates / tui / src / utils.rs
1 //! Utility helpers shared across the `DeepSeek` CLI.
2
3 use std::fs;
4 use std::io::Write;
5 #[cfg(unix)]
6 use std::os::unix::fs::MetadataExt;
7 use std::path::{Path, PathBuf};
8 use std::process::Command;
9
10 use anyhow::Result;
11 use codewhale_models::{ContentBlock, Message};
12 use ignore::WalkBuilder;
13 use std::io;
14
15 // Split out so the integration harness can `#[path]`-include it with
16 // `skills/install.rs`, which reads registry downloads through it.
17 mod response_body;
18 pub use response_body::read_response_body_capped;
19
20 /// A writer that counts bytes written without storing them.
21 pub(crate) struct CountingWriter {
22 count: usize,
23 }
24
25 impl CountingWriter {
26 pub(crate) fn new() -> Self {
27 Self { count: 0 }
28 }
29
30 pub(crate) fn count(&self) -> usize {
31 self.count
32 }
33 }
34
35 impl io::Write for CountingWriter {
36 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
37 self.count += buf.len();
38 Ok(buf.len())
39 }
40
41 fn flush(&mut self) -> io::Result<()> {
42 Ok(())
43 }
44 }
45
46 const LOG_FINGERPRINT_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
47 const LOG_FINGERPRINT_PRIME: u64 = 0x0000_0100_0000_01b3;
48
49 /// Compact token-count label for a model's context or output window:
50 /// `1M`, `1.05M`, `262K`, `500`. Shared by the model picker and the fleet
51 /// capability badges. Not the same scale as `agent_roster::format_tokens`
52 /// (`1.2k`), which labels usage rather than window size.
53 pub(crate) fn format_context_window(tokens: u64) -> String {
54 if tokens >= 1_000_000 {
55 if tokens.is_multiple_of(1_000_000) {
56 format!("{}M", tokens / 1_000_000)
57 } else {
58 format!("{:.2}M", tokens as f64 / 1_000_000.0)
59 .trim_end_matches('0')
60 .trim_end_matches('.')
61 .to_string()
62 }
63 } else if tokens >= 1_000 {
64 format!("{}K", tokens / 1_000)
65 } else {
66 tokens.to_string()
67 }
68 }
69
70 /// Return a stable, non-reversible log label for an identifier.
71 ///
72 /// This is meant for correlation in diagnostics where the raw value may be a
73 /// session token, remote protocol session id, or other bearer-like handle.
74 #[must_use]
75 pub fn redacted_identifier_for_log(identifier: &str) -> String {
76 if identifier.is_empty() {
77 return "<redacted:empty>".to_string();
78 }
79
80 let mut hash = LOG_FINGERPRINT_OFFSET_BASIS;
81 for byte in identifier.as_bytes() {
82 hash ^= u64::from(*byte);
83 hash = hash.wrapping_mul(LOG_FINGERPRINT_PRIME);
84 }
85 hash ^= identifier.len() as u64;
86 hash = hash.wrapping_mul(LOG_FINGERPRINT_PRIME);
87
88 format!("<redacted:{hash:016x}>")
89 }
90
91 #[cfg(windows)]
92 pub(crate) fn suppress_console_window(cmd: &mut Command) {
93 use std::os::windows::process::CommandExt;
94
95 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
96 cmd.creation_flags(CREATE_NO_WINDOW);
97 }
98
99 #[cfg(not(windows))]
100 pub(crate) fn suppress_console_window(_cmd: &mut Command) {}
101
102 #[cfg(windows)]
103 pub(crate) fn suppress_tokio_console_window(cmd: &mut tokio::process::Command) {
104 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
105 cmd.creation_flags(CREATE_NO_WINDOW);
106 }
107
108 #[cfg(not(windows))]
109 pub(crate) fn suppress_tokio_console_window(_cmd: &mut tokio::process::Command) {}
110
111 // === Project Mapping Helpers ===
112
113 /// Identify if a file is a "key" file for project identification.
114 #[must_use]
115 pub fn is_key_file(path: &Path) -> bool {
116 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
117 return false;
118 };
119
120 matches!(
121 file_name.to_lowercase().as_str(),
122 "cargo.toml"
123 | "package.json"
124 | "requirements.txt"
125 | "build.gradle"
126 | "pom.xml"
127 | "readme.md"
128 | "agents.md"
129 | "claude.md"
130 | "makefile"
131 | "dockerfile"
132 | "main.rs"
133 | "lib.rs"
134 | "index.js"
135 | "index.ts"
136 | "app.py"
137 )
138 }
139
140 /// Generate a high-level summary of the project based on key files.
141 ///
142 /// Output is byte-stable across calls: `WalkBuilder` doesn't sort siblings
143 /// (the OS readdir order leaks through), so the joined `key_files` list
144 /// would otherwise reorder run-to-run on filesystems that don't pre-sort.
145 /// Only matters when the workspace has no `AGENTS.md` / `CLAUDE.md`, since
146 /// the system prompt routes through `ProjectContext::as_system_block` first
147 /// and only falls back here when no project-context document exists.
148 #[must_use]
149 pub fn summarize_project(root: &Path) -> String {
150 let mut key_files = Vec::new();
151
152 let mut builder = WalkBuilder::new(root);
153 builder.hidden(false).follow_links(false).max_depth(Some(2));
154 let walker = builder.build();
155
156 for entry in walker {
157 let entry = match entry {
158 Ok(entry) => entry,
159 Err(_) => continue,
160 };
161 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
162 continue;
163 }
164 if is_key_file(entry.path())
165 && let Ok(rel) = entry.path().strip_prefix(root)
166 {
167 key_files.push(rel.to_string_lossy().to_string());
168 }
169 }
170
171 key_files.sort();
172
173 if key_files.is_empty() {
174 return "Unknown project type".to_string();
175 }
176
177 let mut types = Vec::new();
178 if key_files
179 .iter()
180 .any(|f| f.to_lowercase().contains("cargo.toml"))
181 {
182 types.push("Rust");
183 }
184 if key_files
185 .iter()
186 .any(|f| f.to_lowercase().contains("package.json"))
187 {
188 types.push("JavaScript/Node.js");
189 }
190 if key_files
191 .iter()
192 .any(|f| f.to_lowercase().contains("requirements.txt"))
193 {
194 types.push("Python");
195 }
196
197 if types.is_empty() {
198 format!("Project with key files: {}", key_files.join(", "))
199 } else {
200 format!("A {} project", types.join(" and "))
201 }
202 }
203
204 /// Generate a tree-like view of the project structure.
205 ///
206 /// Sibling order is fixed by sorting collected paths — the underlying
207 /// `WalkBuilder` follows the OS readdir order, which is non-deterministic
208 /// across filesystems. Sorting by full path preserves the tree shape (a
209 /// directory still precedes its children because `"src" < "src/lib.rs"`)
210 /// while making the rendered output byte-stable across runs.
211 #[must_use]
212 pub fn project_tree(root: &Path, max_depth: usize, follow_symlinks: bool) -> String {
213 let mut entries: Vec<(PathBuf, bool)> = Vec::new();
214
215 let mut builder = WalkBuilder::new(root);
216 builder
217 .hidden(false)
218 .follow_links(follow_symlinks)
219 .max_depth(Some(max_depth + 1));
220
221 for entry in builder.build().flatten() {
222 if entry.file_type().is_some_and(|ft| ft.is_symlink()) && !follow_symlinks {
223 continue;
224 }
225 let depth = entry.depth();
226 if depth == 0 || depth > max_depth {
227 continue;
228 }
229 let rel_path = entry
230 .path()
231 .strip_prefix(root)
232 .unwrap_or(entry.path())
233 .to_path_buf();
234 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
235 entries.push((rel_path, is_dir));
236 }
237
238 entries.sort_by(|a, b| a.0.cmp(&b.0));
239
240 let mut tree_lines = Vec::with_capacity(entries.len());
241 for (rel_path, is_dir) in entries {
242 let depth = rel_path.components().count();
243 let indent = " ".repeat(depth.saturating_sub(1));
244 let prefix = if is_dir { "DIR: " } else { "FILE: " };
245 tree_lines.push(format!(
246 "{}{}{}",
247 indent,
248 prefix,
249 rel_path.file_name().unwrap_or_default().to_string_lossy()
250 ));
251 }
252
253 tree_lines.join("\n")
254 }
255
256 // === Filesystem Helpers ===
257
258 /// Permission policy for atomic writes.
259 ///
260 /// - [`AtomicWritePermissions::Private`]: keep tempfile's owner-only defaults
261 /// (used for CodeWhale internal persistence such as session/history/trust).
262 /// - [`AtomicWritePermissions::Workspace`]: match ordinary workspace file
263 /// semantics — new files request mode `0666` (kernel applies umask); existing
264 /// files retain ordinary `rwx` bits (not setuid/setgid/sticky).
265 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
266 enum AtomicWritePermissions {
267 Private,
268 Workspace,
269 }
270
271 /// Atomically write `contents` to `path` using a temporary file + fsync + rename.
272 ///
273 /// Uses a **private** permission policy (Unix tempfile default `0600`). Prefer
274 /// [`write_atomic_workspace`] for user workspace source/config files.
275 ///
276 /// 1. Creates a `NamedTempFile` in the same directory as `path` (same filesystem).
277 /// 2. Writes `contents` to the temp file.
278 /// 3. Calls `sync_all()` on the temp file for durability.
279 /// 4. Atomically renames (persists) the temp file over `path`.
280 ///
281 /// On filesystems that support it (`ext4`, `apfs`, `ntfs`), the rename is
282 /// atomic — a concurrent reader sees either the old content or the new, never
283 /// a partial write. `sync_all` ensures the data is on stable storage before
284 /// the metadata change so an OS crash mid-rename doesn't lose data.
285 ///
286 /// # Errors
287 /// Returns `io::Error` if the parent directory cannot be determined, the temp
288 /// file cannot be created, the write fails, or the rename fails.
289 pub fn write_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> {
290 write_atomic_with_permissions(path, contents, AtomicWritePermissions::Private)
291 }
292
293 /// Atomically write `contents` to a **user workspace** path.
294 ///
295 /// On Unix:
296 /// - New files request creation mode `0666`; the OS applies the process umask
297 /// (same candidate mode as ordinary `std::fs::write`).
298 /// - Existing files keep ordinary permission bits (`mode & 0o777`), including
299 /// executable bits. setuid/setgid/sticky are intentionally not restored.
300 ///
301 /// On Windows this matches [`write_atomic`] (no POSIX mode simulation).
302 ///
303 /// # Errors
304 /// Same failure modes as [`write_atomic`].
305 pub fn write_atomic_workspace(path: &Path, contents: &[u8]) -> std::io::Result<()> {
306 // Hard-link guard (issue #5569): a workspace path that shares its inode
307 // with another name cannot be proven to stay inside the writable root by
308 // path checks. Atomic rename would replace the directory entry (leaving
309 // the outside link on the old inode), but that silently splits the pair
310 // and would not block a future non-atomic writer. Fail closed on both
311 // platforms that can count links.
312 if let Some(links) = hard_link_count(path)
313 && links > 1
314 {
315 return Err(std::io::Error::new(
316 std::io::ErrorKind::InvalidData,
317 format!(
318 "refusing to rewrite {}: the file has {links} hard links and path checks cannot prove the other links stay inside the workspace; copy it to a new name to break the link",
319 path.display(),
320 ),
321 ));
322 }
323 write_atomic_with_permissions(path, contents, AtomicWritePermissions::Workspace)
324 }
325
326 /// Hard-link count for an existing regular file, or `None` when the platform
327 /// cannot answer or the path is not a regular file.
328 ///
329 /// `None` means "unknown", never "one". A caller guarding against link
330 /// escapes must treat an unknown count as unguarded, not as safe.
331 #[cfg(unix)]
332 fn hard_link_count(path: &Path) -> Option<u64> {
333 let metadata = std::fs::metadata(path).ok()?;
334 metadata.is_file().then(|| metadata.nlink())
335 }
336
337 /// Windows counts links too — `std` does not expose it, but the Win32 call
338 /// that does is already used for the workspace `.env` guard in `lib.rs`.
339 /// Leaving this side unguarded meant a hard-linked file outside the
340 /// workspace was rewritable on Windows and refused on Unix, which is the
341 /// worse half of the platform to leave open.
342 ///
343 /// The handle is opened read-only and closed by `File`'s Drop, so this adds
344 /// one open/close on a path that is about to be rewritten anyway.
345 #[cfg(windows)]
346 fn hard_link_count(path: &Path) -> Option<u64> {
347 use std::os::windows::io::AsRawHandle;
348 use windows::Win32::Foundation::HANDLE;
349 use windows::Win32::Storage::FileSystem::{
350 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
351 };
352
353 let metadata = std::fs::metadata(path).ok()?;
354 if !metadata.is_file() {
355 return None;
356 }
357 let file = std::fs::File::open(path).ok()?;
358 let mut information = BY_HANDLE_FILE_INFORMATION::default();
359 // SAFETY: `file` owns a live kernel handle for the duration of the call
360 // and `information` stays writable across it. The call is synchronous and
361 // performs no path lookup or re-open.
362 unsafe {
363 GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information).ok()?;
364 }
365 Some(u64::from(information.nNumberOfLinks))
366 }
367
368 #[cfg(not(any(unix, windows)))]
369 fn hard_link_count(_path: &Path) -> Option<u64> {
370 None
371 }
372
373 /// Backoff before re-attempting a Windows atomic publication, or `None` when
374 /// `error` must be surfaced to the caller.
375 ///
376 /// Windows can briefly deny the rename that publishes a temporary file while
377 /// Defender, the indexer, or a concurrent reader still holds the source or the
378 /// destination without delete sharing. `MoveFileExW` then reports a sharing or
379 /// lock violation that clears on its own, while a real permission failure
380 /// repeats until the attempts run out.
381 ///
382 /// The ordinary and confined Fleet writers share this classification
383 /// and schedule, to tolerate brief sharing conflicts without letting
384 /// either path invent a broader retry of its own. Only the rename is
385 /// re-attempted: callers keep the temporary they already wrote and synced, so a
386 /// retry never rewrites bytes or widens the window in which data can be lost.
387 ///
388 /// The classification stays deliberately narrow. `ERROR_ALREADY_EXISTS` in
389 /// particular is a real answer for no-clobber publication — Fleet artifact
390 /// immutability depends on receiving it — so it is returned unchanged.
391 #[cfg(windows)]
392 pub(crate) fn windows_publish_retry_delay(
393 error: &std::io::Error,
394 attempt: usize,
395 ) -> Option<std::time::Duration> {
396 const MAX_PERSIST_ATTEMPTS: usize = 6;
397 // 5 ERROR_ACCESS_DENIED, 32 ERROR_SHARING_VIOLATION, 33 ERROR_LOCK_VIOLATION.
398 let transient = error.kind() == std::io::ErrorKind::PermissionDenied
399 || matches!(error.raw_os_error(), Some(5 | 32 | 33));
400 if !transient || attempt + 1 >= MAX_PERSIST_ATTEMPTS {
401 return None;
402 }
403 Some(std::time::Duration::from_millis(
404 10u64.saturating_mul(1u64 << attempt),
405 ))
406 }
407
408 fn write_atomic_with_permissions(
409 path: &Path,
410 contents: &[u8],
411 #[cfg_attr(not(unix), allow(unused_variables))] permission_policy: AtomicWritePermissions,
412 ) -> std::io::Result<()> {
413 write_atomic_scoped(path, contents, permission_policy, AtomicWriteScope::Single)
414 }
415
416 /// Whether one write also pays its directory's costs, or a batch pays them
417 /// once for the whole set — see [`write_atomic_batch`].
418 #[derive(Clone, Copy, PartialEq, Eq)]
419 enum AtomicWriteScope {
420 /// Sweep this directory for stale temp files and fsync it: what a single
421 /// write should do, and what every caller of `write_atomic` gets.
422 Single,
423 /// Leave both to the caller. Used only by [`write_atomic_batch`].
424 Batch,
425 }
426
427 /// Write many files, and pay each directory's costs once.
428 ///
429 /// Every [`write_atomic`] call sweeps its directory for stale temp files and
430 /// fsyncs that directory, which is exactly right for one record at a time. A
431 /// caller publishing a thousand records into one directory pays that thousand
432 /// times, though, and the sweep is the same answer every time: scanning a
433 /// store directory of tens of thousands of entries per file is minutes of work
434 /// that buys nothing (measured: 22 ms per item write, 34 s for one fork's
435 /// clone).
436 ///
437 /// The batch sweeps each directory once, writes every file with the same
438 /// atomic replace and per-file data sync, and fsyncs each directory once at
439 /// the end. Per-file durability is unchanged; only the per-file directory work
440 /// is hoisted out of the loop.
441 ///
442 /// Ordering is still the caller's job: a batch that publishes a graph of
443 /// records must write its commit record last, as the single-write callers do.
444 pub fn write_atomic_batch(files: &[(PathBuf, Vec<u8>)]) -> std::io::Result<()> {
445 let mut parents: Vec<&Path> = Vec::new();
446 for (path, _) in files {
447 if let Some(parent) = path.parent()
448 && !parents.contains(&parent)
449 {
450 parents.push(parent);
451 }
452 }
453 for parent in &parents {
454 if is_codewhale_owned_state_dir(parent) {
455 sweep_stale_atomic_write_temps(parent);
456 }
457 }
458 for (path, contents) in files {
459 write_atomic_scoped(
460 path,
461 contents,
462 AtomicWritePermissions::Private,
463 AtomicWriteScope::Batch,
464 )?;
465 }
466 for parent in &parents {
467 sync_directory(parent);
468 }
469 Ok(())
470 }
471
472 /// The file name Windows will create for `path`. The native rename used by
473 /// `write_atomic_scoped` takes the name literally, while Win32 path APIs drop
474 /// trailing dots and spaces and map device names (`CON`); refuse a name that
475 /// Windows would rewrite instead of creating a file nothing else can open.
476 /// A `:` names an NTFS alternate data stream (`notes:private`, `con:x`), so
477 /// the write would land in a hidden stream rather than a file; refuse it too.
478 #[cfg(windows)]
479 fn windows_atomic_target_name(path: &Path) -> std::io::Result<std::ffi::OsString> {
480 let invalid = || {
481 std::io::Error::new(
482 std::io::ErrorKind::InvalidInput,
483 format!(
484 "Windows would not write this file name as given: {}",
485 path.display()
486 ),
487 )
488 };
489 let name = path.file_name().ok_or_else(invalid)?;
490 if name.to_string_lossy().contains(':') {
491 return Err(invalid());
492 }
493 // Path normalization alone can leave a reserved DOS basename unchanged.
494 // Reject them explicitly, including extensions and the documented
495 // superscript port digits, before the native rename can create one.
496 let stem = name
497 .to_string_lossy()
498 .split('.')
499 .next()
500 .unwrap_or_default()
501 .trim_end_matches(' ')
502 .to_ascii_uppercase();
503 let reserved_port = stem
504 .strip_prefix("COM")
505 .or_else(|| stem.strip_prefix("LPT"))
506 .is_some_and(|port| {
507 matches!(
508 port,
509 "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
510 )
511 });
512 if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || reserved_port {
513 return Err(invalid());
514 }
515 let absolute = std::path::absolute(path)?;
516 if absolute.file_name() != Some(name)
517 || absolute.as_os_str().to_string_lossy().starts_with(r"\\.\")
518 {
519 return Err(invalid());
520 }
521 Ok(name.to_owned())
522 }
523
524 fn write_atomic_scoped(
525 path: &Path,
526 contents: &[u8],
527 #[cfg_attr(not(unix), allow(unused_variables))] permission_policy: AtomicWritePermissions,
528 scope: AtomicWriteScope,
529 ) -> std::io::Result<()> {
530 let parent = path.parent().ok_or_else(|| {
531 std::io::Error::new(
532 std::io::ErrorKind::InvalidInput,
533 format!("path has no parent directory: {}", path.display()),
534 )
535 })?;
536
537 // Capture ordinary rwx bits before replacement. Use symlink_metadata so we
538 // do not follow links: an inaccessible or dangling symlink target must not
539 // abort the write — rename still replaces the directory entry, matching
540 // the pre-#4606 private write_atomic behavior. Symlink entries themselves
541 // are treated as "no mode to preserve" (new ordinary file after rename);
542 // only regular-file modes are restored. Mask with 0o777 so setuid/setgid/
543 // sticky are never restored after rewriting content.
544 #[cfg(unix)]
545 let existing_workspace_mode = if permission_policy == AtomicWritePermissions::Workspace {
546 match fs::symlink_metadata(path) {
547 Ok(metadata) if metadata.file_type().is_symlink() => None,
548 Ok(metadata) => {
549 use std::os::unix::fs::PermissionsExt;
550 Some(metadata.permissions().mode() & 0o777)
551 }
552 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
553 Err(err) => return Err(err),
554 }
555 } else {
556 None
557 };
558
559 // Use parent directory so the rename is on the same filesystem.
560 #[cfg(unix)]
561 let mut builder = tempfile::Builder::new();
562 #[cfg(not(unix))]
563 let builder = tempfile::Builder::new();
564
565 // New workspace files should behave like ordinary files opened with
566 // creation mode 0666. The kernel applies the inherited process umask.
567 // Do NOT chmod after create: set_permissions bypasses umask.
568 #[cfg(unix)]
569 if permission_policy == AtomicWritePermissions::Workspace && existing_workspace_mode.is_none() {
570 use std::os::unix::fs::PermissionsExt;
571 builder.permissions(fs::Permissions::from_mode(0o666));
572 }
573
574 // Reclaim our own strays before adding another (see the function docs).
575 // Private permission policy is also used for user-chosen destinations
576 // such as `/save <path>`; only sweep Codewhale-owned state/config dirs.
577 if permission_policy == AtomicWritePermissions::Private
578 && scope == AtomicWriteScope::Single
579 && is_codewhale_owned_state_dir(parent)
580 {
581 sweep_stale_atomic_write_temps(parent);
582 }
583
584 // Validated before any temp exists, so a refused name leaves nothing.
585 #[cfg(windows)]
586 let target_name = windows_atomic_target_name(path)?;
587 #[cfg(not(windows))]
588 let mut tmp = builder.tempfile_in(parent)?;
589 // DELETE on the temp handle lets Windows rename through that handle.
590 #[cfg(windows)]
591 let mut tmp = {
592 use std::os::windows::fs::OpenOptionsExt;
593 use windows_sys::Win32::Storage::FileSystem::{
594 DELETE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ,
595 };
596 builder.make_in(parent, |temp| {
597 fs::OpenOptions::new()
598 .write(true)
599 .create_new(true)
600 .access_mode(FILE_GENERIC_READ | FILE_GENERIC_WRITE | DELETE)
601 .share_mode(FILE_SHARE_READ)
602 .open(temp)
603 })?
604 };
605
606 #[cfg(not(windows))]
607 {
608 std::io::Write::write_all(&mut tmp, contents)?;
609
610 // Atomic replacement creates a new inode. Restore ordinary access /
611 // executable bits of an existing workspace file before persisting.
612 #[cfg(unix)]
613 if let Some(mode) = existing_workspace_mode {
614 use std::os::unix::fs::PermissionsExt;
615 tmp.as_file()
616 .set_permissions(fs::Permissions::from_mode(mode))?;
617 }
618
619 tmp.as_file().sync_all()?;
620 tmp.persist(path)?;
621 }
622 #[cfg(windows)]
623 {
624 // MoveFileExW (tempfile::persist) reopens the destination directory
625 // with FILE_ADD_FILE, which conflicts with Fleet's read-only-shared
626 // ancestor pins (fleet/files.rs). Rename through the synced temp handle
627 // with a bare file name, so the parent is never reopened; the rename
628 // retries transient sharing/lock failures itself.
629 let result = (|| {
630 std::io::Write::write_all(&mut tmp, contents)?;
631 tmp.as_file().sync_all()?;
632 crate::fleet::files::rename_windows_opened(tmp.as_file(), &target_name, true)
633 })();
634 match result {
635 // The temp name is vacant now; never unlink a later entry.
636 Ok(()) => tmp.disable_cleanup(true),
637 Err(err) => {
638 // Close the delete-denying handle before deleting the temp.
639 let _ = tmp.into_temp_path().close();
640 return Err(std::io::Error::new(
641 err.kind(),
642 format!("replace {}: {err}", path.display()),
643 ));
644 }
645 }
646 }
647 // Fsync the parent directory so the rename (the new directory entry) is
648 // itself durable — otherwise a power loss right after the rename can lose
649 // it even though the file data was synced, silently dropping a
650 // crash-recovery checkpoint. Best-effort: not all platforms permit
651 // opening a directory for sync, so a failure here is not fatal. A batch
652 // hoists this to one sync per directory (see `write_atomic_batch`).
653 if scope == AtomicWriteScope::Single {
654 sync_directory(parent);
655 }
656 Ok(())
657 }
658
659 /// Best-effort directory sync: not all platforms permit opening a directory
660 /// for sync, so a failure here is never fatal.
661 fn sync_directory(parent: &Path) {
662 if let Ok(dir) = std::fs::File::open(parent) {
663 let _ = dir.sync_all();
664 }
665 }
666
667 /// True when `dir` is under `$CODEWHALE_HOME` / `~/.codewhale`, or the ambient
668 /// `~/.deepseek` legacy root when that root is still in play.
669 fn is_codewhale_owned_state_dir(dir: &Path) -> bool {
670 if dir.as_os_str().is_empty() {
671 return false;
672 }
673 let primary = codewhale_paths::codewhale_home().ok().flatten();
674 let legacy = (!codewhale_paths::codewhale_home_is_explicit())
675 .then(codewhale_paths::legacy_deepseek_home)
676 .flatten();
677 [primary, legacy].into_iter().flatten().any(|root| {
678 if root.as_os_str().is_empty() {
679 return false;
680 }
681 let Ok(physical_root) = std::fs::canonicalize(root) else {
682 return false;
683 };
684 let Ok(physical_dir) = std::fs::canonicalize(dir) else {
685 return false;
686 };
687 physical_dir.starts_with(physical_root)
688 })
689 }
690
691 /// Remove `.tmpXXXXXX` files this writer stranded in `dir` on an earlier run.
692 ///
693 /// `NamedTempFile` deletes itself on drop, so an ordinary failure — or an
694 /// ordinary exit — leaves nothing behind. A `SIGKILL` between `tempfile_in`
695 /// and `persist` cannot run a destructor, so the partial file survives, and
696 /// nothing ever collected it: five such strays (46 KB each, mode 0600) were
697 /// sitting in a real `~/.codewhale/` from a single day three weeks earlier.
698 /// They accumulate silently in the user's config directory forever.
699 ///
700 /// Deliberately conservative, because this deletes files under `$HOME`:
701 ///
702 /// - **Product directories only** — parent must be under `$CODEWHALE_HOME`
703 /// (or `~/.codewhale`) or the ambient `~/.deepseek` legacy root. User-chosen
704 /// destinations such as `/save <path>` keep the private permission policy
705 /// but are not swept (enforced at the call site).
706 /// - **Exact shape only** — `tempfile`'s default naming is the literal prefix
707 /// `.tmp` followed by exactly six alphanumerics and nothing else. A user file
708 /// called `.tmp`, `.tmpfile`, or `.tmp-backup` does not match.
709 /// - **Older than an hour** — so a concurrent write by another Codewhale
710 /// process is never raced. Same threshold and reasoning as
711 /// `shell_dispatcher::sweep_stale_temp_ps1`.
712 /// - **Best effort** — every failure is ignored; this must never turn a
713 /// successful write into an error.
714 fn sweep_stale_atomic_write_temps(dir: &Path) {
715 const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60 * 60);
716 let Ok(entries) = fs::read_dir(dir) else {
717 return;
718 };
719 for entry in entries.flatten() {
720 let name = entry.file_name();
721 let Some(name) = name.to_str() else {
722 continue;
723 };
724 if !is_stray_atomic_write_temp_name(name) {
725 continue;
726 }
727 // Only regular files; never follow or remove a symlink or directory.
728 let Ok(metadata) = entry.metadata() else {
729 continue;
730 };
731 if !metadata.is_file() {
732 continue;
733 }
734 let stale = metadata
735 .modified()
736 .ok()
737 .and_then(|modified| modified.elapsed().ok())
738 .is_some_and(|age| age > STALE_AFTER);
739 if stale {
740 let _ = fs::remove_file(entry.path());
741 }
742 }
743 }
744
745 /// `tempfile`'s default name: `.tmp` + exactly six ASCII alphanumerics.
746 fn is_stray_atomic_write_temp_name(name: &str) -> bool {
747 let Some(random) = name.strip_prefix(".tmp") else {
748 return false;
749 };
750 random.len() == 6 && random.chars().all(|c| c.is_ascii_alphanumeric())
751 }
752
753 /// Open or create a file for appending at `path`, optionally syncing after
754 /// every write. Use this for append-only logs like `audit.log`.
755 ///
756 /// The returned `BufWriter<fs::File>` wraps the append handle. Call
757 /// `.flush()` followed by `.get_ref().sync_all()` after each batch.
758 pub fn open_append(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
759 if let Some(parent) = path.parent() {
760 std::fs::create_dir_all(parent)?;
761 }
762 let file = private_log_options().append(true).open(path)?;
763 restrict_to_owner(&file)?;
764 Ok(std::io::BufWriter::new(file))
765 }
766
767 /// Open options for an owner-only log or lock file: created 0600 and never
768 /// opened through a link at the final component (Unix). Callers add the
769 /// access mode they need.
770 pub fn private_log_options() -> std::fs::OpenOptions {
771 let mut options = std::fs::OpenOptions::new();
772 options.create(true);
773 #[cfg(unix)]
774 {
775 use std::os::unix::fs::OpenOptionsExt;
776 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
777 }
778 options
779 }
780
781 /// Tighten a log created by an earlier version at the default mode. No-op
782 /// outside Unix.
783 pub fn restrict_to_owner(file: &std::fs::File) -> std::io::Result<()> {
784 #[cfg(unix)]
785 {
786 use std::os::unix::fs::PermissionsExt;
787 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
788 }
789 #[cfg(not(unix))]
790 let _ = file;
791 Ok(())
792 }
793
794 /// Flush a `BufWriter` wrapping a `File`, then `fsync` the underlying file.
795 pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> std::io::Result<()> {
796 writer.flush()?;
797 writer.get_ref().sync_all()
798 }
799
800 /// Open a URL in the system's default browser.
801 ///
802 /// Dispatches to the platform-appropriate opener:
803 /// - macOS: `open`
804 /// - Linux / BSD: `xdg-open`
805 /// - Windows: `rundll32 url.dll,FileProtocolHandler`
806 /// - Other: returns an error.
807 ///
808 /// This is the single entry point for URL opening — every call site in
809 /// the codebase should use this instead of hardcoding `Command::new("open")`,
810 /// `Command::new("xdg-open")`, or `Command::new("cmd")`.
811 pub fn open_url(url: &str) -> Result<()> {
812 let mut command = browser_open_command(url)?;
813 command
814 .stdout(std::process::Stdio::null())
815 .stderr(std::process::Stdio::null())
816 .spawn()
817 .map(|_| ())
818 .map_err(|e| anyhow::anyhow!("failed to launch browser command: {e}"))
819 }
820
821 fn browser_open_command(url: &str) -> Result<Command> {
822 if url.trim().is_empty() {
823 return Err(anyhow::anyhow!("browser URL cannot be empty"));
824 }
825
826 #[cfg(target_os = "macos")]
827 {
828 let mut command = Command::new("open");
829 command.arg(url);
830 Ok(command)
831 }
832
833 #[cfg(any(
834 all(target_os = "linux", not(target_env = "ohos")),
835 target_os = "netbsd",
836 target_os = "freebsd",
837 target_os = "openbsd",
838 target_os = "dragonfly"
839 ))]
840 {
841 let mut command = Command::new("xdg-open");
842 command.arg(url);
843 Ok(command)
844 }
845
846 // Not `cmd /C start`: cmd.exe would parse `&`, `|`, `^` and `%` inside
847 // the URL. The protocol handler receives it as data.
848 #[cfg(target_os = "windows")]
849 {
850 let mut cmd = Command::new("rundll32");
851 cmd.args(["url.dll,FileProtocolHandler", url]);
852 Ok(cmd)
853 }
854
855 #[cfg(not(any(
856 target_os = "macos",
857 all(target_os = "linux", not(target_env = "ohos")),
858 target_os = "windows",
859 target_os = "netbsd",
860 target_os = "freebsd",
861 target_os = "openbsd",
862 target_os = "dragonfly"
863 )))]
864 Err(anyhow::anyhow!(
865 "browser opening is unsupported on this platform"
866 ))
867 }
868
869 /// Spawn a tokio task with panic supervision.
870 ///
871 /// Wraps the future in `AssertUnwindSafe` + `catch_unwind`. On panic:
872 /// 1. Logs the panic with the task name and caller location via `tracing::error!`.
873 /// 2. Writes a crash dump to the selected profile's `crashes/` directory.
874 ///
875 /// The returned `JoinHandle` resolves to `()` — the panic is caught and
876 /// handled internally so the parent process stays alive.
877 pub fn spawn_supervised<F>(
878 name: &'static str,
879 location: &'static std::panic::Location<'static>,
880 future: F,
881 ) -> tokio::task::JoinHandle<()>
882 where
883 F: std::future::Future<Output = ()> + Send + 'static,
884 {
885 tokio::spawn(async move {
886 use futures_util::FutureExt;
887 let result = std::panic::AssertUnwindSafe(future).catch_unwind().await;
888 if let Err(panic_info) = result {
889 let msg = panic_message(&*panic_info);
890 tracing::error!(
891 target: "panic",
892 "Task '{name}' panicked at {}: {msg}",
893 location,
894 );
895 // Write crash dump (best-effort)
896 let _ = write_panic_dump(name, location, &msg);
897 }
898 })
899 }
900
901 /// Extract a human-readable message from a caught panic payload (the `Err`
902 /// value of `catch_unwind`). Mirrors how the panic hook formats `&str` and
903 /// `String` payloads so crash dumps stay consistent across call sites.
904 #[must_use]
905 pub fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
906 if let Some(s) = panic.downcast_ref::<&str>() {
907 (*s).to_string()
908 } else if let Some(s) = panic.downcast_ref::<String>() {
909 s.clone()
910 } else {
911 "unknown panic".to_string()
912 }
913 }
914
915 /// Record a panic that was caught at a call site (via `catch_unwind`) rather
916 /// than by a task supervisor. Logs it on the `panic` target and writes a
917 /// best-effort crash dump to the selected profile's `crashes/`, so diagnostics land in
918 /// the same place `spawn_supervised` writes them even when the caller recovers
919 /// and keeps running.
920 #[track_caller]
921 pub fn record_caught_panic(name: &'static str, message: &str) {
922 let location = std::panic::Location::caller();
923 tracing::error!(target: "panic", "Task '{name}' panicked at {location}: {message}");
924 let _ = write_panic_dump(name, location, message);
925 // A caught panic is still a panic. The site is allowlist-reduced to
926 // `crates/…` or the literal `<dep>`, and `message` is deliberately not
927 // read: a slicing panic embeds the entire string being sliced. The exit
928 // class is left alone — the caller recovered, so this process is not
929 // ending here. A no-op unless this process was armed.
930 codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic {
931 site: codewhale_telemetry::reduce_panic_site(
932 location.file(),
933 location.line(),
934 location.column(),
935 ),
936 });
937 }
938
939 /// Write a panic dump file to the selected profile's `crashes/` directory.
940 ///
941 /// Creates the directory if needed and writes a timestamped log
942 /// with the task name, caller location, and panic message.
943 /// Best-effort — failures are silently ignored.
944 fn write_panic_dump(
945 name: &str,
946 location: &std::panic::Location<'_>,
947 message: &str,
948 ) -> std::io::Result<()> {
949 let crash_dir = codewhale_config::codewhale_home()
950 .map_err(std::io::Error::other)?
951 .join("crashes");
952 write_panic_dump_to(&crash_dir, name, location, message)
953 }
954
955 fn write_panic_dump_to(
956 crash_dir: &Path,
957 name: &str,
958 location: &std::panic::Location<'_>,
959 message: &str,
960 ) -> std::io::Result<()> {
961 use chrono::Utc;
962 std::fs::create_dir_all(crash_dir)?;
963 let timestamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
964 let filename = format!("{timestamp}-{name}.log");
965 let path = crash_dir.join(&filename);
966 let contents =
967 format!("Task: {name}\nLocation: {location}\nTimestamp: {timestamp}\nPanic: {message}\n");
968 std::fs::write(&path, contents)?;
969 Ok(())
970 }
971
972 /// Fire-and-forget `spawn_blocking` with panic dump protection.
973 ///
974 /// In contrast to `spawn_supervised` (which wraps `tokio::spawn` for async
975 /// tasks), this helper wraps `tokio::task::spawn_blocking`. Use it when a
976 /// CPU-bound or blocking-I/O task must run off the async runtime and its
977 /// completion is *not* awaited — for example a post-turn disk snapshot or a
978 /// file-tree build polled later via a shared data structure. If the closure
979 /// panics, a crash dump is written to the selected profile's `crashes/` and the panic
980 /// is logged at ERROR level rather than being silently swallowed.
981 #[track_caller]
982 pub fn spawn_blocking_supervised<F>(name: &'static str, f: F) -> tokio::task::JoinHandle<()>
983 where
984 F: FnOnce() + Send + 'static,
985 {
986 let location = std::panic::Location::caller();
987 #[cfg(test)]
988 let env_ticket = crate::test_support::env_scope_ticket();
989 tokio::task::spawn_blocking(move || {
990 #[cfg(test)]
991 let _membership = crate::test_support::join_env_scope(env_ticket);
992 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
993 if let Err(panic_info) = result {
994 let msg = panic_message(&*panic_info);
995 tracing::error!(
996 target: "panic",
997 "Blocking task '{name}' panicked at {location}: {msg}",
998 );
999 let _ = write_panic_dump(name, location, &msg);
1000 }
1001 })
1002 }
1003
1004 /// Truncate a string to a maximum length, adding an ellipsis if truncated.
1005 ///
1006 /// Uses char boundaries to avoid panicking on multi-byte UTF-8 characters.
1007 #[must_use]
1008 pub fn truncate_with_ellipsis(s: &str, max_len: usize, ellipsis: &str) -> String {
1009 if s.len() <= max_len {
1010 return s.to_string();
1011 }
1012 let budget = max_len.saturating_sub(ellipsis.len());
1013 // Find the last char boundary that fits within the byte budget.
1014 let safe_end = s
1015 .char_indices()
1016 .map(|(i, _)| i)
1017 .take_while(|&i| i <= budget)
1018 .last()
1019 .unwrap_or(0);
1020 format!("{}{}", &s[..safe_end], ellipsis)
1021 }
1022
1023 /// Percent-encode a string for use in URL query parameters.
1024 ///
1025 /// Encodes all characters except unreserved characters (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`).
1026 /// Spaces are encoded as `+`.
1027 #[must_use]
1028 pub fn url_encode(input: &str) -> String {
1029 let mut encoded = String::new();
1030 for ch in input.bytes() {
1031 match ch {
1032 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1033 encoded.push(ch as char)
1034 }
1035 b' ' => encoded.push('+'),
1036 _ => encoded.push_str(&format!("%{ch:02X}")),
1037 }
1038 }
1039 encoded
1040 }
1041
1042 /// Render a path for **user-facing display** with the home directory
1043 /// contracted to `~`. Use this in the TUI, doctor/setup stdout, and any
1044 /// other place a viewer might see the output (screenshot, video,
1045 /// pasted-into-issue help). On macOS/Linux the absolute path
1046 /// `/Users/<name>/...` or `/home/<name>/...` reveals the OS account name,
1047 /// which is often the same as a public handle — undesirable for users
1048 /// who share their terminal.
1049 ///
1050 /// **Do not use** this for paths that get persisted (sessions, audit log)
1051 /// or sent to the LLM provider — those want full fidelity so they
1052 /// resolve correctly across processes.
1053 #[must_use]
1054 pub fn display_path(path: &Path) -> String {
1055 display_path_with_home(path, crate::config::effective_home_dir().as_deref())
1056 }
1057
1058 /// Like [`display_path`] but takes an explicit home directory instead of
1059 /// reading `$HOME` / `crate::config::effective_home_dir()`. Used in tests and anywhere the
1060 /// caller already has the home path available.
1061 ///
1062 /// The home-relative suffix is rejoined with the platform separator
1063 /// (`\` on Windows, `/` elsewhere) by walking the path's components, so
1064 /// inputs that carried foreign separators don't leak through.
1065 #[must_use]
1066 pub fn display_path_with_home(path: &Path, home: Option<&Path>) -> String {
1067 let Some(home) = home else {
1068 return path.display().to_string();
1069 };
1070 if let Ok(rest) = path.strip_prefix(home) {
1071 if rest.as_os_str().is_empty() {
1072 return "~".to_string();
1073 }
1074 let sep = std::path::MAIN_SEPARATOR_STR;
1075 let mut out = String::from("~");
1076 for component in rest.components() {
1077 out.push_str(sep);
1078 out.push_str(&component.as_os_str().to_string_lossy());
1079 }
1080 return out;
1081 }
1082 path.display().to_string()
1083 }
1084
1085 /// Estimate the total character count across message content blocks.
1086 #[must_use]
1087 pub fn estimate_message_chars(messages: &[Message]) -> usize {
1088 let mut total = 0;
1089 for msg in messages {
1090 for block in &msg.content {
1091 match block {
1092 ContentBlock::Text { text, .. } => total += text.len(),
1093 ContentBlock::Thinking { thinking, .. } => total += thinking.len(),
1094 ContentBlock::ToolUse { input, .. } => {
1095 let mut cw = CountingWriter::new();
1096 let _ = serde_json::to_writer(&mut cw, input);
1097 total += cw.count();
1098 }
1099 ContentBlock::ToolResult { content, .. } => total += content.len(),
1100 ContentBlock::ServerToolUse { .. }
1101 | ContentBlock::ToolSearchToolResult { .. }
1102 | ContentBlock::CodeExecutionToolResult { .. }
1103 | ContentBlock::ImageUrl { .. } => {}
1104 }
1105 }
1106 }
1107 total
1108 }
1109
1110 // Tests use `display_path_with_home` so they never mutate the global `HOME`
1111 // env var. Mutating `HOME` via `std::env::set_var` is not thread-safe; Cargo
1112 // runs tests in parallel by default and CI runners are multi-core, so any test
1113 // that stomps `HOME` will race with tests that *read* it. Using the injected
1114 // helper avoids the race entirely and makes the tests portable to Windows
1115 // without additional platform scaffolding.
1116 #[cfg(test)]
1117 mod tests {
1118 use super::{display_path_with_home, redacted_identifier_for_log};
1119 use std::path::PathBuf;
1120
1121 fn home(s: &str) -> Option<PathBuf> {
1122 Some(PathBuf::from(s))
1123 }
1124
1125 #[test]
1126 fn redacted_identifier_for_log_hides_value_and_stays_stable() {
1127 let identifier = "session-secret-1234567890";
1128 let redacted = redacted_identifier_for_log(identifier);
1129
1130 assert!(redacted.starts_with("<redacted:"));
1131 assert!(redacted.ends_with('>'));
1132 assert!(!redacted.contains(identifier));
1133 assert_eq!(redacted, redacted_identifier_for_log(identifier));
1134 assert_ne!(redacted, redacted_identifier_for_log("another-session"));
1135 }
1136
1137 #[test]
1138 fn redacted_identifier_for_log_marks_empty_values() {
1139 assert_eq!(redacted_identifier_for_log(""), "<redacted:empty>");
1140 }
1141
1142 #[test]
1143 fn display_path_contracts_home_prefix() {
1144 let h = home("/Users/alice");
1145 assert_eq!(
1146 display_path_with_home(&PathBuf::from("/Users/alice/projects/foo"), h.as_deref()),
1147 format!(
1148 "~{}projects{}foo",
1149 std::path::MAIN_SEPARATOR,
1150 std::path::MAIN_SEPARATOR
1151 ),
1152 );
1153 }
1154
1155 #[test]
1156 fn display_path_returns_bare_tilde_for_home_itself() {
1157 let h = home("/Users/alice");
1158 assert_eq!(
1159 display_path_with_home(&PathBuf::from("/Users/alice"), h.as_deref()),
1160 "~"
1161 );
1162 }
1163
1164 #[test]
1165 fn display_path_leaves_unrelated_paths_alone() {
1166 let h = home("/Users/alice");
1167 // Different user — must not get rewritten or share the tilde.
1168 assert_eq!(
1169 display_path_with_home(&PathBuf::from("/Users/bob/Code"), h.as_deref()),
1170 "/Users/bob/Code".to_string()
1171 );
1172 // System path must stay absolute.
1173 assert_eq!(
1174 display_path_with_home(&PathBuf::from("/etc/hosts"), h.as_deref()),
1175 "/etc/hosts"
1176 );
1177 }
1178
1179 #[test]
1180 fn display_path_does_not_match_username_prefix() {
1181 // Regression guard: a directory named like the user's home
1182 // *prefix* but not under it must not get rewritten.
1183 let h = home("/Users/alice");
1184 assert_eq!(
1185 display_path_with_home(&PathBuf::from("/Users/alice2/work"), h.as_deref()),
1186 "/Users/alice2/work"
1187 );
1188 }
1189
1190 #[test]
1191 fn display_path_with_no_home_returns_full_path() {
1192 assert_eq!(
1193 display_path_with_home(&PathBuf::from("/some/path"), None),
1194 "/some/path"
1195 );
1196 }
1197 }
1198
1199 #[cfg(test)]
1200 mod atomic_write_tests {
1201 use super::*;
1202 use std::fs;
1203 use tempfile::tempdir;
1204
1205 #[test]
1206 fn write_atomic_writes_content() {
1207 let tmp = tempdir().expect("tempdir");
1208 let path = tmp.path().join("test.json");
1209 let content = b"hello atomic world";
1210
1211 write_atomic(&path, content).expect("write_atomic");
1212 assert!(path.exists());
1213 let read = fs::read_to_string(&path).expect("read");
1214 assert_eq!(read.as_bytes(), content);
1215 }
1216
1217 /// A batch writes every file, and pays the directory's hygiene once.
1218 ///
1219 /// The per-file path sweeps the directory for stale temp files and fsyncs
1220 /// it on every call; a fork publishing hundreds of cloned items paid that
1221 /// hundreds of times (22 ms of directory scan each, 34 s for one fork).
1222 /// What must not change: every file lands with its content, a stray temp
1223 /// file is still reclaimed, and none of ours is left behind.
1224 #[test]
1225 fn write_atomic_batch_writes_every_file_and_sweeps_the_directory() {
1226 let _lock = crate::test_support::lock_test_env();
1227 let tmp = tempfile::TempDir::new().expect("tempdir");
1228 let (product, _guards) = seal_product_home(tmp.path());
1229
1230 let stray = product.join(".tmpCCCCCC");
1231 std::fs::write(&stray, b"stranded by a SIGKILL").expect("write stray");
1232 age_past_the_threshold(&stray);
1233
1234 let files: Vec<(PathBuf, Vec<u8>)> = (0..5)
1235 .map(|index| {
1236 (
1237 product.join(format!("item_{index}.json")),
1238 format!("{{\"index\":{index}}}").into_bytes(),
1239 )
1240 })
1241 .collect();
1242 super::write_atomic_batch(&files).expect("batch write");
1243
1244 for (path, contents) in &files {
1245 assert_eq!(
1246 std::fs::read(path).expect("read back"),
1247 *contents,
1248 "{}",
1249 path.display()
1250 );
1251 }
1252 assert!(
1253 !stray.exists(),
1254 "the batch sweeps the directory it writes into"
1255 );
1256 let leftovers: Vec<String> = std::fs::read_dir(&product)
1257 .expect("read dir")
1258 .flatten()
1259 .map(|entry| entry.file_name().to_string_lossy().into_owned())
1260 .filter(|name| super::is_stray_atomic_write_temp_name(name))
1261 .collect();
1262 assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
1263 }
1264
1265 #[test]
1266 fn write_atomic_replaces_existing_file() {
1267 let tmp = tempdir().expect("tempdir");
1268 let path = tmp.path().join("existing.json");
1269 fs::write(&path, b"old content").expect("write old");
1270 write_atomic(&path, b"new content").expect("write_atomic");
1271 let read = fs::read_to_string(&path).expect("read");
1272 assert_eq!(read, "new content");
1273 }
1274
1275 #[cfg(windows)]
1276 #[test]
1277 fn write_atomic_retries_windows_replace_contention() {
1278 use std::os::windows::fs::OpenOptionsExt;
1279
1280 let tmp = tempdir().expect("tempdir");
1281 let path = tmp.path().join("contended.json");
1282 fs::write(&path, b"old content").expect("write old");
1283
1284 // FILE_SHARE_READ | FILE_SHARE_WRITE deliberately omits
1285 // FILE_SHARE_DELETE, reproducing the short-lived handle contention
1286 // that makes MoveFileExW report access denied during replacement.
1287 let held = fs::OpenOptions::new()
1288 .read(true)
1289 .share_mode(0x1 | 0x2)
1290 .open(&path)
1291 .expect("hold destination without delete sharing");
1292 let release = std::thread::spawn(move || {
1293 std::thread::sleep(std::time::Duration::from_millis(50));
1294 drop(held);
1295 });
1296
1297 write_atomic(&path, b"new content").expect("retry contended atomic replacement");
1298 release.join().expect("release destination handle");
1299 assert_eq!(fs::read(&path).expect("read replacement"), b"new content");
1300 }
1301
1302 #[cfg(windows)]
1303 #[test]
1304 fn write_atomic_writes_beside_live_fleet_pins_and_refuses_rewritten_names() {
1305 // A live Fleet ledger keeps every ancestor of its files open with
1306 // read-only sharing, so MoveFileExW could not add an entry there
1307 // (hosted os error 32 writing mcp.json).
1308 let workspace = tempdir().expect("tempdir");
1309 let _pins = crate::fleet::files::WorkspaceFile::open(
1310 workspace.path(),
1311 Path::new(".codewhale/fleet.jsonl"),
1312 true,
1313 )
1314 .expect("pin workspace ancestors");
1315 let created = workspace.path().join("created.json");
1316 write_atomic(&created, b"new").expect("create beside pins");
1317 assert_eq!(fs::read(&created).expect("read created"), b"new");
1318 write_atomic(&created, b"replaced").expect("replace beside pins");
1319 assert_eq!(fs::read(&created).expect("read replaced"), b"replaced");
1320 for name in [
1321 "trailing.",
1322 "trailing ",
1323 "CON",
1324 "con.txt",
1325 "PRN",
1326 "AUX.log",
1327 "nul.tar.gz",
1328 "COM1",
1329 "com9.cfg",
1330 "LPT1",
1331 "lpt9.log",
1332 "COM¹",
1333 "COM².log",
1334 "COM³",
1335 "LPT¹",
1336 "LPT².log",
1337 "LPT³",
1338 "notes:private",
1339 "config.json:stream",
1340 "con:stream",
1341 "file::$DATA",
1342 ] {
1343 assert!(
1344 write_atomic(&workspace.path().join(name), b"x").is_err(),
1345 "{name}"
1346 );
1347 }
1348 for name in ["console.json", "CON-file", "COM10.txt", "LPT10.txt"] {
1349 let path = workspace.path().join(name);
1350 write_atomic(&path, b"ordinary").expect("write ordinary name");
1351 assert_eq!(fs::read(&path).expect("read ordinary name"), b"ordinary");
1352 }
1353 let strays: Vec<_> = fs::read_dir(workspace.path())
1354 .expect("read_dir")
1355 .filter_map(Result::ok)
1356 .filter(|entry| entry.file_name().to_string_lossy().starts_with(".tmp"))
1357 .collect();
1358 assert!(strays.is_empty(), "{strays:?}");
1359 }
1360
1361 #[test]
1362 fn write_atomic_no_temp_left_behind_on_success() {
1363 let tmp = tempdir().expect("tempdir");
1364 let path = tmp.path().join("clean.json");
1365 write_atomic(&path, b"clean").expect("write_atomic");
1366 // List files in dir — there should be no .tmp files left
1367 let entries: Vec<_> = fs::read_dir(tmp.path())
1368 .expect("read_dir")
1369 .filter_map(|e| e.ok())
1370 .collect();
1371 let tmp_files: Vec<_> = entries
1372 .iter()
1373 .filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with('.')))
1374 .collect();
1375 assert!(
1376 tmp_files.is_empty(),
1377 "temp files left behind: {tmp_files:?}"
1378 );
1379 }
1380
1381 #[cfg(any(unix, windows))]
1382 fn assert_workspace_hard_link_is_refused() {
1383 let dir = tempdir().expect("tempdir");
1384 let workspace = dir.path().join("workspace");
1385 let outside = dir.path().join("outside");
1386 fs::create_dir_all(&workspace).expect("create workspace");
1387 fs::create_dir_all(&outside).expect("create outside directory");
1388 let outside = outside.join("outside.txt");
1389 fs::write(&outside, b"outside").expect("outside state");
1390 let linked = workspace.join("linked.txt");
1391 fs::hard_link(&outside, &linked).expect("hard link");
1392
1393 let err = write_atomic_workspace(&linked, b"new").expect_err("must refuse");
1394 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1395 assert!(
1396 err.to_string().contains("hard links"),
1397 "the refusal must name the hard-link reason: {err}"
1398 );
1399 assert_eq!(
1400 fs::read_to_string(&outside).expect("outside read"),
1401 "outside",
1402 "the outside file must stay untouched"
1403 );
1404 assert_eq!(
1405 fs::read_to_string(&linked).expect("linked read"),
1406 "outside",
1407 "the workspace entry must stay untouched too"
1408 );
1409 // Breaking the link re-enables normal writes.
1410 fs::remove_file(&linked).expect("break link");
1411 write_atomic_workspace(&linked, b"fresh").expect("single-link write");
1412 assert_eq!(fs::read_to_string(&linked).expect("fresh read"), "fresh");
1413 assert_eq!(
1414 fs::read_to_string(&outside).expect("outside read"),
1415 "outside"
1416 );
1417 }
1418
1419 #[cfg(unix)]
1420 #[test]
1421 fn write_atomic_workspace_refuses_a_hard_linked_target() {
1422 assert_workspace_hard_link_is_refused();
1423 }
1424
1425 #[cfg(windows)]
1426 #[test]
1427 fn windows_write_atomic_workspace_refuses_a_hard_linked_target() {
1428 assert_workspace_hard_link_is_refused();
1429 }
1430
1431 #[cfg(windows)]
1432 #[test]
1433 fn windows_hard_link_count_detects_multiple_links() {
1434 let dir = tempdir().expect("tempdir");
1435 let first = dir.path().join("first.txt");
1436 let second = dir.path().join("second.txt");
1437 fs::write(&first, b"linked").expect("write fixture");
1438 fs::hard_link(&first, &second).expect("hard link");
1439
1440 assert_eq!(hard_link_count(&second), Some(2));
1441 }
1442
1443 #[cfg(unix)]
1444 #[test]
1445 fn write_atomic_workspace_new_file_matches_standard_creation_mode() {
1446 use std::os::unix::fs::PermissionsExt;
1447
1448 let dir = tempdir().expect("tempdir");
1449 let control = dir.path().join("control.txt");
1450 let actual = dir.path().join("actual.txt");
1451
1452 fs::write(&control, b"control").expect("write control");
1453 write_atomic_workspace(&actual, b"actual").expect("atomic workspace write");
1454
1455 let control_mode = fs::metadata(&control)
1456 .expect("control metadata")
1457 .permissions()
1458 .mode()
1459 & 0o777;
1460 let actual_mode = fs::metadata(&actual)
1461 .expect("actual metadata")
1462 .permissions()
1463 .mode()
1464 & 0o777;
1465
1466 assert_eq!(actual_mode, control_mode);
1467 assert_eq!(fs::read(&actual).expect("read"), b"actual");
1468 }
1469
1470 #[cfg(unix)]
1471 #[test]
1472 fn write_atomic_workspace_preserves_existing_mode() {
1473 use std::os::unix::fs::PermissionsExt;
1474
1475 let dir = tempdir().expect("tempdir");
1476 let path = dir.path().join("shared.txt");
1477 fs::write(&path, b"before").expect("initial write");
1478 fs::set_permissions(&path, fs::Permissions::from_mode(0o664))
1479 .expect("set shared permissions");
1480
1481 write_atomic_workspace(&path, b"after").expect("atomic workspace write");
1482
1483 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
1484 assert_eq!(mode, 0o664);
1485 assert_eq!(fs::read(&path).expect("read"), b"after");
1486 }
1487
1488 #[cfg(unix)]
1489 #[test]
1490 fn write_atomic_workspace_preserves_executable_bits() {
1491 use std::os::unix::fs::PermissionsExt;
1492
1493 let dir = tempdir().expect("tempdir");
1494 let path = dir.path().join("script.sh");
1495 fs::write(&path, b"#!/bin/sh\nexit 0\n").expect("initial write");
1496 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
1497 .expect("set executable permissions");
1498
1499 write_atomic_workspace(&path, b"#!/bin/sh\nexit 1\n").expect("atomic workspace write");
1500
1501 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
1502 assert_eq!(mode, 0o755);
1503 assert_eq!(fs::read(&path).expect("read"), b"#!/bin/sh\nexit 1\n");
1504 }
1505
1506 #[cfg(unix)]
1507 #[test]
1508 fn write_atomic_workspace_does_not_restore_special_bits() {
1509 use std::os::unix::fs::PermissionsExt;
1510
1511 let dir = tempdir().expect("tempdir");
1512 let path = dir.path().join("special.sh");
1513 fs::write(&path, b"#!/bin/sh\n").expect("initial write");
1514 // Request sticky + setgid + rwxr-xr-x. Filesystems may clear some
1515 // special bits; we only assert that after rewrite we never keep
1516 // bits outside the ordinary 0o777 mask.
1517 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o6755));
1518 let before = fs::metadata(&path)
1519 .expect("metadata before")
1520 .permissions()
1521 .mode();
1522 let expected_ordinary = before & 0o777;
1523
1524 write_atomic_workspace(&path, b"#!/bin/sh\necho rewritten\n")
1525 .expect("atomic workspace write");
1526
1527 let after = fs::metadata(&path)
1528 .expect("metadata after")
1529 .permissions()
1530 .mode();
1531 assert_eq!(after & 0o777, expected_ordinary);
1532 // `PermissionsExt::mode()` also contains the regular-file type bit on
1533 // macOS/BSD. Check only the Unix special permission bits rather than
1534 // treating every non-rwx bit as a restored permission.
1535 assert_eq!(after & 0o7000, 0, "special bits must not be restored");
1536 }
1537
1538 #[cfg(unix)]
1539 #[test]
1540 fn write_atomic_workspace_replaces_symlink_without_following_target() {
1541 use std::os::unix::fs::{PermissionsExt, symlink};
1542
1543 let dir = tempdir().expect("tempdir");
1544 let target = dir.path().join("target.txt");
1545 let link = dir.path().join("link.txt");
1546 fs::write(&target, b"target-body").expect("write target");
1547 fs::set_permissions(&target, fs::Permissions::from_mode(0o600))
1548 .expect("lock down target mode");
1549 symlink(&target, &link).expect("create symlink");
1550
1551 write_atomic_workspace(&link, b"replaced-link")
1552 .expect("workspace write must replace symlink directory entry");
1553
1554 let link_meta = fs::symlink_metadata(&link).expect("link metadata");
1555 assert!(
1556 link_meta.file_type().is_file() && !link_meta.file_type().is_symlink(),
1557 "rename should replace the symlink with a regular file"
1558 );
1559 assert_eq!(fs::read(&link).expect("read link path"), b"replaced-link");
1560 // Target inode must remain untouched (old private write_atomic semantics).
1561 assert_eq!(fs::read(&target).expect("read target"), b"target-body");
1562 assert_eq!(
1563 fs::metadata(&target)
1564 .expect("target metadata")
1565 .permissions()
1566 .mode()
1567 & 0o777,
1568 0o600
1569 );
1570 }
1571
1572 #[cfg(unix)]
1573 #[test]
1574 fn write_atomic_workspace_replaces_self_referential_symlink_without_following() {
1575 use std::os::unix::fs::symlink;
1576
1577 let dir = tempdir().expect("tempdir");
1578 let link = dir.path().join("self-link.txt");
1579 symlink(&link, &link).expect("create self-referential symlink");
1580
1581 assert!(
1582 fs::symlink_metadata(&link)
1583 .expect("lstat self-referential symlink")
1584 .file_type()
1585 .is_symlink()
1586 );
1587 let follow_error = fs::metadata(&link).expect_err("following the symlink must fail");
1588 assert_ne!(
1589 follow_error.kind(),
1590 std::io::ErrorKind::NotFound,
1591 "the fixture must catch a metadata-following regression"
1592 );
1593
1594 let result = write_atomic_workspace(&link, b"new-content");
1595 result.expect("workspace write must not follow a self-referential symlink");
1596 let link_meta = fs::symlink_metadata(&link).expect("link metadata");
1597 assert!(link_meta.file_type().is_file() && !link_meta.file_type().is_symlink());
1598 assert_eq!(fs::read(&link).expect("read"), b"new-content");
1599 }
1600
1601 #[cfg(unix)]
1602 #[test]
1603 fn write_atomic_private_new_file_does_not_gain_group_or_other_access() {
1604 use std::os::unix::fs::PermissionsExt;
1605
1606 let dir = tempdir().expect("tempdir");
1607 let path = dir.path().join("private.json");
1608
1609 write_atomic(&path, b"{}").expect("private atomic write");
1610
1611 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
1612 assert_eq!(mode & 0o077, 0);
1613 }
1614
1615 #[test]
1616 fn write_atomic_workspace_rewrites_a_single_link_file() {
1617 let tmp = tempdir().expect("tempdir");
1618 let path = tmp.path().join("workspace.txt");
1619 fs::write(&path, b"before").expect("write initial content");
1620 write_atomic_workspace(&path, b"workspace").expect("write_atomic_workspace");
1621 assert_eq!(fs::read(&path).expect("read"), b"workspace");
1622 }
1623
1624 #[test]
1625 fn flush_and_sync_writes_and_syncs() {
1626 let tmp = tempdir().expect("tempdir");
1627 let path = tmp.path().join("append.log");
1628 {
1629 let mut writer = open_append(&path).expect("open_append");
1630 writeln!(writer, "line 1").expect("write");
1631 flush_and_sync(&mut writer).expect("flush_and_sync");
1632 writeln!(writer, "line 2").expect("write");
1633 flush_and_sync(&mut writer).expect("flush_and_sync");
1634 }
1635 let content = fs::read_to_string(&path).expect("read");
1636 assert_eq!(content, "line 1\nline 2\n");
1637 }
1638
1639 // === stray atomic-write temp files ===
1640
1641 /// Backdate a file's mtime past the sweeper's one-hour threshold, using
1642 /// std rather than pulling in a dev-dependency just to age a fixture.
1643 fn age_past_the_threshold(path: &std::path::Path) {
1644 let two_hours_ago =
1645 std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
1646 let file = std::fs::File::options()
1647 .write(true)
1648 .open(path)
1649 .expect("open fixture");
1650 file.set_times(std::fs::FileTimes::new().set_modified(two_hours_ago))
1651 .expect("age the fixture");
1652 }
1653
1654 #[test]
1655 fn stray_temp_names_match_only_tempfiles_default_shape() {
1656 // What `tempfile` actually produces.
1657 assert!(super::is_stray_atomic_write_temp_name(".tmp0dqfST"));
1658 assert!(super::is_stray_atomic_write_temp_name(".tmpBcX9dY"));
1659 assert!(super::is_stray_atomic_write_temp_name(".tmpABC123"));
1660
1661 // User files that must never be swept.
1662 for safe in [
1663 ".tmp",
1664 ".tmpfile",
1665 ".tmp-backup",
1666 ".tmp12345", // five
1667 ".tmp1234567", // seven
1668 ".tmpABC12_", // underscore is not alphanumeric
1669 "tmpABC123", // no leading dot
1670 ".temp123456",
1671 "config.toml",
1672 ] {
1673 assert!(
1674 !super::is_stray_atomic_write_temp_name(safe),
1675 "{safe} must not be treated as ours to delete"
1676 );
1677 }
1678 }
1679
1680 #[test]
1681 fn sweeping_removes_only_old_strays_and_leaves_everything_else() {
1682 let dir = tempfile::TempDir::new().expect("tempdir");
1683 let old_stray = dir.path().join(".tmpAAAAAA");
1684 let fresh_stray = dir.path().join(".tmpBBBBBB");
1685 let user_file = dir.path().join(".tmp-please-keep");
1686 let real_file = dir.path().join("config.toml");
1687 for path in [&old_stray, &fresh_stray, &user_file, &real_file] {
1688 std::fs::write(path, b"x").expect("write fixture");
1689 }
1690
1691 age_past_the_threshold(&old_stray);
1692
1693 super::sweep_stale_atomic_write_temps(dir.path());
1694
1695 assert!(
1696 !old_stray.exists(),
1697 "an hour-old stray of ours is collected"
1698 );
1699 assert!(
1700 fresh_stray.exists(),
1701 "a fresh stray may belong to a concurrent write and must be left alone"
1702 );
1703 assert!(user_file.exists(), "a user file must never be swept");
1704 assert!(real_file.exists());
1705 }
1706
1707 /// Seal HOME / CODEWHALE_HOME to `tmp` so sweep policy is deterministic
1708 /// and never inspects the developer's real `~/.codewhale`.
1709 fn seal_product_home(
1710 tmp: &std::path::Path,
1711 ) -> (std::path::PathBuf, Vec<crate::test_support::EnvVarGuard>) {
1712 use crate::test_support::EnvVarGuard;
1713
1714 let product = tmp.join("product-home");
1715 std::fs::create_dir_all(&product).expect("create product home");
1716 let guards = vec![
1717 EnvVarGuard::set("HOME", tmp),
1718 EnvVarGuard::set("USERPROFILE", tmp),
1719 EnvVarGuard::set("CODEWHALE_HOME", &product),
1720 EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
1721 EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
1722 EnvVarGuard::remove("DEEPSEEK_HOME"),
1723 ];
1724 (product, guards)
1725 }
1726
1727 #[test]
1728 fn a_private_atomic_write_in_a_product_dir_collects_strays() {
1729 let _lock = crate::test_support::lock_test_env();
1730 let tmp = tempfile::TempDir::new().expect("tempdir");
1731 let (product, _guards) = seal_product_home(tmp.path());
1732
1733 assert!(
1734 super::is_codewhale_owned_state_dir(&product),
1735 "sealed CODEWHALE_HOME must count as a product dir"
1736 );
1737
1738 let stray = product.join(".tmpCCCCCC");
1739 std::fs::write(&stray, b"stranded by a SIGKILL").expect("write stray");
1740 age_past_the_threshold(&stray);
1741
1742 let target = product.join("state.json");
1743 super::write_atomic(&target, b"{\"ok\":true}").expect("atomic write");
1744
1745 assert_eq!(std::fs::read(&target).expect("read back"), b"{\"ok\":true}");
1746 assert!(!stray.exists(), "the write reclaimed the earlier stray");
1747 }
1748
1749 #[test]
1750 fn a_private_atomic_write_to_a_user_chosen_dest_does_not_sweep() {
1751 let _lock = crate::test_support::lock_test_env();
1752 let tmp = tempfile::TempDir::new().expect("tempdir");
1753 let (_product, _guards) = seal_product_home(tmp.path());
1754 let user_dir = tmp.path().join("user-chosen");
1755 std::fs::create_dir_all(&user_dir).expect("create user dest");
1756
1757 assert!(
1758 !super::is_codewhale_owned_state_dir(&user_dir),
1759 "user-chosen dest must not count as a product dir: {}",
1760 user_dir.display()
1761 );
1762
1763 let stray = user_dir.join(".tmpDDDDDD");
1764 std::fs::write(&stray, b"user or concurrent tempfile").expect("write stray");
1765 age_past_the_threshold(&stray);
1766
1767 let target = user_dir.join("session.json");
1768 super::write_atomic(&target, b"{\"ok\":true}").expect("atomic write");
1769
1770 assert_eq!(std::fs::read(&target).expect("read back"), b"{\"ok\":true}");
1771 assert!(
1772 stray.exists(),
1773 "/save <path> and other user-chosen dests must not sweep the parent"
1774 );
1775 }
1776
1777 #[test]
1778 fn atomic_write_product_dir_detection_matches_codewhale_home_not_siblings() {
1779 let _lock = crate::test_support::lock_test_env();
1780 let tmp = tempfile::TempDir::new().expect("tempdir");
1781 let (product, explicit_guards) = seal_product_home(tmp.path());
1782 let sessions = product.join("sessions");
1783 std::fs::create_dir_all(&sessions).expect("create sessions");
1784 let sibling = tmp.path().join("product-home-extra");
1785 std::fs::create_dir_all(&sibling).expect("create sibling");
1786
1787 assert!(super::is_codewhale_owned_state_dir(&product));
1788 assert!(super::is_codewhale_owned_state_dir(&sessions));
1789 assert!(!super::is_codewhale_owned_state_dir(&sibling));
1790 assert!(!super::is_codewhale_owned_state_dir(tmp.path()));
1791 drop(explicit_guards);
1792
1793 use crate::test_support::EnvVarGuard;
1794 let _home = EnvVarGuard::set("HOME", tmp.path());
1795 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1796 let _no_explicit = EnvVarGuard::remove("CODEWHALE_HOME");
1797 let _no_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
1798 let _no_legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
1799 let _no_legacy_home = EnvVarGuard::remove("DEEPSEEK_HOME");
1800 let primary_sessions = tmp.path().join(".codewhale").join("sessions");
1801 let legacy_home = tmp.path().join(".deepseek");
1802 std::fs::create_dir_all(&primary_sessions).expect("create primary sessions");
1803 std::fs::create_dir_all(&legacy_home).expect("create legacy home");
1804
1805 assert!(super::is_codewhale_owned_state_dir(&primary_sessions));
1806 assert!(super::is_codewhale_owned_state_dir(&legacy_home));
1807 assert!(!super::is_codewhale_owned_state_dir(
1808 &tmp.path().join("user-chosen")
1809 ));
1810 }
1811
1812 #[cfg(unix)]
1813 #[test]
1814 fn symlinked_product_subdir_cannot_sweep_an_external_directory() {
1815 use std::os::unix::fs::symlink;
1816
1817 let _lock = crate::test_support::lock_test_env();
1818 let tmp = tempfile::TempDir::new().expect("tempdir");
1819 let (product, _guards) = seal_product_home(tmp.path());
1820 let external = tmp.path().join("external");
1821 std::fs::create_dir_all(&external).expect("create external dir");
1822 let linked = product.join("exports");
1823 symlink(&external, &linked).expect("link product subdir outside");
1824
1825 let stray = external.join(".tmpEEEEEE");
1826 std::fs::write(&stray, b"external tempfile").expect("write external fixture");
1827 age_past_the_threshold(&stray);
1828
1829 assert!(
1830 !super::is_codewhale_owned_state_dir(&linked),
1831 "physical containment must reject a nested symlink escape"
1832 );
1833 super::write_atomic(&linked.join("state.json"), b"{\"ok\":true}")
1834 .expect("atomic write through link remains non-sweeping");
1835
1836 assert!(
1837 stray.exists(),
1838 "external temp-shaped files must not be swept"
1839 );
1840 }
1841 }
1842
1843 #[cfg(test)]
1844 mod spawn_supervised_tests {
1845 use super::*;
1846 use std::sync::Arc;
1847 use std::sync::atomic::{AtomicBool, Ordering};
1848
1849 /// A spawned task that panics does not propagate the panic to the
1850 /// parent task — `spawn_supervised` catches it. Verified in isolation
1851 /// from the on-disk crash-dump path so the test is portable across
1852 /// macOS / Linux / Windows (where `crate::config::effective_home_dir()` reads
1853 /// `USERPROFILE`, not `HOME`, so env-mutation tricks don't redirect
1854 /// the dump on Windows).
1855 #[tokio::test]
1856 async fn panicking_task_does_not_propagate_to_parent() {
1857 let parent_alive = Arc::new(AtomicBool::new(false));
1858 let parent_alive_clone = parent_alive.clone();
1859
1860 let handle = spawn_supervised(
1861 "panic-test-fixture",
1862 std::panic::Location::caller(),
1863 async move {
1864 parent_alive_clone.store(true, Ordering::SeqCst);
1865 panic!("deliberate panic for catch-unwind test");
1866 },
1867 );
1868
1869 let result = handle.await;
1870 assert!(
1871 result.is_ok(),
1872 "spawn_supervised must convert panic to a normal completion"
1873 );
1874 assert!(
1875 parent_alive.load(Ordering::SeqCst),
1876 "fixture task must have run before panicking"
1877 );
1878 }
1879
1880 #[tokio::test]
1881 async fn panicking_blocking_task_does_not_propagate_to_parent() {
1882 let parent_alive = Arc::new(AtomicBool::new(false));
1883 let parent_alive_clone = parent_alive.clone();
1884
1885 let handle = spawn_blocking_supervised("blocking-panic-test-fixture", move || {
1886 parent_alive_clone.store(true, Ordering::SeqCst);
1887 panic!("deliberate panic for spawn_blocking catch-unwind test");
1888 });
1889
1890 let result = handle.await;
1891 assert!(
1892 result.is_ok(),
1893 "spawn_blocking_supervised must convert panic to a normal completion"
1894 );
1895 assert!(
1896 parent_alive.load(Ordering::SeqCst),
1897 "fixture blocking task must have run before panicking"
1898 );
1899 }
1900
1901 /// The public writer keeps its named log in the selected profile even
1902 /// when another supervised task also writes a crash there.
1903 #[test]
1904 fn write_panic_dump_writes_named_log() {
1905 let _lock = crate::test_support::lock_test_env();
1906 let tmp = tempfile::tempdir().expect("tempdir");
1907 let _profile = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
1908 let crash_dir = tmp.path().join("crashes");
1909 let location = std::panic::Location::caller();
1910
1911 // Crash directories hold multiple tasks' logs. The other supervised
1912 // panic tests can write here while this process-wide profile is set.
1913 write_panic_dump("another-task", location, "other boom").expect("write other dump");
1914 let other_entries: Vec<_> = std::fs::read_dir(&crash_dir)
1915 .expect("crashes dir exists")
1916 .collect::<std::io::Result<Vec<_>>>()
1917 .expect("read crash entries")
1918 .into_iter()
1919 .filter(|entry| {
1920 entry
1921 .file_name()
1922 .to_string_lossy()
1923 .ends_with("-another-task.log")
1924 })
1925 .collect();
1926 assert_eq!(
1927 other_entries.len(),
1928 1,
1929 "exactly one other-task log expected"
1930 );
1931 let other_path = other_entries[0].path();
1932 let other_dump = std::fs::read(&other_path).expect("read other dump");
1933
1934 write_panic_dump("panic-fixture", location, "boom").expect("write dump");
1935 let entries: Vec<_> = std::fs::read_dir(&crash_dir)
1936 .expect("crashes dir exists")
1937 .collect::<std::io::Result<Vec<_>>>()
1938 .expect("read crash entries")
1939 .into_iter()
1940 .filter(|entry| {
1941 entry
1942 .file_name()
1943 .to_string_lossy()
1944 .ends_with("-panic-fixture.log")
1945 })
1946 .collect();
1947 assert_eq!(entries.len(), 1, "exactly one panic-fixture log expected");
1948 let dump = std::fs::read_to_string(entries[0].path()).expect("read dump");
1949 assert!(dump.lines().any(|line| line == "Task: panic-fixture"));
1950 assert!(
1951 dump.lines()
1952 .any(|line| line == format!("Location: {location}"))
1953 );
1954 assert!(dump.lines().any(|line| line == "Panic: boom"));
1955 assert_eq!(
1956 std::fs::read(other_path).expect("other dump remains"),
1957 other_dump,
1958 "writing a named crash must preserve other tasks' logs"
1959 );
1960 }
1961 }
1962
1963 #[cfg(test)]
1964 mod project_mapping_tests {
1965 use super::{project_tree, summarize_project};
1966 use std::fs;
1967 use tempfile::tempdir;
1968
1969 #[test]
1970 fn project_tree_sorts_siblings_alphabetically() {
1971 // Cross-platform readdir doesn't guarantee alphabetical order — on
1972 // ext4 with htree it's hash order, on APFS it's roughly insertion
1973 // order, on ZFS it's storage-class dependent. The system prompt
1974 // embeds this string in the cached prefix when a workspace has no
1975 // AGENTS.md / CLAUDE.md, so the function has to be byte-stable
1976 // across runs regardless of host filesystem.
1977 let tmp = tempdir().expect("tempdir");
1978 let root = tmp.path();
1979 // Create files in a deliberately scrambled order to make the
1980 // hosting filesystem's pre-sort (if any) less likely to mask a
1981 // missing sort in our code.
1982 fs::write(root.join("zebra.txt"), "z").expect("write zebra");
1983 fs::write(root.join("apple.txt"), "a").expect("write apple");
1984 fs::write(root.join("mango.txt"), "m").expect("write mango");
1985
1986 let tree = project_tree(root, 1, false);
1987 let lines: Vec<&str> = tree.lines().collect();
1988 let apple_pos = lines
1989 .iter()
1990 .position(|l| l.contains("apple.txt"))
1991 .expect("apple line");
1992 let mango_pos = lines
1993 .iter()
1994 .position(|l| l.contains("mango.txt"))
1995 .expect("mango line");
1996 let zebra_pos = lines
1997 .iter()
1998 .position(|l| l.contains("zebra.txt"))
1999 .expect("zebra line");
2000
2001 assert!(apple_pos < mango_pos);
2002 assert!(mango_pos < zebra_pos);
2003 }
2004
2005 #[test]
2006 fn project_tree_keeps_directory_before_its_children() {
2007 // Sorting siblings by full path is enough to preserve tree shape:
2008 // `"src" < "src/lib.rs"` because the shorter string compares less.
2009 let tmp = tempdir().expect("tempdir");
2010 let root = tmp.path();
2011 let src = root.join("src");
2012 fs::create_dir_all(&src).expect("mkdir src");
2013 fs::write(src.join("lib.rs"), "lib").expect("write lib");
2014 fs::write(src.join("main.rs"), "main").expect("write main");
2015
2016 let tree = project_tree(root, 2, false);
2017 let src_pos = tree.find("DIR: src").expect("src dir line");
2018 let lib_pos = tree.find("FILE: lib.rs").expect("lib file line");
2019 let main_pos = tree.find("FILE: main.rs").expect("main file line");
2020
2021 assert!(src_pos < lib_pos, "directory must precede its children");
2022 assert!(lib_pos < main_pos, "siblings sorted by name");
2023 }
2024
2025 #[test]
2026 fn project_tree_is_byte_stable_across_calls() {
2027 let tmp = tempdir().expect("tempdir");
2028 let root = tmp.path();
2029 fs::write(root.join("z.txt"), "z").expect("write");
2030 fs::write(root.join("a.txt"), "a").expect("write");
2031
2032 assert_eq!(project_tree(root, 1, false), project_tree(root, 1, false));
2033 }
2034
2035 #[test]
2036 #[cfg(unix)]
2037 fn project_mapping_does_not_follow_symlinked_key_files() {
2038 let tmp = tempdir().expect("tempdir");
2039 let root = tmp.path().join("workspace");
2040 let outside = tmp.path().join("outside");
2041 fs::create_dir_all(&root).expect("mkdir workspace");
2042 fs::create_dir_all(&outside).expect("mkdir outside");
2043 let outside_file = outside.join("Cargo.toml");
2044 fs::write(&outside_file, "[package]\nname = \"outside\"\n").expect("write outside");
2045 std::os::unix::fs::symlink(&outside_file, root.join("Cargo.toml")).expect("symlink");
2046
2047 assert_eq!(summarize_project(&root), "Unknown project type");
2048 assert!(!project_tree(&root, 1, false).contains("Cargo.toml"));
2049 }
2050
2051 #[test]
2052 fn summarize_project_sorts_key_files_in_fallback() {
2053 // When `summarize_project` can't classify a project type it falls
2054 // back to listing the discovered key files. That joined list must
2055 // be deterministic so the system prompt that embeds it doesn't
2056 // drift between runs on filesystems that emit readdir in a
2057 // non-alphabetical order.
2058 let tmp = tempdir().expect("tempdir");
2059 let root = tmp.path();
2060 // Use key files that don't trigger any of the type detectors
2061 // (Cargo.toml / package.json / requirements.txt) so the function
2062 // hits the `Project with key files: …` branch.
2063 fs::write(root.join("Makefile"), "all:").expect("write makefile");
2064 fs::write(root.join("README.md"), "# x").expect("write readme");
2065
2066 let summary = summarize_project(root);
2067 assert!(
2068 summary.starts_with("Project with key files: "),
2069 "expected fallback branch; got: {summary}"
2070 );
2071 let suffix = summary
2072 .strip_prefix("Project with key files: ")
2073 .expect("prefix");
2074 assert_eq!(suffix, "Makefile, README.md");
2075 }
2076
2077 // ===================================================================
2078 // open_url tests
2079 // ===================================================================
2080
2081 #[test]
2082 fn open_url_builds_platform_command_without_spawning() {
2083 let command = super::browser_open_command("https://example.com").expect("command");
2084
2085 #[cfg(target_os = "macos")]
2086 {
2087 assert_eq!(command.get_program(), "open");
2088 assert_eq!(
2089 command
2090 .get_args()
2091 .map(|arg| arg.to_string_lossy().into_owned())
2092 .collect::<Vec<_>>(),
2093 vec!["https://example.com"]
2094 );
2095 }
2096
2097 #[cfg(any(
2098 target_os = "netbsd",
2099 target_os = "freebsd",
2100 target_os = "openbsd",
2101 target_os = "dragonfly"
2102 ))]
2103 {
2104 assert_eq!(command.get_program(), "xdg-open");
2105 }
2106
2107 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
2108 {
2109 assert_eq!(command.get_program(), "xdg-open");
2110 assert_eq!(
2111 command
2112 .get_args()
2113 .map(|arg| arg.to_string_lossy().into_owned())
2114 .collect::<Vec<_>>(),
2115 vec!["https://example.com"]
2116 );
2117 }
2118
2119 #[cfg(target_os = "windows")]
2120 {
2121 assert_eq!(command.get_program(), "rundll32");
2122 assert_eq!(
2123 command
2124 .get_args()
2125 .map(|arg| arg.to_string_lossy().into_owned())
2126 .collect::<Vec<_>>(),
2127 vec!["url.dll,FileProtocolHandler", "https://example.com"]
2128 );
2129 // Shell metacharacters stay inside the single URL argument.
2130 let url = "https://example.com/?a=1&b=2|x^y%PATH%";
2131 let command = super::browser_open_command(url).expect("command");
2132 assert_eq!(command.get_program(), "rundll32");
2133 assert_eq!(
2134 command
2135 .get_args()
2136 .last()
2137 .map(|arg| arg.to_string_lossy().into_owned()),
2138 Some(url.to_string())
2139 );
2140 }
2141 }
2142
2143 #[test]
2144 fn open_url_rejects_empty_url_gracefully() {
2145 // An empty URL should fail with a clear error, not panic.
2146 let result = super::browser_open_command("");
2147 match result {
2148 Ok(_) => panic!("empty URL should not build an opener command"),
2149 Err(e) => {
2150 let msg = e.to_string();
2151 assert!(!msg.is_empty(), "error message must not be empty");
2152 assert!(msg.contains("empty"), "unexpected error message: {msg}");
2153 }
2154 }
2155 }
2156
2157 #[cfg(unix)]
2158 #[test]
2159 fn append_logs_are_owner_only_and_not_opened_through_links() {
2160 use std::os::unix::fs::PermissionsExt;
2161 let dir = tempfile::tempdir().expect("tempdir");
2162
2163 let log = dir.path().join("audit.log");
2164 drop(super::open_append(&log).expect("open"));
2165 let mode = std::fs::metadata(&log).unwrap().permissions().mode() & 0o777;
2166 assert_eq!(mode, 0o600);
2167
2168 // A log left world-readable by an earlier version is tightened.
2169 std::fs::set_permissions(&log, std::fs::Permissions::from_mode(0o644)).unwrap();
2170 drop(super::open_append(&log).expect("reopen"));
2171 let mode = std::fs::metadata(&log).unwrap().permissions().mode() & 0o777;
2172 assert_eq!(mode, 0o600);
2173
2174 let target = dir.path().join("elsewhere");
2175 std::fs::write(&target, "").unwrap();
2176 let link = dir.path().join("linked.log");
2177 std::os::unix::fs::symlink(&target, &link).unwrap();
2178 assert!(super::open_append(&link).is_err());
2179 }
2180 }
2181
2181 lines RUST