Skip to main content

workspace/
lib.rs

1// Unsafe is allowed only at the FFI boundaries listed in CONTRIBUTING.md
2// (CoW syscalls, statvfs), each with a SAFETY contract. Everything else —
3// locking, CAS, history, GC, presets — must stay pure safe Rust.
4#![deny(unsafe_code)]
5
6use fileset::FilesetError;
7use protocol::{FileEntry, ManifestPayload};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use tracing::debug;
13
14pub mod presets;
15pub use presets::{detect_preset_outputs, resolve_artifact_paths, Preset, DEFAULT_PRESETS};
16
17pub mod lock;
18pub use lock::WorkspaceLockManager;
19
20pub mod state;
21pub use state::{compute_lockfiles_hash, read_state, write_state, WorkspaceState};
22
23pub mod history;
24pub use history::{format_rfc3339, get_recent_runs, save_run, MAX_RUNS_RETAINED, RUNS_DIR};
25
26pub mod cow;
27pub use cow::{cow_clone_dir, cow_clone_file, find_seed_workspace, parse_base_project_name};
28
29pub mod cas;
30pub use cas::CasStore;
31
32pub mod gc;
33pub use gc::{
34    calculate_dir_size, gc_cas, get_workspace_last_used, run_emergency_disk_gc,
35    run_garbage_collection, scan_workspaces, touch_workspace, trim_workspace_caches, CasGcReport,
36    GcReport, WorkspaceMetadata,
37};
38
39pub mod disk;
40pub use disk::{get_disk_space, DiskSpace};
41
42pub mod change_token;
43pub use change_token::change_token;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct DiffResult {
47    /// List of forward-slash relative paths the agent needs the client to upload
48    pub want: Vec<String>,
49    /// List of forward-slash relative paths present remotely that should be deleted
50    pub delete_extraneous: Vec<String>,
51}
52
53/// Resolve a persistent workspace directory path based on a base root and project name.
54/// Example: `~/.farhand/workspaces/my-app-8a4b2e1f/`
55pub fn resolve_workspace_dir(base_dir: &Path, project_name: &str) -> PathBuf {
56    let mut hasher = Sha256::new();
57    hasher.update(project_name.as_bytes());
58    let hash = hasher.finalize();
59    let short_hash = hex::encode(&hash[..4]); // 8 hex characters
60
61    let clean_name: String = project_name
62        .chars()
63        .map(|c| {
64            if c.is_alphanumeric() || c == '-' || c == '_' {
65                c
66            } else {
67                '_'
68            }
69        })
70        .collect();
71
72    base_dir.join(format!("{}-{}", clean_name, short_hash))
73}
74
75/// Ensure a persistent workspace exists for `project_name`.
76/// If the directory does not exist, but an existing base/seed workspace exists
77/// (e.g. for `repo:main` when creating `repo:feat`), clones it via APFS CoW.
78pub fn ensure_workspace_dir(base_dir: &Path, project_name: &str) -> std::io::Result<PathBuf> {
79    let ws_dir = resolve_workspace_dir(base_dir, project_name);
80    if ws_dir.is_dir() {
81        touch_workspace(&ws_dir, project_name);
82        return Ok(ws_dir);
83    }
84
85    if let Some(seed_dir) = find_seed_workspace(base_dir, project_name) {
86        tracing::info!(
87            "Forking new branch workspace for '{}' from seed '{}' via APFS CoW...",
88            project_name,
89            seed_dir.display()
90        );
91        if let Err(e) = cow_clone_dir(&seed_dir, &ws_dir) {
92            tracing::warn!("CoW clone failed ({}); creating clean directory", e);
93            fs::create_dir_all(&ws_dir)?;
94        }
95    } else {
96        fs::create_dir_all(&ws_dir)?;
97    }
98
99    touch_workspace(&ws_dir, project_name);
100    Ok(ws_dir)
101}
102
103/// Returns the default workspaces root directory (`~/.farhand/workspaces`).
104pub fn default_workspaces_dir() -> PathBuf {
105    if let Ok(override_dir) = std::env::var("FARHAND_WORKDIR") {
106        return PathBuf::from(override_dir);
107    }
108
109    if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
110        PathBuf::from(home).join(".farhand").join("workspaces")
111    } else {
112        std::env::temp_dir().join("farhand").join("workspaces")
113    }
114}
115
116/// Diff client manifest against the remote workspace files, obeying Section 5.1 deletion safety.
117pub fn diff_manifests(
118    workspace_root: &Path,
119    client_manifest: &ManifestPayload,
120    extra_ignores: &[String],
121) -> Result<DiffResult, FilesetError> {
122    let mut client_map: HashMap<&str, &FileEntry> =
123        HashMap::with_capacity(client_manifest.files.len());
124    for f in &client_manifest.files {
125        client_map.insert(&f.path, f);
126    }
127
128    // The digest index lives in the workspace root, so it must be excluded from
129    // the walk or the scan would hash it and the diff would treat it as a
130    // stray file. `fileset::scan` already skips ignored names, and the
131    // Section 5.1 delete guard below independently skips `.farhand-*`, so the
132    // index cannot be deleted even if this exclusion were dropped.
133    let index_path = workspace_root.join(fileset::HASH_INDEX_FILENAME);
134    let mut ignores = extra_ignores.to_vec();
135    ignores.push(fileset::HASH_INDEX_FILENAME.to_string());
136
137    let remote_files = if workspace_root.exists() {
138        let mut cache = fileset::load_hash_cache(&index_path);
139        let (files, stats) =
140            fileset::scan_cached_with(workspace_root, &ignores, &mut cache, &change_token)?;
141
142        // A failed index write costs a full re-hash next run and nothing else,
143        // so it must not fail the diff.
144        if let Err(e) = fileset::save_hash_cache(&index_path, &cache) {
145            debug!("Could not write digest index: {e}");
146        }
147
148        debug!(
149            "Workspace scan reused {} of {} digests ({} re-hashed)",
150            stats.reused,
151            stats.reused + stats.hashed,
152            stats.hashed
153        );
154
155        files
156    } else {
157        HashMap::new()
158    };
159
160    let mut want = Vec::new();
161    for (rel_path, client_entry) in &client_map {
162        // A path the agent's own ignore rules exclude is not a file the agent
163        // will ever scan, so it must never be requested. The client scans with
164        // no extra ignores while the agent scans with the template's
165        // `ignoreExtra`, so without this a path present in both trees is
166        // absent from `remote_files`, lands in the `None` arm below, uploads,
167        // is excluded from the next scan, and is requested again — every run,
168        // forever.
169        if fileset::would_ignore(workspace_root, rel_path, false, extra_ignores) {
170            continue;
171        }
172
173        match remote_files.get(*rel_path) {
174            Some(remote_meta) => {
175                if remote_meta.hash != client_entry.hash {
176                    want.push((*rel_path).to_string());
177                }
178            }
179            // Not on the agent yet, and not ignored: genuinely new.
180            None => {
181                want.push((*rel_path).to_string());
182            }
183        }
184    }
185
186    let mut delete_extraneous = Vec::new();
187    for rel_path in remote_files.keys() {
188        if !client_map.contains_key(rel_path.as_str()) {
189            // Section 5.1 Deletion Safety Rule:
190            // Never delete files located in default ignored directories (node_modules, target, etc.)
191            // and never delete agent-internal state or history files (.farhand-state.json, .farhand-runs).
192            if !fileset::is_default_ignored(rel_path)
193                && rel_path != state::STATE_FILENAME
194                && !rel_path.starts_with(".farhand-")
195            {
196                delete_extraneous.push(rel_path.clone());
197            }
198        }
199    }
200
201    want.sort();
202    delete_extraneous.sort();
203
204    Ok(DiffResult {
205        want,
206        delete_extraneous,
207    })
208}
209
210/// Total on-disk size of the paths an artifact transfer would include.
211///
212/// Used to refuse an oversized artifact *before* anything is packed, since the
213/// archive is built in memory. Symlinks are counted as links, never followed,
214/// so a link cannot inflate the total.
215pub fn sum_artifact_bytes(workspace_root: &Path, rel_paths: &[String]) -> u64 {
216    let mut total = 0u64;
217    for rel in rel_paths {
218        let Ok(rel_buf) = protocol::from_wire_path(rel) else {
219            continue;
220        };
221        let path = workspace_root.join(rel_buf);
222        match std::fs::symlink_metadata(&path) {
223            Ok(m) if m.is_dir() => total = total.saturating_add(calculate_dir_size(&path)),
224            Ok(m) => total = total.saturating_add(m.len()),
225            Err(_) => continue,
226        }
227    }
228    total
229}
230
231/// Safely remove files listed in `to_delete` from `workspace_root` and prune empty parent folders.
232pub fn apply_deletions(workspace_root: &Path, to_delete: &[String]) -> std::io::Result<usize> {
233    if !workspace_root.exists() {
234        return Ok(0);
235    }
236
237    let canonical_root = workspace_root.canonicalize()?;
238    let mut count = 0;
239
240    for rel_path in to_delete {
241        let safe_rel = match protocol::from_wire_path(rel_path) {
242            Ok(p) => p,
243            Err(_) => continue,
244        };
245
246        let target = canonical_root.join(&safe_rel);
247        if target.exists() {
248            if let Ok(canonical_target) = target.canonicalize() {
249                if canonical_target.starts_with(&canonical_root)
250                    && canonical_target != canonical_root
251                    && fs::remove_file(&canonical_target).is_ok()
252                {
253                    count += 1;
254                    // Prune empty parent directories up to workspace root
255                    let mut parent = canonical_target.parent();
256                    while let Some(p) = parent {
257                        if p == canonical_root {
258                            break;
259                        }
260                        if fs::remove_dir(p).is_err() {
261                            break; // Directory not empty
262                        }
263                        parent = p.parent();
264                    }
265                }
266            }
267        }
268    }
269
270    Ok(count)
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use tempfile::tempdir;
277
278    #[test]
279    fn test_resolve_workspace_dir() {
280        let base = Path::new("/var/farhand/workspaces");
281        let dir1 = resolve_workspace_dir(base, "my-app");
282        let dir2 = resolve_workspace_dir(base, "my-app");
283        assert_eq!(dir1, dir2);
284        assert_eq!(dir1.parent(), Some(base));
285        assert!(dir1
286            .file_name()
287            .and_then(|n| n.to_str())
288            .is_some_and(|s| s.starts_with("my-app-")));
289
290        let dir3 = resolve_workspace_dir(base, "other-project");
291        assert_ne!(dir1, dir3);
292    }
293
294    #[test]
295    fn test_diff_fresh_workspace() {
296        let dir = tempdir().unwrap();
297        let ws = dir.path().join("ws");
298
299        let manifest = ManifestPayload {
300            files: vec![
301                FileEntry {
302                    path: "src/main.rs".into(),
303                    hash: "hash1".into(),
304                    size: 10,
305                    mode: 0o644,
306                },
307                FileEntry {
308                    path: "Cargo.toml".into(),
309                    hash: "hash2".into(),
310                    size: 20,
311                    mode: 0o644,
312                },
313            ],
314        };
315
316        let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
317        assert_eq!(diff.want, vec!["Cargo.toml", "src/main.rs"]);
318        assert!(diff.delete_extraneous.is_empty());
319    }
320
321    #[test]
322    fn test_diff_unchanged_workspace() {
323        let dir = tempdir().unwrap();
324        let ws = dir.path().join("ws");
325        fs::create_dir_all(ws.join("src")).unwrap();
326
327        fs::write(ws.join("src/main.rs"), b"fn main() {}").unwrap();
328        let hash = fileset::hash_file(&ws.join("src/main.rs")).unwrap();
329
330        let manifest = ManifestPayload {
331            files: vec![FileEntry {
332                path: "src/main.rs".into(),
333                hash,
334                size: 12,
335                mode: 0o644,
336            }],
337        };
338
339        let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
340        assert!(
341            diff.want.is_empty(),
342            "Unchanged files should not be in want: {:?}",
343            diff.want
344        );
345        assert!(diff.delete_extraneous.is_empty());
346    }
347
348    #[test]
349    fn test_diff_modified_file() {
350        let dir = tempdir().unwrap();
351        let ws = dir.path().join("ws");
352        fs::create_dir_all(&ws).unwrap();
353
354        fs::write(ws.join("file.txt"), b"remote content").unwrap();
355
356        let manifest = ManifestPayload {
357            files: vec![FileEntry {
358                path: "file.txt".into(),
359                hash: "different_local_hash".into(),
360                size: 20,
361                mode: 0o644,
362            }],
363        };
364
365        let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
366        assert_eq!(diff.want, vec!["file.txt"]);
367        assert!(diff.delete_extraneous.is_empty());
368    }
369
370    #[test]
371    fn test_deletion_safety_rule_section_5_1() {
372        let dir = tempdir().unwrap();
373        let ws = dir.path().join("ws");
374        fs::create_dir_all(ws.join("src")).unwrap();
375        fs::create_dir_all(ws.join("node_modules/react")).unwrap();
376        fs::create_dir_all(ws.join("target/release")).unwrap();
377
378        // 1. Legitimate source file that exists remotely
379        fs::write(ws.join("src/old_deleted_file.rs"), b"old").unwrap();
380
381        // 2. Remote cached dependencies (must NOT be deleted)
382        fs::write(ws.join("node_modules/react/index.js"), b"react").unwrap();
383        fs::write(ws.join("target/release/binary"), b"elf").unwrap();
384
385        // Client manifest only tracks new_file.rs (old_deleted_file.rs was removed locally)
386        let manifest = ManifestPayload {
387            files: vec![FileEntry {
388                path: "src/new_file.rs".into(),
389                hash: "new_hash".into(),
390                size: 10,
391                mode: 0o644,
392            }],
393        };
394
395        let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
396
397        // old_deleted_file.rs should be flagged for deletion
398        assert_eq!(diff.delete_extraneous, vec!["src/old_deleted_file.rs"]);
399
400        // CRITICAL: node_modules and target files MUST NOT be in delete_extraneous
401        assert!(!diff
402            .delete_extraneous
403            .iter()
404            .any(|p| p.contains("node_modules")));
405        assert!(!diff.delete_extraneous.iter().any(|p| p.contains("target")));
406
407        // Test apply_deletions
408        let deleted = apply_deletions(&ws, &diff.delete_extraneous).unwrap();
409        assert_eq!(deleted, 1);
410        assert!(!ws.join("src/old_deleted_file.rs").exists());
411
412        // Verify node_modules and target survived untouched
413        assert!(ws.join("node_modules/react/index.js").exists());
414        assert!(ws.join("target/release/binary").exists());
415    }
416
417    /// The digest index lives inside the scanned root, so it is the one file
418    /// most likely to be mistaken for project state. It must never be
419    /// transferred, never be deleted, and must not perturb the diff.
420    #[test]
421    fn test_digest_index_is_never_transferred_or_deleted() {
422        let dir = tempdir().unwrap();
423        let ws = dir.path().join("ws");
424        fs::create_dir_all(&ws).unwrap();
425        fs::write(ws.join("file.txt"), b"content").unwrap();
426
427        let manifest = ManifestPayload {
428            files: vec![FileEntry {
429                path: "file.txt".into(),
430                hash: fileset::hash_file(&ws.join("file.txt")).unwrap(),
431                size: 7,
432                mode: 0o644,
433            }],
434        };
435
436        let first = diff_manifests(&ws, &manifest, &[]).unwrap();
437        assert!(first.want.is_empty());
438        assert!(first.delete_extraneous.is_empty());
439
440        let index = ws.join(fileset::HASH_INDEX_FILENAME);
441        assert!(index.is_file(), "the diff did not persist a digest index");
442
443        // A second diff, driven entirely by the on-disk index, must agree.
444        let second = diff_manifests(&ws, &manifest, &[]).unwrap();
445        assert!(second.want.is_empty());
446        assert!(
447            second.delete_extraneous.is_empty(),
448            "the index was treated as project state: {:?}",
449            second.delete_extraneous
450        );
451
452        // And it must survive the deletions the diff asked for.
453        fs::write(ws.join("stray.txt"), b"stray").unwrap();
454        let third = diff_manifests(&ws, &manifest, &[]).unwrap();
455        assert_eq!(third.delete_extraneous, vec!["stray.txt"]);
456        apply_deletions(&ws, &third.delete_extraneous).unwrap();
457        assert!(index.is_file(), "the digest index was deleted");
458    }
459
460    /// A file edited on the agent between two diffs must be re-detected even
461    /// though the second diff reads its digest from the index rather than
462    /// hashing the file again.
463    #[test]
464    fn test_digest_index_does_not_mask_a_remote_edit() {
465        let dir = tempdir().unwrap();
466        let ws = dir.path().join("ws");
467        fs::create_dir_all(&ws).unwrap();
468        fs::write(ws.join("file.txt"), b"first").unwrap();
469
470        let stale = ManifestPayload {
471            files: vec![FileEntry {
472                path: "file.txt".into(),
473                hash: fileset::hash_file(&ws.join("file.txt")).unwrap(),
474                size: 5,
475                mode: 0o644,
476            }],
477        };
478
479        // Populate the index.
480        assert!(diff_manifests(&ws, &stale, &[]).unwrap().want.is_empty());
481
482        // The agent rewrites the file. Same length, so only a content change
483        // distinguishes it.
484        fs::write(ws.join("file.txt"), b"other").unwrap();
485
486        let diff = diff_manifests(&ws, &stale, &[]).unwrap();
487        assert_eq!(
488            diff.want,
489            vec!["file.txt"],
490            "the cached digest masked a remote edit"
491        );
492    }
493
494    /// A path the template ignores on the agent must never be requested, no
495    /// matter how many times the same manifest is replayed.
496    ///
497    /// The client scans with no extra ignores and the agent scans with the
498    /// template's `ignoreExtra`, so before this was fixed the file was absent
499    /// from `remote_files`, fell into the "not present" arm, was uploaded,
500    /// stayed excluded from the next scan, and was uploaded again on the next
501    /// run — a permanent re-upload that showed up as a sync which never
502    /// reaches zero bytes.
503    #[test]
504    fn test_ignored_path_is_never_requested_however_often_the_manifest_replays() {
505        let dir = tempdir().unwrap();
506        let ws = dir.path().join("ws");
507        fs::create_dir_all(&ws).unwrap();
508
509        // The file exists on the client and is in its manifest.
510        let client_side = dir.path().join("client");
511        fs::create_dir_all(&client_side).unwrap();
512        fs::write(client_side.join("build.log"), b"chatter").unwrap();
513
514        let manifest = ManifestPayload {
515            files: vec![FileEntry {
516                path: "build.log".into(),
517                hash: fileset::hash_file(&client_side.join("build.log")).unwrap(),
518                size: 7,
519                mode: 0o644,
520            }],
521        };
522
523        let ignored = vec!["*.log".to_string()];
524
525        // First run: the file is not on the agent and must not be requested.
526        let first = diff_manifests(&ws, &manifest, &ignored).unwrap();
527        assert!(
528            !first.want.contains(&"build.log".to_string()),
529            "an ignored path was requested: {:?}",
530            first.want
531        );
532
533        // Second run: nothing was uploaded, so the tree is unchanged and the
534        // answer must be identical. A regression here is what turned into an
535        // upload-forever loop.
536        let second = diff_manifests(&ws, &manifest, &ignored).unwrap();
537        assert_eq!(first.want, second.want, "the want set is not stable");
538        assert!(!second.want.contains(&"build.log".to_string()));
539    }
540
541    /// The negative control: a file the template does *not* ignore is still
542    /// transferred, so the fix cannot be satisfied by requesting nothing.
543    #[test]
544    fn test_unignored_new_file_is_still_requested() {
545        let dir = tempdir().unwrap();
546        let ws = dir.path().join("ws");
547        fs::create_dir_all(&ws).unwrap();
548
549        let client_side = dir.path().join("client");
550        fs::create_dir_all(&client_side).unwrap();
551        fs::write(client_side.join("main.rs"), b"fn main() {}").unwrap();
552
553        let manifest = ManifestPayload {
554            files: vec![FileEntry {
555                path: "main.rs".into(),
556                hash: fileset::hash_file(&client_side.join("main.rs")).unwrap(),
557                size: 12,
558                mode: 0o644,
559            }],
560        };
561
562        let diff = diff_manifests(&ws, &manifest, &["*.log".to_string()]).unwrap();
563        assert_eq!(diff.want, vec!["main.rs"]);
564    }
565
566    /// The blind spot a `(size, mtime)` gate cannot see, exercised through the
567    /// real platform change token and the real `diff_manifests` path. A build
568    /// tool that silently misses this ships stale code, so the guarantee is
569    /// worth pinning.
570    #[test]
571    fn test_diff_detects_a_same_size_overwrite_that_preserves_the_mtime() {
572        let dir = tempdir().unwrap();
573        let ws = dir.path().join("ws");
574        fs::create_dir_all(&ws).unwrap();
575        let path = ws.join("file.txt");
576        fs::write(&path, b"aaaa").unwrap();
577
578        let original_mtime = fs::metadata(&path).unwrap().modified().unwrap();
579
580        // The client's copy: same bytes the agent already has.
581        let manifest = ManifestPayload {
582            files: vec![FileEntry {
583                path: "file.txt".into(),
584                hash: fileset::hash_file(&path).unwrap(),
585                size: 4,
586                mode: 0o644,
587            }],
588        };
589        assert!(
590            diff_manifests(&ws, &manifest, &[]).unwrap().want.is_empty(),
591            "the first diff should see no change"
592        );
593
594        // Something on the agent rewrote the file to different content of the
595        // same length, then restored the original mtime — what an `rsync -a`
596        // or `tar -p` extraction onto the workspace does.
597        fs::write(&path, b"bbbb").unwrap();
598        restore_mtime(&path, original_mtime);
599
600        let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
601        assert_eq!(
602            diff.want,
603            vec!["file.txt"],
604            "a preserved-mtime same-size rewrite was masked by the digest cache"
605        );
606    }
607
608    #[cfg(unix)]
609    fn restore_mtime(path: &Path, mtime: std::time::SystemTime) {
610        use std::os::unix::fs::PermissionsExt;
611        let mode = fs::metadata(path).unwrap().permissions().mode();
612        fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
613        fs::File::options()
614            .write(true)
615            .open(path)
616            .unwrap()
617            .set_modified(mtime)
618            .unwrap();
619    }
620
621    #[cfg(windows)]
622    fn restore_mtime(path: &Path, mtime: std::time::SystemTime) {
623        // Windows does let userspace forge LastWriteTime, which is exactly why
624        // the digest gate reads the change time separately.
625        fs::File::options()
626            .write(true)
627            .open(path)
628            .unwrap()
629            .set_modified(mtime)
630            .unwrap();
631    }
632}