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