Skip to main content

agentlink_fs/
lib.rs

1//! The filesystem adapter.
2//!
3//! This crate is the only place that knows about native paths, separators,
4//! reparse points and platform privileges. It implements
5//! [`agentlink_domain::Workspace`] against a real rooted directory so
6//! the domain can stay a pure function of observable state.
7//!
8//! Three platform facts shape everything here:
9//!
10//! * **Windows junctions never require elevation.** `mklink /J` has always been
11//!   unprivileged. Since the highest-value resource — skills — is a *directory*,
12//!   agentlink can link it on any Windows machine out of the box.
13//! * **Windows symlinks do require elevation or Developer Mode.** Two machines
14//!   running the same Windows build can differ, so support is *probed at runtime*
15//!   rather than inferred from the target triple.
16//! * **Junctions store an absolute target; symlinks can store a relative one.**
17//!   Symlinks are therefore preferred: a relative target keeps the workspace
18//!   movable. Junctions are the directory-only fallback, and
19//!   [`RootedWorkspace::stale_junctions`] detects the ones a move left behind.
20
21#![forbid(unsafe_code)]
22#![warn(missing_docs, missing_debug_implementations)]
23
24use std::fs;
25use std::io;
26use std::path::{Component, Path, PathBuf};
27
28use agentlink_domain::model::{Entry, LinkSupport, LinkTarget, NodeKind, Via};
29use agentlink_domain::path::RelPath;
30use agentlink_domain::workspace::{FsError, FsResult, Workspace};
31
32/// A real directory, viewed through workspace-relative paths.
33#[derive(Debug, Clone)]
34pub struct RootedWorkspace {
35    root: PathBuf,
36    support: LinkSupport,
37}
38
39impl RootedWorkspace {
40    /// Opens `root`, probing which link primitives this host currently permits.
41    pub fn open(root: impl Into<PathBuf>) -> io::Result<Self> {
42        let root = root.into();
43        // Canonicalising resolves `..` and any links on the way in, so link
44        // targets can be compared against a stable root. The `\\?\` prefix
45        // Windows adds is stripped: it would otherwise surface in every message
46        // agentlink prints.
47        let root = fs::canonicalize(&root).map_or(root, |resolved| strip_verbatim(&resolved));
48        Ok(Self {
49            root,
50            support: probe_support(),
51        })
52    }
53
54    /// Opens `root` with a fixed capability set, for tests that need to exercise
55    /// a fallback path on a host that would not otherwise take it.
56    pub fn with_support(root: impl Into<PathBuf>, support: LinkSupport) -> Self {
57        Self {
58            root: root.into(),
59            support,
60        }
61    }
62
63    /// The absolute root of this workspace.
64    pub fn root(&self) -> &Path {
65        &self.root
66    }
67
68    /// Translates a workspace-relative path into a native one.
69    pub fn native(&self, path: &RelPath) -> PathBuf {
70        let mut native = self.root.clone();
71        for segment in path.segments() {
72            native.push(segment);
73        }
74        native
75    }
76
77    /// Links whose stored target no longer resolves inside this workspace.
78    ///
79    /// Junctions record an absolute path, so moving or copying a workspace leaves
80    /// them pointing at the old location — silently, and with the old content
81    /// still readable. `agentlink doctor` uses this to catch that case.
82    pub fn stale_junctions(&self, candidates: &[RelPath]) -> Vec<(RelPath, String)> {
83        candidates
84            .iter()
85            .filter_map(|path| match self.probe(path) {
86                Ok(Some(Entry {
87                    link: Some(LinkTarget::Outside(target)),
88                    ..
89                })) => Some((path.clone(), target)),
90                _ => None,
91            })
92            .collect()
93    }
94
95    /// Converts a raw link target into one the domain can compare.
96    fn interpret_target(&self, link: &RelPath, raw: &Path) -> LinkTarget {
97        let resolved = if raw.is_absolute() {
98            strip_verbatim(raw)
99        } else {
100            // Relative targets resolve against the directory holding the link.
101            let mut base = match link.parent() {
102                Some(parent) => self.native(&parent),
103                None => self.root.clone(),
104            };
105            base.push(raw);
106            base
107        };
108
109        let normalised = normalise(&resolved);
110        let root = normalise(&strip_verbatim(&self.root));
111
112        match normalised.strip_prefix(&root) {
113            Ok(relative) => {
114                let text = relative.to_string_lossy().replace('\\', "/");
115                match RelPath::new(&text) {
116                    Ok(rel) => LinkTarget::Inside(rel),
117                    // The link resolves to the workspace root itself, which no
118                    // capability can legitimately target.
119                    Err(_) => LinkTarget::Outside(normalised.to_string_lossy().into_owned()),
120                }
121            }
122            Err(_) => LinkTarget::Outside(normalised.to_string_lossy().into_owned()),
123        }
124    }
125}
126
127impl Workspace for RootedWorkspace {
128    fn probe(&self, path: &RelPath) -> FsResult<Option<Entry>> {
129        let native = self.native(path);
130        let meta = match fs::symlink_metadata(&native) {
131            Ok(meta) => meta,
132            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
133            Err(err) => return Err(FsError::new("inspect", path, err)),
134        };
135
136        if !meta.file_type().is_symlink() {
137            return Ok(Some(if meta.is_dir() {
138                Entry::dir()
139            } else {
140                Entry::file()
141            }));
142        }
143
144        // `is_symlink()` is true for both symlinks and junctions on Windows, and
145        // `read_link` understands both, so links are handled uniformly. The
146        // planner deliberately does not care which primitive was used: a link
147        // pointing at the right place is correct, and rewriting a working
148        // junction into a symlink would be churn for no benefit.
149        let raw =
150            fs::read_link(&native).map_err(|err| FsError::new("read the link at", path, err))?;
151        let node = link_node_kind(&native, &meta);
152        Ok(Some(Entry::link(node, self.interpret_target(path, &raw))))
153    }
154
155    fn read(&self, path: &RelPath) -> FsResult<String> {
156        fs::read_to_string(self.native(path)).map_err(|err| FsError::new("read", path, err))
157    }
158
159    fn write(&self, path: &RelPath, contents: &str) -> FsResult<()> {
160        if let Some(parent) = path.parent() {
161            self.create_dir_all(&parent)?;
162        }
163        fs::write(self.native(path), contents).map_err(|err| FsError::new("write", path, err))
164    }
165
166    fn create_dir_all(&self, path: &RelPath) -> FsResult<()> {
167        fs::create_dir_all(self.native(path))
168            .map_err(|err| FsError::new("create the directory", path, err))
169    }
170
171    fn link(
172        &self,
173        via: Via,
174        node: NodeKind,
175        canonical: &RelPath,
176        target: &RelPath,
177    ) -> FsResult<()> {
178        if let Some(parent) = target.parent() {
179            self.create_dir_all(&parent)?;
180        }
181        let link_path = self.native(target);
182
183        match via {
184            Via::Symlink => {
185                // A relative target keeps the workspace movable: copy the
186                // directory anywhere and the links still resolve.
187                let relative = canonical.relative_to_dir(target.parent().as_ref());
188                create_symlink(&relative, &link_path, node)
189                    .map_err(|err| FsError::new("create a symlink at", target, err))
190            }
191            Via::Junction => {
192                // Junctions cannot store a relative target, so this is absolute
193                // by necessity rather than by choice.
194                create_junction(&self.native(canonical), &link_path)
195                    .map_err(|err| FsError::new("create a junction at", target, err))
196            }
197            Via::Import => Err(FsError::new(
198                "create a link at",
199                target,
200                io::Error::new(
201                    io::ErrorKind::InvalidInput,
202                    "`import` writes a file and must not be routed through link()",
203                ),
204            )),
205        }
206    }
207
208    fn remove_link(&self, path: &RelPath, node: NodeKind) -> FsResult<()> {
209        let native = self.native(path);
210        let meta = fs::symlink_metadata(&native)
211            .map_err(|err| FsError::new("inspect the link at", path, err))?;
212
213        // The guard that makes this tool safe to run unattended: if the entry is
214        // not a link, it holds real content, and removing it would destroy work.
215        if !meta.file_type().is_symlink() {
216            return Err(FsError::new(
217                "remove the link at",
218                path,
219                io::Error::new(
220                    io::ErrorKind::InvalidInput,
221                    "refusing to remove: this path holds real content, not a link",
222                ),
223            ));
224        }
225
226        remove_link_native(&native, node, &meta)
227            .map_err(|err| FsError::new("remove the link at", path, err))
228    }
229
230    fn remove_file(&self, path: &RelPath) -> FsResult<()> {
231        fs::remove_file(self.native(path)).map_err(|err| FsError::new("remove", path, err))
232    }
233
234    fn remove_empty_dir(&self, path: &RelPath) -> FsResult<()> {
235        fs::remove_dir(self.native(path))
236            .map_err(|err| FsError::new("remove the directory", path, err))
237    }
238
239    fn rename(&self, from: &RelPath, to: &RelPath) -> FsResult<()> {
240        if let Some(parent) = to.parent() {
241            self.create_dir_all(&parent)?;
242        }
243        fs::rename(self.native(from), self.native(to))
244            .map_err(|err| FsError::new("move", from, err))
245    }
246
247    fn is_empty_dir(&self, path: &RelPath) -> FsResult<bool> {
248        let mut entries =
249            fs::read_dir(self.native(path)).map_err(|err| FsError::new("list", path, err))?;
250        Ok(entries.next().is_none())
251    }
252
253    fn support(&self) -> LinkSupport {
254        self.support
255    }
256}
257
258// ---------------------------------------------------------------------------
259// Path helpers
260// ---------------------------------------------------------------------------
261
262/// Removes a Windows `\\?\` verbatim prefix so paths compare as users write them.
263fn strip_verbatim(path: &Path) -> PathBuf {
264    let text = path.to_string_lossy();
265    match text.strip_prefix(r"\\?\") {
266        Some(rest) => PathBuf::from(rest),
267        None => path.to_path_buf(),
268    }
269}
270
271/// Resolves `.` and `..` lexically, without touching the filesystem.
272///
273/// Lexical resolution is the right choice here: it lets a link target be
274/// interpreted even when it dangles, which is exactly when the user most needs a
275/// clear diagnostic.
276fn normalise(path: &Path) -> PathBuf {
277    let mut out = PathBuf::new();
278    for component in path.components() {
279        match component {
280            Component::CurDir => {}
281            Component::ParentDir => {
282                out.pop();
283            }
284            other => out.push(other.as_os_str()),
285        }
286    }
287    out
288}
289
290// ---------------------------------------------------------------------------
291// Platform primitives
292// ---------------------------------------------------------------------------
293
294#[cfg(windows)]
295fn link_node_kind(_native: &Path, meta: &fs::Metadata) -> NodeKind {
296    use std::os::windows::fs::FileTypeExt;
297    if meta.file_type().is_symlink_dir() {
298        NodeKind::Dir
299    } else {
300        NodeKind::File
301    }
302}
303
304#[cfg(not(windows))]
305fn link_node_kind(native: &Path, _meta: &fs::Metadata) -> NodeKind {
306    // Following the link is the only way to tell on Unix. A dangling link has no
307    // answer; `File` is a safe default because the domain never uses the node
308    // kind of a link entry to decide what to remove.
309    match fs::metadata(native) {
310        Ok(meta) if meta.is_dir() => NodeKind::Dir,
311        _ => NodeKind::File,
312    }
313}
314
315#[cfg(windows)]
316fn create_symlink(relative_target: &str, link: &Path, node: NodeKind) -> io::Result<()> {
317    use std::os::windows::fs::{symlink_dir, symlink_file};
318    // Reparse points store the target verbatim, so it must use native separators.
319    let target = PathBuf::from(relative_target.replace('/', "\\"));
320    match node {
321        NodeKind::Dir => symlink_dir(target, link),
322        NodeKind::File => symlink_file(target, link),
323    }
324}
325
326#[cfg(not(windows))]
327fn create_symlink(relative_target: &str, link: &Path, _node: NodeKind) -> io::Result<()> {
328    std::os::unix::fs::symlink(relative_target, link)
329}
330
331#[cfg(windows)]
332fn create_junction(absolute_target: &Path, link: &Path) -> io::Result<()> {
333    junction::create(absolute_target, link)
334}
335
336#[cfg(not(windows))]
337fn create_junction(_absolute_target: &Path, _link: &Path) -> io::Result<()> {
338    Err(io::Error::new(
339        io::ErrorKind::Unsupported,
340        "junctions exist only on Windows",
341    ))
342}
343
344#[cfg(windows)]
345fn remove_link_native(native: &Path, _node: NodeKind, meta: &fs::Metadata) -> io::Result<()> {
346    use std::os::windows::fs::FileTypeExt;
347    // Directory links — symlinks and junctions alike — are removed with
348    // `remove_dir`, which unlinks the reparse point and never touches the
349    // content it points at.
350    if meta.file_type().is_symlink_dir() {
351        fs::remove_dir(native)
352    } else {
353        fs::remove_file(native)
354    }
355}
356
357#[cfg(not(windows))]
358fn remove_link_native(native: &Path, _node: NodeKind, _meta: &fs::Metadata) -> io::Result<()> {
359    fs::remove_file(native)
360}
361
362/// Determines which link primitives this host currently allows.
363#[cfg(windows)]
364fn probe_support() -> LinkSupport {
365    LinkSupport {
366        symlink_file: can_symlink(),
367        symlink_dir: can_symlink(),
368        junction: true,
369    }
370}
371
372#[cfg(not(windows))]
373fn probe_support() -> LinkSupport {
374    LinkSupport::FULL
375}
376
377/// Attempts a throwaway symlink to see whether this process holds
378/// `SeCreateSymbolicLinkPrivilege`.
379///
380/// Windows grants it only to elevated processes or when Developer Mode is on, so
381/// it cannot be inferred from the platform alone — it has to be tried.
382#[cfg(windows)]
383fn can_symlink() -> bool {
384    use std::os::windows::fs::symlink_file;
385    use std::time::{SystemTime, UNIX_EPOCH};
386
387    let nonce = SystemTime::now()
388        .duration_since(UNIX_EPOCH)
389        .map_or(0, |d| d.as_nanos())
390        .wrapping_add(u128::from(std::process::id()));
391    let probe = std::env::temp_dir().join(format!("agentlink-symlink-probe-{nonce}"));
392
393    // Windows permits dangling symlinks, so no real target is needed.
394    let allowed = symlink_file("agentlink-probe-target", &probe).is_ok();
395    let _ = fs::remove_file(&probe);
396    allowed
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn normalise_resolves_parent_segments_lexically() {
405        assert_eq!(normalise(Path::new("a/b/../c")), PathBuf::from("a/c"));
406        assert_eq!(normalise(Path::new("./a/./b")), PathBuf::from("a/b"));
407    }
408
409    #[test]
410    fn strip_verbatim_removes_the_windows_prefix() {
411        assert_eq!(
412            strip_verbatim(Path::new(r"\\?\C:\repo")),
413            PathBuf::from(r"C:\repo")
414        );
415        assert_eq!(
416            strip_verbatim(Path::new("/home/repo")),
417            PathBuf::from("/home/repo")
418        );
419    }
420}