1use 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
17pub const MAX_PATH_UNITS: usize = 4096;
20
21pub const MAX_ARTIFACT_ID_BYTES: usize = 64;
23
24#[derive(Debug, Error)]
25#[error("{0}")]
26pub struct ScopeError(pub String);
27
28#[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
67impl 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
98macro_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#[derive(Debug, Clone)]
157pub struct Artifact {
158 pub path: PathBuf,
159 pub kind: ArtifactKind,
160 pub name: String,
161 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 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 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#[derive(Debug, Error)]
263pub enum OpenError {
264 #[error("No such file or directory.")]
265 NotFound,
266 #[error("Cannot open Artifact input without symlink traversal.")]
268 Link,
269 #[error(transparent)]
270 Refused(#[from] ScopeError),
271}
272
273pub 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 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
317fn 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
331pub 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 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
363pub 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
411pub fn logical_from_native(path: &Path) -> Option<String> {
414 platform::logical_from_native(path)
415}
416
417pub 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 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}