Skip to main content

workspace/
gc.rs

1use crate::cow::parse_base_project_name;
2use crate::state::{read_state, write_state};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime};
6use tracing::{debug, info};
7
8#[derive(Debug, Clone, Default)]
9pub struct GcReport {
10    pub total_workspaces_scanned: usize,
11    pub caches_trimmed_bytes: u64,
12    pub workspaces_deleted: usize,
13    pub workspaces_deleted_bytes: u64,
14    pub remaining_disk_bytes: u64,
15}
16
17/// Result of a CAS garbage-collection pass.
18#[derive(Debug, Clone, Default)]
19pub struct CasGcReport {
20    pub objects_deleted: usize,
21    pub bytes_freed: u64,
22    pub remaining_bytes: u64,
23}
24
25#[derive(Debug, Clone)]
26pub struct WorkspaceMetadata {
27    pub path: PathBuf,
28    pub name: String,
29    /// The project name this workspace serves, as the client spells it.
30    ///
31    /// This is *not* `name`: `name` is the on-disk directory, which is
32    /// sanitised and hash-suffixed. Locks are keyed by the client's project
33    /// name, so the lock predicate must be given this field. Passing `name`
34    /// made every lock check silently false.
35    pub project: String,
36    pub last_used_at: SystemTime,
37    pub size_bytes: u64,
38    pub is_canonical: bool,
39}
40
41/// Calculate total size of a directory recursively in bytes.
42pub fn calculate_dir_size(path: &Path) -> u64 {
43    let mut total = 0u64;
44    if let Ok(entries) = fs::read_dir(path) {
45        for entry in entries.flatten() {
46            // `DirEntry::metadata` does not follow symlinks: a link reports
47            // `is_symlink` and its own length, so `is_dir` is false and a
48            // link to a directory is never recursed into. That is what keeps
49            // a self-referential link from recursing forever and a link to `/`
50            // from inflating the total. `fs::symlink_metadata` would say the
51            // same thing; `fs::metadata` would NOT, and would reintroduce both.
52            if let Ok(meta) = entry.metadata() {
53                if meta.is_dir() {
54                    total += calculate_dir_size(&entry.path());
55                } else {
56                    total += meta.len();
57                }
58            }
59        }
60    }
61    total
62}
63
64/// Update workspace `last_used_at` timestamp.
65pub fn touch_workspace(workspace_dir: &Path, _project_name: &str) {
66    if let Some(mut state) = read_state(workspace_dir) {
67        state.last_installed_at = SystemTime::now();
68        let _ = write_state(workspace_dir, &state);
69    }
70
71    // Write / update .farhand-last-used marker file
72    let stamp = workspace_dir.join(".farhand-last-used");
73    let _ = fs::write(stamp, format!("{:?}", SystemTime::now()));
74}
75
76/// Retrieve last used timestamp for a workspace.
77pub fn get_workspace_last_used(workspace_dir: &Path) -> SystemTime {
78    if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-last-used")) {
79        if let Ok(mtime) = meta.modified() {
80            return mtime;
81        }
82    }
83
84    if let Some(state) = read_state(workspace_dir) {
85        return state.last_installed_at;
86    }
87
88    if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-state.json")) {
89        if let Ok(mtime) = meta.modified() {
90            return mtime;
91        }
92    }
93
94    if let Ok(meta) = fs::metadata(workspace_dir) {
95        if let Ok(mtime) = meta.modified() {
96            return mtime;
97        }
98    }
99
100    SystemTime::UNIX_EPOCH
101}
102
103/// Soft pruning: trims volatile compiler caches (e.g. incremental caches, node_modules/.cache).
104/// Keeps installed dependencies and build artifacts intact.
105pub fn trim_workspace_caches(workspace_dir: &Path) -> u64 {
106    let cache_dirs = [
107        "target/debug/incremental",
108        "target/release/incremental",
109        "node_modules/.cache",
110        ".next/cache",
111        "DerivedData",
112        ".gradle/caches",
113    ];
114
115    let mut freed_bytes = 0u64;
116    for rel_dir in cache_dirs {
117        let dir = workspace_dir.join(rel_dir);
118        if dir.is_dir() {
119            let size = calculate_dir_size(&dir);
120            if fs::remove_dir_all(&dir).is_ok() {
121                freed_bytes += size;
122                info!("Trimmed cache {} (freed {} bytes)", dir.display(), size);
123            }
124        }
125    }
126
127    freed_bytes
128}
129
130/// Scan all workspace directories under `workspaces_root`.
131pub fn scan_workspaces(workspaces_root: &Path) -> Vec<WorkspaceMetadata> {
132    let mut workspaces = Vec::new();
133
134    let entries = match fs::read_dir(workspaces_root) {
135        Ok(e) => e,
136        Err(_) => return workspaces,
137    };
138
139    for entry in entries.flatten() {
140        let path = entry.path();
141        if path.is_dir() {
142            let file_name = path
143                .file_name()
144                .and_then(|s| s.to_str())
145                .unwrap_or("")
146                .to_string();
147
148            // Skip non-workspace infrastructure dirs (CAS storage, state).
149            if file_name == "cas" || file_name.starts_with('.') {
150                continue;
151            }
152
153            let last_used = get_workspace_last_used(&path);
154            let size = calculate_dir_size(&path);
155
156            // A workspace is canonical if it does not have branch separators in its name
157            // or explicitly matches main/master
158            let is_canonical = parse_base_project_name(&file_name).is_none()
159                || file_name.contains("__main")
160                || file_name.contains("__master")
161                || file_name.contains(":main")
162                || file_name.contains(":master");
163
164            // Prefer the project name recorded when the workspace was last
165            // used; fall back to the directory name for workspaces created
166            // before this field existed, which is the pre-fix behaviour and
167            // therefore no worse.
168            let project = read_state(&path)
169                .map(|s| s.project)
170                .filter(|p| !p.is_empty())
171                .unwrap_or_else(|| file_name.clone());
172
173            workspaces.push(WorkspaceMetadata {
174                path,
175                name: file_name,
176                project,
177                last_used_at: last_used,
178                size_bytes: size,
179                is_canonical,
180            });
181        }
182    }
183
184    workspaces
185}
186
187/// Execute automated Garbage Collection across `workspaces_root`.
188///
189/// `skip_locked` decides whether a workspace (by project name) may be
190/// deleted or cache-trimmed — the daemon passes its workspace-lock check so
191/// active runs are never destroyed underneath themselves.
192pub fn run_garbage_collection(
193    workspaces_root: &Path,
194    max_disk_bytes: Option<u64>,
195    ttl: Option<Duration>,
196    skip_locked: &dyn Fn(&str) -> bool,
197) -> GcReport {
198    let mut report = GcReport::default();
199    let mut workspaces = scan_workspaces(workspaces_root);
200    report.total_workspaces_scanned = workspaces.len();
201
202    let now = SystemTime::now();
203
204    // 1. Evict any non-canonical workspace exceeding TTL
205    if let Some(ttl_dur) = ttl {
206        workspaces.retain(|ws| {
207            if !ws.is_canonical {
208                if skip_locked(&ws.project) {
209                    debug!("GC: skipping locked workspace {} (active run)", ws.name);
210                    return true;
211                }
212                if let Ok(age) = now.duration_since(ws.last_used_at) {
213                    if age > ttl_dur {
214                        info!(
215                            "TTL expired for workspace {} (age {:?} > {:?}). Purging...",
216                            ws.path.display(),
217                            age,
218                            ttl_dur
219                        );
220                        let _ = fs::remove_dir_all(&ws.path);
221                        report.workspaces_deleted += 1;
222                        report.workspaces_deleted_bytes += ws.size_bytes;
223                        return false;
224                    }
225                }
226            }
227            true
228        });
229    }
230
231    // 2. Check total storage quota
232    let current_total: u64 = workspaces.iter().map(|w| w.size_bytes).sum();
233    let mut current_usage = current_total;
234
235    if let Some(max_bytes) = max_disk_bytes {
236        if current_usage > max_bytes {
237            // Sort workspaces oldest first (LRU)
238            workspaces.sort_by_key(|a| a.last_used_at);
239
240            // Tier 1: Soft trim caches of non-canonical workspaces
241            for ws in &mut workspaces {
242                if current_usage <= max_bytes {
243                    break;
244                }
245                if !ws.is_canonical && !skip_locked(&ws.project) {
246                    let trimmed = trim_workspace_caches(&ws.path);
247                    report.caches_trimmed_bytes += trimmed;
248                    current_usage = current_usage.saturating_sub(trimmed);
249                    ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
250                }
251            }
252
253            // Tier 2: Hard eviction of non-canonical workspaces (oldest first)
254            for ws in &workspaces {
255                if current_usage <= max_bytes {
256                    break;
257                }
258                if !ws.is_canonical && !skip_locked(&ws.project) {
259                    info!(
260                        "Quota exceeded. Purging LRU workspace {} (size {} bytes)...",
261                        ws.path.display(),
262                        ws.size_bytes
263                    );
264                    let _ = fs::remove_dir_all(&ws.path);
265                    report.workspaces_deleted += 1;
266                    report.workspaces_deleted_bytes += ws.size_bytes;
267                    current_usage = current_usage.saturating_sub(ws.size_bytes);
268                }
269            }
270        }
271    }
272
273    report.remaining_disk_bytes = current_usage;
274    report
275}
276
277/// Emergency disk cleanup pass: trims caches in non-canonical workspaces,
278/// and if needed, evicts oldest non-canonical workspaces until target_bytes_to_free is reached.
279///
280/// `skip_locked` protects workspaces with active runs (same contract as
281/// [`run_garbage_collection`]).
282pub fn run_emergency_disk_gc(
283    workspaces_root: &Path,
284    target_bytes_to_free: u64,
285    skip_locked: &dyn Fn(&str) -> bool,
286) -> GcReport {
287    let mut report = GcReport::default();
288    let mut workspaces = scan_workspaces(workspaces_root);
289    report.total_workspaces_scanned = workspaces.len();
290
291    // Sort LRU: oldest last_used_at first
292    workspaces.sort_by_key(|w| w.last_used_at);
293
294    let mut freed_bytes = 0u64;
295
296    // Phase 1: Trim incremental / compiler caches in non-canonical workspaces
297    for ws in &mut workspaces {
298        if freed_bytes >= target_bytes_to_free {
299            break;
300        }
301        if !ws.is_canonical && !skip_locked(&ws.project) {
302            let trimmed = trim_workspace_caches(&ws.path);
303            report.caches_trimmed_bytes += trimmed;
304            freed_bytes += trimmed;
305            ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
306        }
307    }
308
309    // Phase 2: Purge oldest non-canonical workspaces if still below target
310    for ws in &workspaces {
311        if freed_bytes >= target_bytes_to_free {
312            break;
313        }
314        if !ws.is_canonical && !skip_locked(&ws.project) {
315            info!(
316                "Emergency GC: Purging LRU workspace {} (size {} bytes)...",
317                ws.path.display(),
318                ws.size_bytes
319            );
320            let _ = fs::remove_dir_all(&ws.path);
321            report.workspaces_deleted += 1;
322            report.workspaces_deleted_bytes += ws.size_bytes;
323            freed_bytes += ws.size_bytes;
324        }
325    }
326
327    report.remaining_disk_bytes = freed_bytes;
328    report
329}
330
331/// Garbage-collect the content-addressable store.
332///
333/// CAS is a **cache**: an evicted object is simply re-uploaded on the next
334/// manifest mismatch, so retention is a throughput trade, never a correctness
335/// one. Policy:
336/// 1. TTL — objects whose mtime is older than `ttl` are removed. Hydration
337///    touches object mtimes (see `CasStore::materialize_to`), so this means
338///    "unused since", not "ingested at".
339/// 2. Quota — if the store exceeds `max_bytes`, oldest-mtime objects are
340///    evicted first (LRU).
341/// 3. Stale `.tmp` files (aborted `put_file` uploads) are removed after an
342///    hour regardless of quota.
343pub fn gc_cas(
344    cas_objects_dir: &Path,
345    max_bytes: Option<u64>,
346    ttl: Option<Duration>,
347) -> CasGcReport {
348    let mut report = CasGcReport::default();
349
350    let mut objects: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
351    for entry in walkdir::WalkDir::new(cas_objects_dir)
352        .follow_links(false)
353        .into_iter()
354        .flatten()
355    {
356        let path = entry.path();
357        if !path.is_file() {
358            continue;
359        }
360        let meta = match entry.metadata() {
361            Ok(m) => m,
362            Err(_) => continue,
363        };
364        objects.push((
365            path.to_path_buf(),
366            meta.len(),
367            meta.modified().unwrap_or(SystemTime::now()),
368        ));
369    }
370
371    let now = SystemTime::now();
372    let mut remaining: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
373    let is_tmp = |p: &Path| {
374        p.file_name()
375            .and_then(|s| s.to_str())
376            .map(|s| s.contains(".tmp."))
377            .unwrap_or(false)
378    };
379
380    for (path, size, mtime) in objects {
381        let age = now.duration_since(mtime).unwrap_or_default();
382        let stale_tmp = is_tmp(&path) && age > Duration::from_secs(3600);
383        let expired = ttl.map(|t| age > t).unwrap_or(false);
384
385        if stale_tmp || expired {
386            if fs::remove_file(&path).is_ok() {
387                report.objects_deleted += 1;
388                report.bytes_freed += size;
389            } else {
390                remaining.push((path, size, mtime));
391            }
392        } else {
393            remaining.push((path, size, mtime));
394        }
395    }
396
397    // Quota: evict oldest-touched first.
398    if let Some(max_bytes) = max_bytes {
399        let mut total: u64 = remaining.iter().map(|(_, s, _)| *s).sum();
400        if total > max_bytes {
401            remaining.sort_by_key(|(_, _, mtime)| *mtime);
402            let mut survivors = Vec::new();
403            for (path, size, mtime) in remaining {
404                if total > max_bytes && fs::remove_file(&path).is_ok() {
405                    report.objects_deleted += 1;
406                    report.bytes_freed += size;
407                    total = total.saturating_sub(size);
408                } else {
409                    survivors.push((path, size, mtime));
410                }
411            }
412            remaining = survivors;
413        }
414    }
415
416    report.remaining_bytes = remaining.iter().map(|(_, s, _)| *s).sum();
417    report
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use tempfile::tempdir;
424
425    #[test]
426    fn test_trim_workspace_caches() {
427        let temp = tempdir().unwrap();
428        let ws = temp.path().join("my-ws");
429
430        let cache_dir = ws.join("target/debug/incremental");
431        fs::create_dir_all(&cache_dir).unwrap();
432        fs::write(cache_dir.join("cache.dat"), "1234567890").unwrap();
433
434        let src_file = ws.join("src/main.rs");
435        fs::create_dir_all(ws.join("src")).unwrap();
436        fs::write(&src_file, "fn main() {}").unwrap();
437
438        let freed = trim_workspace_caches(&ws);
439        assert!(freed >= 10);
440        assert!(!cache_dir.exists());
441        assert!(src_file.exists());
442    }
443
444    #[test]
445    fn test_run_garbage_collection_quota_eviction() {
446        let temp = tempdir().unwrap();
447        let root = temp.path();
448
449        // 1. Canonical workspace (should NOT be deleted)
450        let main_ws = root.join("my-repo__main-12345678");
451        fs::create_dir_all(&main_ws).unwrap();
452        fs::write(main_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
453
454        // 2. Old feature branch workspace
455        let feat_ws = root.join("my-repo__feat1-87654321");
456        fs::create_dir_all(&feat_ws).unwrap();
457        fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
458
459        // Quota is 1200 bytes, total is ~2000 bytes
460        let report = run_garbage_collection(root, Some(1200), None, &|_| false);
461
462        assert_eq!(report.workspaces_deleted, 1);
463        assert!(!feat_ws.exists());
464        assert!(main_ws.exists(), "Canonical workspace must be preserved");
465    }
466
467    #[test]
468    fn test_run_garbage_collection_skips_locked_workspaces() {
469        let temp = tempdir().unwrap();
470        let root = temp.path();
471
472        let feat_ws = root.join("my-repo__feat1-87654321");
473        fs::create_dir_all(&feat_ws).unwrap();
474        fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
475
476        // Quota forces eviction, but the workspace's project is locked
477        // (an active run holds it) — GC must leave it alone.
478        let report = run_garbage_collection(root, Some(500), None, &|name| {
479            name.starts_with("my-repo__feat1")
480        });
481
482        assert_eq!(report.workspaces_deleted, 0);
483        assert!(feat_ws.exists(), "locked workspace must survive GC");
484    }
485
486    /// Reproduces the production wiring, which the test above does not.
487    ///
488    /// The daemon does not hand GC a name predicate — it hands it a `HashSet`
489    /// of *client project names* taken from `locked_projects()`. The test above
490    /// instead matched on the on-disk directory name, so it passed while the
491    /// real check compared `my-repo:feat1` against `my-repo__feat1-87654321`,
492    /// never matched, and let GC delete workspaces out from under live runs.
493    #[test]
494    fn test_gc_lock_check_matches_the_project_name_not_the_directory() {
495        let temp = tempdir().unwrap();
496        let root = temp.path();
497
498        // Directory name is sanitised and hash-suffixed, as resolve_workspace_dir
499        // produces it.
500        let feat_ws = root.join("my-repo__feat1-87654321");
501        fs::create_dir_all(&feat_ws).unwrap();
502        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
503
504        // The workspace records the name the client actually used.
505        write_state(
506            &feat_ws,
507            &crate::state::WorkspaceState {
508                version: 1,
509                last_success_lockfile_hash: String::new(),
510                last_installed_at: SystemTime::now(),
511                template: "npm".to_string(),
512                project: "my-repo:feat1".to_string(),
513                toolchain: std::collections::BTreeMap::new(),
514            },
515        )
516        .unwrap();
517
518        // Exactly what fhd passes: a set of locked *project* names.
519        let locked: std::collections::HashSet<String> =
520            ["my-repo:feat1".to_string()].into_iter().collect();
521
522        let report = run_garbage_collection(root, Some(500), None, &|name| locked.contains(name));
523
524        assert_eq!(report.workspaces_deleted, 0, "GC deleted a live workspace");
525        assert!(
526            feat_ws.exists(),
527            "a workspace with an active run was evicted by quota"
528        );
529    }
530
531    /// The negative control for the test above: with nothing locked, the very
532    /// same setup must be evicted. Without this, a GC that ignored locks
533    /// entirely would also pass.
534    #[test]
535    fn test_gc_still_evicts_when_no_run_holds_the_lock() {
536        let temp = tempdir().unwrap();
537        let root = temp.path();
538
539        let feat_ws = root.join("my-repo__feat1-87654321");
540        fs::create_dir_all(&feat_ws).unwrap();
541        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
542        write_state(
543            &feat_ws,
544            &crate::state::WorkspaceState {
545                version: 1,
546                last_success_lockfile_hash: String::new(),
547                last_installed_at: SystemTime::now(),
548                template: "npm".to_string(),
549                project: "my-repo:feat1".to_string(),
550                toolchain: std::collections::BTreeMap::new(),
551            },
552        )
553        .unwrap();
554
555        let report = run_garbage_collection(root, Some(500), None, &|_| false);
556
557        assert_eq!(report.workspaces_deleted, 1);
558        assert!(!feat_ws.exists());
559    }
560
561    #[test]
562    fn test_run_emergency_disk_gc() {
563        let temp = tempdir().unwrap();
564        let root = temp.path();
565
566        let feat_ws = root.join("my-repo__feat1-87654321");
567        fs::create_dir_all(&feat_ws).unwrap();
568        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
569
570        let report = run_emergency_disk_gc(root, 1000, &|_| false);
571        assert_eq!(report.workspaces_deleted, 1);
572        assert!(!feat_ws.exists());
573    }
574
575    #[test]
576    fn test_run_emergency_disk_gc_skips_locked() {
577        let temp = tempdir().unwrap();
578        let root = temp.path();
579
580        let feat_ws = root.join("my-repo__feat1-87654321");
581        fs::create_dir_all(&feat_ws).unwrap();
582        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
583
584        let report = run_emergency_disk_gc(root, 1000, &|_| true);
585        assert_eq!(report.workspaces_deleted, 0);
586        assert!(feat_ws.exists());
587    }
588}
589
590#[cfg(test)]
591mod cas_gc_tests {
592    use super::*;
593    use std::time::Duration;
594
595    /// Build a fake CAS object layout: cas_objects_dir/ab/cd/<hash>.
596    fn make_object(dir: &Path, hash: &str, size: usize, age_secs: u64) -> PathBuf {
597        let path = dir.join(&hash[..2]).join(&hash[2..4]).join(hash);
598        fs::create_dir_all(path.parent().unwrap()).unwrap();
599        fs::write(&path, vec![0u8; size]).unwrap();
600        let old = SystemTime::now() - Duration::from_secs(age_secs);
601        let f = fs::File::options().write(true).open(&path).unwrap();
602        f.set_modified(old).unwrap();
603        path
604    }
605
606    #[test]
607    fn test_gc_cas_ttl_evicts_unused_objects() {
608        let dir = tempfile::tempdir().unwrap();
609        let fresh = make_object(dir.path(), "aa11fresh_object_1", 100, 0);
610        let stale = make_object(dir.path(), "bb22stale_object_2", 100, 40 * 86400);
611
612        let report = gc_cas(dir.path(), None, Some(Duration::from_secs(30 * 86400)));
613
614        assert_eq!(report.objects_deleted, 1);
615        assert_eq!(report.bytes_freed, 100);
616        assert!(fresh.is_file(), "fresh object must survive");
617        assert!(!stale.exists(), "stale object must be evicted");
618    }
619
620    #[test]
621    fn test_gc_cas_quota_evicts_lru_first() {
622        let dir = tempfile::tempdir().unwrap();
623        let old = make_object(dir.path(), "cc33old_object_1111", 400, 86400);
624        let new = make_object(dir.path(), "dd44new_object_1111", 400, 1);
625
626        // Quota 500 bytes, store has 800 → oldest (old) must go first.
627        let report = gc_cas(dir.path(), Some(500), None);
628
629        assert_eq!(report.objects_deleted, 1);
630        assert!(!old.exists(), "oldest object evicted under quota");
631        assert!(new.is_file(), "newest object survives under quota");
632    }
633
634    #[test]
635    fn test_gc_cas_removes_stale_tmp_files() {
636        let dir = tempfile::tempdir().unwrap();
637        let tmp = dir.path().join("ab").join("cd");
638        fs::create_dir_all(&tmp).unwrap();
639        let tmp_file = tmp.join("abcddeadbeef.tmp.12345.7");
640        fs::write(&tmp_file, vec![0u8; 50]).unwrap();
641        let old = SystemTime::now() - Duration::from_secs(7200);
642        let f = fs::File::options().write(true).open(&tmp_file).unwrap();
643        f.set_modified(old).unwrap();
644
645        let report = gc_cas(dir.path(), None, None);
646
647        assert!(!tmp_file.exists(), "stale tmp file must be cleaned");
648        assert_eq!(report.objects_deleted, 1);
649    }
650
651    #[test]
652    fn test_scan_workspaces_skips_cas_dir() {
653        let root = tempfile::tempdir().unwrap();
654        fs::create_dir_all(root.path().join("myrepo")).unwrap();
655        fs::create_dir_all(root.path().join("cas").join("objects")).unwrap();
656
657        let scanned = scan_workspaces(root.path());
658        let names: Vec<&str> = scanned.iter().map(|w| w.name.as_str()).collect();
659        assert_eq!(
660            names,
661            vec!["myrepo"],
662            "cas dir must not count as a workspace"
663        );
664    }
665}
666
667// Unix-only, and gated at the module rather than per test so the imports
668// below do not go unused on Windows and fail clippy there.
669#[cfg(all(test, unix))]
670mod symlink_accounting_tests {
671    use super::*;
672    use tempfile::tempdir;
673
674    /// Characterisation, not a regression: `DirEntry::metadata` already does
675    /// not follow symlinks, so this behaviour was never broken. The test pins
676    /// it, because switching to `fs::metadata` — the obvious-looking
677    /// "fix" — would reintroduce two real failures at once: infinite recursion
678    /// on a self-referential link, and a workspace reporting the size of the
679    /// whole filesystem, which the quota path would then act on by evicting
680    /// other workspaces.
681    #[cfg(unix)]
682    #[test]
683    fn symlinks_are_counted_as_links_and_never_recursed_into() {
684        let dir = tempdir().unwrap();
685        let root = dir.path();
686        fs::write(root.join("real.bin"), vec![7u8; 4096]).unwrap();
687        let before = calculate_dir_size(root);
688
689        // A self-referential link, and a link to a large tree.
690        let big = tempdir().unwrap();
691        fs::write(big.path().join("payload.bin"), vec![1u8; 4 * 1024 * 1024]).unwrap();
692        std::os::unix::fs::symlink(root, root.join("self")).unwrap();
693        std::os::unix::fs::symlink(big.path(), root.join("big")).unwrap();
694
695        let after = calculate_dir_size(root);
696
697        assert!(
698            after < 64 * 1024,
699            "a 4 MiB tree behind a link was counted: {before} -> {after}"
700        );
701        assert!(
702            after >= before,
703            "the real file's own bytes must still count"
704        );
705    }
706}