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)]
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
59impl 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
90macro_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 Aspect,
146 Graph,
147}
148
149#[derive(Debug, Clone)]
151pub struct Artifact {
152 pub path: PathBuf,
153 pub kind: ArtifactKind,
154 pub name: String,
155 pub nested: BTreeMap<ChildPrefix, ArtifactId>,
157 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 pub fn selective(&self) -> bool {
174 matches!(self.kind, ArtifactKind::File | ArtifactKind::Aspect)
175 }
176
177 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 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 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#[derive(Debug, Error)]
287pub enum OpenError {
288 #[error("No such file or directory.")]
289 NotFound,
290 #[error("Cannot open Artifact input without symlink traversal.")]
292 Link,
293 #[error(transparent)]
294 Refused(#[from] ScopeError),
295}
296
297pub 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 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
341fn 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
360pub 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 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
392pub 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
440pub fn logical_from_native(path: &Path) -> Option<String> {
443 platform::logical_from_native(path)
444}
445
446pub 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 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}