1#![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#[derive(Debug, Clone)]
34pub struct RootedWorkspace {
35 root: PathBuf,
36 support: LinkSupport,
37}
38
39impl RootedWorkspace {
40 pub fn open(root: impl Into<PathBuf>) -> io::Result<Self> {
42 let root = root.into();
43 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 pub fn with_support(root: impl Into<PathBuf>, support: LinkSupport) -> Self {
57 Self {
58 root: root.into(),
59 support,
60 }
61 }
62
63 pub fn root(&self) -> &Path {
65 &self.root
66 }
67
68 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 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 fn interpret_target(&self, link: &RelPath, raw: &Path) -> LinkTarget {
97 let resolved = if raw.is_absolute() {
98 strip_verbatim(raw)
99 } else {
100 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 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 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 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 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 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
258fn 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
271fn 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#[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 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 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 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#[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#[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 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}