Skip to main content

artifactize_tools/
scope.rs

1//! Logical scope data and path resolution independent of project configuration.
2
3use std::{
4    borrow::Borrow,
5    collections::BTreeMap,
6    fmt,
7    fs::File,
8    io,
9    path::{Path, PathBuf},
10};
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::files as platform;
16
17/// Bound logical tool paths in UTF-16 units, matching JSON Schema string limits
18/// across supported clients without tying scoped paths to an OS-specific PATH_MAX.
19pub const MAX_PATH_UNITS: usize = 4096;
20
21/// Bound Artifact IDs like artifactize's Artifact names, so they stay short in tool output.
22pub const MAX_ARTIFACT_ID_BYTES: usize = 64;
23
24#[derive(Debug, Error)]
25#[error("{0}")]
26pub struct ScopeError(pub String);
27
28/// An Artifact's ID: `[A-Za-z0-9][A-Za-z0-9_-]*` with an optional `space::` prefix of the
29/// same form, at most 64 bytes, so it can never smuggle a path separator, a dot component,
30/// or control text into a scope key or a tool result.
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
32#[serde(transparent)]
33pub struct ArtifactId(String);
34
35impl ArtifactId {
36    pub fn new(value: impl Into<String>) -> Result<Self, ScopeError> {
37        let value = value.into();
38        let segment = |segment: &str| {
39            segment
40                .bytes()
41                .next()
42                .is_some_and(|first| first.is_ascii_alphanumeric())
43                && segment
44                    .bytes()
45                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
46        };
47        let valid = value.len() <= MAX_ARTIFACT_ID_BYTES
48            && match value.split_once("::") {
49                Some((space, name)) => segment(space) && segment(name),
50                None => segment(&value),
51            };
52        if valid {
53            Ok(Self(value))
54        } else {
55            Err(ScopeError(format!(
56                "Artifact ID must match [A-Za-z0-9][A-Za-z0-9_-]* with an optional space:: \
57                 prefix, at most 64 bytes: {value:?}"
58            )))
59        }
60    }
61
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65}
66
67/// Lets scope maps be looked up by a borrowed name; the derived order matches `str`'s.
68impl Borrow<str> for ArtifactId {
69    fn borrow(&self) -> &str {
70        &self.0
71    }
72}
73
74impl fmt::Display for ArtifactId {
75    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76        formatter.write_str(&self.0)
77    }
78}
79
80impl PartialEq<str> for ArtifactId {
81    fn eq(&self, other: &str) -> bool {
82        self.0 == other
83    }
84}
85
86impl PartialEq<&str> for ArtifactId {
87    fn eq(&self, other: &&str) -> bool {
88        self.0 == *other
89    }
90}
91
92impl PartialEq<String> for ArtifactId {
93    fn eq(&self, other: &String) -> bool {
94        self.0 == *other
95    }
96}
97
98/// A safe relative logical path below an Artifact: `/`-separated plain names, such as a
99/// child Artifact's folder prefix. It holds no empty, `.` or `..` component.
100macro_rules! logical_name {
101    ($name:ident, $what:literal, $valid:expr) => {
102        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
103        #[serde(transparent)]
104        pub struct $name(String);
105
106        impl $name {
107            pub fn new(value: impl Into<String>) -> Result<Self, ScopeError> {
108                let value = value.into();
109                if !value.is_empty() && logical_path(&value).is_ok() && ($valid)(&value) {
110                    Ok(Self(value))
111                } else {
112                    Err(ScopeError(format!(
113                        concat!("Invalid ", $what, ": {:?}"),
114                        value
115                    )))
116                }
117            }
118
119            pub fn as_str(&self) -> &str {
120                &self.0
121            }
122        }
123
124        impl Borrow<str> for $name {
125            fn borrow(&self) -> &str {
126                &self.0
127            }
128        }
129
130        impl std::ops::Deref for $name {
131            type Target = str;
132            fn deref(&self) -> &str {
133                &self.0
134            }
135        }
136
137        impl fmt::Display for $name {
138            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139                formatter.write_str(&self.0)
140            }
141        }
142    };
143}
144
145logical_name!(ChildPrefix, "child folder prefix", |_: &str| true);
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub enum ArtifactKind {
150    Folder,
151    File,
152    Graph,
153}
154
155/// Access data computed by the caller, without declarations, evals, or owner commands.
156#[derive(Debug, Clone)]
157pub struct Artifact {
158    pub path: PathBuf,
159    pub kind: ArtifactKind,
160    pub name: String,
161    /// Logical path prefix to the child Artifact that owns it.
162    pub children: BTreeMap<ChildPrefix, ArtifactId>,
163}
164
165impl Artifact {
166    pub fn folder(&self) -> &Path {
167        match self.kind {
168            ArtifactKind::Folder | ArtifactKind::Graph => &self.path,
169            ArtifactKind::File => self.path.parent().unwrap_or(Path::new("")),
170        }
171    }
172
173    pub fn file_name(&self) -> Option<&str> {
174        (self.kind == ArtifactKind::File)
175            .then(|| self.path.file_name().and_then(|name| name.to_str()))
176            .flatten()
177    }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ScopedPath {
182    pub artifact_id: ArtifactId,
183    pub path: String,
184}
185
186#[derive(Debug)]
187pub struct Scope {
188    pub artifacts: BTreeMap<ArtifactId, Artifact>,
189}
190
191impl Scope {
192    /// Pure logical resolution. Each child hop consumes path components.
193    pub fn resolve_path(
194        &self,
195        artifact_id: &ArtifactId,
196        path: &str,
197    ) -> Result<ScopedPath, ScopeError> {
198        logical_path(path)?;
199        let mut current = artifact_id;
200        let mut remaining = path;
201        loop {
202            let artifact = self
203                .artifacts
204                .get(current)
205                .ok_or_else(|| ScopeError(format!("Artifact is outside this review: {current}")))?;
206            if remaining.is_empty() {
207                break;
208            }
209            if let Some((prefix, target)) = artifact.children.iter().find(|(prefix, _)| {
210                remaining == prefix.as_str()
211                    || remaining
212                        .strip_prefix(prefix.as_str())
213                        .is_some_and(|rest| rest.starts_with('/'))
214            }) {
215                current = target;
216                remaining = remaining[prefix.len()..].strip_prefix('/').unwrap_or("");
217                continue;
218            }
219            break;
220        }
221        let artifact = &self.artifacts[current];
222        file_input(artifact, remaining)?;
223        Ok(ScopedPath {
224            artifact_id: current.clone(),
225            path: remaining.to_owned(),
226        })
227    }
228
229    /// Existing file or directory, without traversing links even inside the owner.
230    pub fn resolve_input(
231        &self,
232        root: &Path,
233        artifact_id: &ArtifactId,
234        path: &str,
235    ) -> Result<PathBuf, ScopeError> {
236        let location = self.resolve_path(artifact_id, path)?;
237        let artifact = &self.artifacts[&location.artifact_id];
238        let owner = scoped_path(root, artifact.folder())?;
239        let path = if location.path.is_empty() {
240            artifact.file_name().unwrap_or("")
241        } else {
242            &location.path
243        };
244        let resolved = scoped_path(&owner, Path::new(path))?;
245        let file =
246            open_input(root, artifact, path).map_err(|error| ScopeError(error.to_string()))?;
247        let regular = file
248            .metadata()
249            .map_err(|error| ScopeError(error.to_string()))?
250            .is_file();
251        if artifact.file_name().is_some() && !regular {
252            return Err(ScopeError(
253                "File Artifact target must remain a regular file.".into(),
254            ));
255        }
256        Ok(resolved)
257    }
258}
259
260/// Why a scoped open failed. A missing entry stays distinct, so a caller that knows the
261/// logical path the user asked for can name it.
262#[derive(Debug, Error)]
263pub enum OpenError {
264    #[error("No such file or directory.")]
265    NotFound,
266    /// A component is a link, which scoped access never follows.
267    #[error("Cannot open Artifact input without symlink traversal.")]
268    Link,
269    #[error(transparent)]
270    Refused(#[from] ScopeError),
271}
272
273/// Open each component relative to its pinned parent, so replacement cannot redirect a read
274/// through a link.
275pub fn open_input(root: &Path, artifact: &Artifact, path: &str) -> Result<File, OpenError> {
276    if !platform::is_absolute(root) || platform::is_absolute(&artifact.path) {
277        return Err(
278            ScopeError("Artifact roots must be absolute and owner paths relative.".into()).into(),
279        );
280    }
281    logical_path(path)?;
282    file_input(artifact, path)?;
283    let owner = open_scoped(&root.join(artifact.folder()), "")?;
284    // Keep the owner pinned between checking its namespace and opening each component.
285    // Check the parent of each reserved component: a nested APFS mount can have a different
286    // case policy from its owner volume. Exact-name checks additionally reject OS aliases.
287    let components: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect();
288    let mut file = owner;
289    for (depth, component) in components.iter().enumerate() {
290        let child = artifact.children.keys().any(|prefix| {
291            let prefix: Vec<_> = prefix.split('/').collect();
292            prefix.len() == depth + 1
293                && prefix
294                    .iter()
295                    .zip(&components)
296                    .all(|(left, right)| left.eq_ignore_ascii_case(right))
297        });
298        if child
299            && !platform::case_sensitive(&file).map_err(|error| ScopeError(error.to_string()))?
300        {
301            return Err(ScopeError("Physical input conflicts with a child name.".into()).into());
302        }
303        file = open_child(&file, std::ffi::OsStr::new(component))?;
304    }
305    if artifact.file_name().is_some()
306        && !path.is_empty()
307        && !file
308            .metadata()
309            .map_err(|e| ScopeError(e.to_string()))?
310            .is_file()
311    {
312        return Err(ScopeError("File Artifact target must remain a regular file.".into()).into());
313    }
314    Ok(file)
315}
316
317/// A file Artifact exposes a virtual root containing only its target.
318fn file_input(artifact: &Artifact, path: &str) -> Result<(), ScopeError> {
319    if let Some(target) = artifact.file_name()
320        && !path.is_empty()
321        && path != target
322    {
323        return Err(ScopeError(format!(
324            "File Artifact {} exposes only its target {target}.",
325            artifact.name
326        )));
327    }
328    Ok(())
329}
330
331/// Open a relative path below an absolute root without following any symlink components.
332pub fn open_scoped(root: &Path, path: &str) -> Result<File, OpenError> {
333    logical_path(path)?;
334    if !platform::is_absolute(root) {
335        return Err(ScopeError("Scoped roots must be absolute.".into()).into());
336    }
337    let (filesystem_root, names) = platform::split_root(root)
338        .ok_or_else(|| ScopeError("Artifact path must not traverse parent directories.".into()))?;
339    let mut directory =
340        platform::open_directory(filesystem_root).map_err(|e| ScopeError(e.to_string()))?;
341    // Host-selected roots may use system aliases, such as short names. Logical components
342    // below that trusted root must use exact entry spellings. Both walks stay
343    // descriptor-relative and refuse links in every component.
344    let components = names.into_iter().map(|name| (name, false)).chain(
345        path.split('/')
346            .filter(|part| !part.is_empty())
347            .map(|part| (std::ffi::OsStr::new(part), true)),
348    );
349    for (name, exact) in components {
350        directory = match name {
351            name if name != ".." && name != "." => open_named(&directory, name, exact)?,
352            _ => {
353                return Err(ScopeError(
354                    "Artifact path must not traverse parent directories.".into(),
355                )
356                .into());
357            }
358        };
359    }
360    Ok(directory)
361}
362
363/// Open one entry of a pinned directory without following a symlink.
364pub fn open_child(directory: &File, name: &std::ffi::OsStr) -> Result<File, OpenError> {
365    open_named(directory, name, true)
366}
367
368fn open_named(directory: &File, name: &std::ffi::OsStr, exact: bool) -> Result<File, OpenError> {
369    let entry_name = platform::EntryName::new(name)
370        .ok_or_else(|| ScopeError("Invalid Artifact path.".into()))?;
371    let file = platform::open_entry(directory, &entry_name).map_err(|error| {
372        if error.kind() == io::ErrorKind::NotFound {
373            OpenError::NotFound
374        } else if platform::is_link_refusal(&error) {
375            OpenError::Link
376        } else {
377            ScopeError(format!("Cannot open Artifact input: {error}")).into()
378        }
379    })?;
380    if exact && !platform::exact_name(&file, name).map_err(|error| ScopeError(error.to_string()))? {
381        return Err(
382            ScopeError("Artifact path must use the exact directory entry name.".into()).into(),
383        );
384    }
385    let metadata = file.metadata().map_err(|e| ScopeError(e.to_string()))?;
386    if !metadata.is_file() && !metadata.is_dir() {
387        return Err(
388            ScopeError("Artifact input must be a regular file or directory.".into()).into(),
389        );
390    }
391    Ok(file)
392}
393
394pub fn logical_path(path: &str) -> Result<(), ScopeError> {
395    if path.encode_utf16().count() > MAX_PATH_UNITS
396        || path
397            .bytes()
398            .any(|byte| byte.is_ascii_control() || matches!(byte, b'\\' | b':'))
399        || (!path.is_empty()
400            && path
401                .split('/')
402                .any(|part| part.is_empty() || part == "." || part == ".."))
403    {
404        return Err(ScopeError(
405            "Artifact path must be a safe relative logical path.".into(),
406        ));
407    }
408    Ok(())
409}
410
411/// The logical form of a native relative path: its components joined with `/`, whatever
412/// separator the platform uses. `None` when a component is not UTF-8 or not a plain name.
413pub fn logical_from_native(path: &Path) -> Option<String> {
414    platform::logical_from_native(path)
415}
416
417/// Resolve a physical input below a canonical root. No component may be a symlink.
418pub fn scoped_path(root: &Path, path: &Path) -> Result<PathBuf, ScopeError> {
419    let relative =
420        platform::scoped_relative(path).map_err(|error| ScopeError(error.to_string()))?;
421    // The pinned, no-follow walk decides: it refuses links, special files and spellings
422    // other than the directory entry's own, so the returned path names what it opened.
423    match open_scoped(root, &relative) {
424        Ok(_) => Ok(relative
425            .split('/')
426            .filter(|part| !part.is_empty())
427            .fold(root.to_owned(), |path, part| path.join(part))),
428        Err(OpenError::NotFound) => Err(ScopeError(format!(
429            "Artifact input does not exist: {relative}"
430        ))),
431        Err(OpenError::Link) => Err(ScopeError("Artifact symlinks are not supported.".into())),
432        Err(OpenError::Refused(error)) => Err(error),
433    }
434}