use std::{
borrow::Borrow,
collections::BTreeMap,
fmt,
fs::File,
io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::files as platform;
pub const MAX_PATH_UNITS: usize = 4096;
pub const MAX_ARTIFACT_ID_BYTES: usize = 64;
#[derive(Debug, Error)]
#[error("{0}")]
pub struct ScopeError(pub String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ArtifactId(String);
impl ArtifactId {
pub fn new(value: impl Into<String>) -> Result<Self, ScopeError> {
let value = value.into();
let valid = value.len() <= MAX_ARTIFACT_ID_BYTES
&& value
.bytes()
.next()
.is_some_and(|first| first.is_ascii_alphanumeric())
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'));
if valid {
Ok(Self(value))
} else {
Err(ScopeError(format!(
"Artifact ID must match [A-Za-z0-9][A-Za-z0-9_-]{{0,63}}: {value:?}"
)))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Borrow<str> for ArtifactId {
fn borrow(&self) -> &str {
&self.0
}
}
impl fmt::Display for ArtifactId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl PartialEq<str> for ArtifactId {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for ArtifactId {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<String> for ArtifactId {
fn eq(&self, other: &String) -> bool {
self.0 == *other
}
}
macro_rules! logical_name {
($name:ident, $what:literal, $valid:expr) => {
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Result<Self, ScopeError> {
let value = value.into();
if !value.is_empty() && logical_path(&value).is_ok() && ($valid)(&value) {
Ok(Self(value))
} else {
Err(ScopeError(format!(
concat!("Invalid ", $what, ": {:?}"),
value
)))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Borrow<str> for $name {
fn borrow(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for $name {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
};
}
logical_name!(ChildPrefix, "child folder prefix", |_: &str| true);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ArtifactKind {
Folder,
File,
Graph,
}
#[derive(Debug, Clone)]
pub struct Artifact {
pub path: PathBuf,
pub kind: ArtifactKind,
pub name: String,
pub children: BTreeMap<ChildPrefix, ArtifactId>,
}
impl Artifact {
pub fn folder(&self) -> &Path {
match self.kind {
ArtifactKind::Folder | ArtifactKind::Graph => &self.path,
ArtifactKind::File => self.path.parent().unwrap_or(Path::new("")),
}
}
pub fn file_name(&self) -> Option<&str> {
(self.kind == ArtifactKind::File)
.then(|| self.path.file_name().and_then(|name| name.to_str()))
.flatten()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopedPath {
pub artifact_id: ArtifactId,
pub path: String,
}
#[derive(Debug)]
pub struct Scope {
pub artifacts: BTreeMap<ArtifactId, Artifact>,
}
impl Scope {
pub fn resolve_path(
&self,
artifact_id: &ArtifactId,
path: &str,
) -> Result<ScopedPath, ScopeError> {
logical_path(path)?;
let mut current = artifact_id;
let mut remaining = path;
loop {
let artifact = self
.artifacts
.get(current)
.ok_or_else(|| ScopeError(format!("Artifact is outside this review: {current}")))?;
if remaining.is_empty() {
break;
}
if let Some((prefix, target)) = artifact.children.iter().find(|(prefix, _)| {
remaining == prefix.as_str()
|| remaining
.strip_prefix(prefix.as_str())
.is_some_and(|rest| rest.starts_with('/'))
}) {
current = target;
remaining = remaining[prefix.len()..].strip_prefix('/').unwrap_or("");
continue;
}
break;
}
let artifact = &self.artifacts[current];
file_input(artifact, remaining)?;
Ok(ScopedPath {
artifact_id: current.clone(),
path: remaining.to_owned(),
})
}
pub fn resolve_input(
&self,
root: &Path,
artifact_id: &ArtifactId,
path: &str,
) -> Result<PathBuf, ScopeError> {
let location = self.resolve_path(artifact_id, path)?;
let artifact = &self.artifacts[&location.artifact_id];
let owner = scoped_path(root, artifact.folder())?;
let path = if location.path.is_empty() {
artifact.file_name().unwrap_or("")
} else {
&location.path
};
let resolved = scoped_path(&owner, Path::new(path))?;
let file =
open_input(root, artifact, path).map_err(|error| ScopeError(error.to_string()))?;
let regular = file
.metadata()
.map_err(|error| ScopeError(error.to_string()))?
.is_file();
if artifact.file_name().is_some() && !regular {
return Err(ScopeError(
"File Artifact target must remain a regular file.".into(),
));
}
Ok(resolved)
}
}
#[derive(Debug, Error)]
pub enum OpenError {
#[error("No such file or directory.")]
NotFound,
#[error("Cannot open Artifact input without symlink traversal.")]
Link,
#[error(transparent)]
Refused(#[from] ScopeError),
}
pub fn open_input(root: &Path, artifact: &Artifact, path: &str) -> Result<File, OpenError> {
if !platform::is_absolute(root) || platform::is_absolute(&artifact.path) {
return Err(
ScopeError("Artifact roots must be absolute and owner paths relative.".into()).into(),
);
}
logical_path(path)?;
file_input(artifact, path)?;
let owner = open_scoped(&root.join(artifact.folder()), "")?;
let components: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect();
let mut file = owner;
for (depth, component) in components.iter().enumerate() {
let child = artifact.children.keys().any(|prefix| {
let prefix: Vec<_> = prefix.split('/').collect();
prefix.len() == depth + 1
&& prefix
.iter()
.zip(&components)
.all(|(left, right)| left.eq_ignore_ascii_case(right))
});
if child
&& !platform::case_sensitive(&file).map_err(|error| ScopeError(error.to_string()))?
{
return Err(ScopeError("Physical input conflicts with a child name.".into()).into());
}
file = open_child(&file, std::ffi::OsStr::new(component))?;
}
if artifact.file_name().is_some()
&& !path.is_empty()
&& !file
.metadata()
.map_err(|e| ScopeError(e.to_string()))?
.is_file()
{
return Err(ScopeError("File Artifact target must remain a regular file.".into()).into());
}
Ok(file)
}
fn file_input(artifact: &Artifact, path: &str) -> Result<(), ScopeError> {
if let Some(target) = artifact.file_name()
&& !path.is_empty()
&& path != target
{
return Err(ScopeError(format!(
"File Artifact {} exposes only its target {target}.",
artifact.name
)));
}
Ok(())
}
pub fn open_scoped(root: &Path, path: &str) -> Result<File, OpenError> {
logical_path(path)?;
if !platform::is_absolute(root) {
return Err(ScopeError("Scoped roots must be absolute.".into()).into());
}
let (filesystem_root, names) = platform::split_root(root)
.ok_or_else(|| ScopeError("Artifact path must not traverse parent directories.".into()))?;
let mut directory =
platform::open_directory(filesystem_root).map_err(|e| ScopeError(e.to_string()))?;
let components = names.into_iter().map(|name| (name, false)).chain(
path.split('/')
.filter(|part| !part.is_empty())
.map(|part| (std::ffi::OsStr::new(part), true)),
);
for (name, exact) in components {
directory = match name {
name if name != ".." && name != "." => open_named(&directory, name, exact)?,
_ => {
return Err(ScopeError(
"Artifact path must not traverse parent directories.".into(),
)
.into());
}
};
}
Ok(directory)
}
pub fn open_child(directory: &File, name: &std::ffi::OsStr) -> Result<File, OpenError> {
open_named(directory, name, true)
}
fn open_named(directory: &File, name: &std::ffi::OsStr, exact: bool) -> Result<File, OpenError> {
let entry_name = platform::EntryName::new(name)
.ok_or_else(|| ScopeError("Invalid Artifact path.".into()))?;
let file = platform::open_entry(directory, &entry_name).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
OpenError::NotFound
} else if platform::is_link_refusal(&error) {
OpenError::Link
} else {
ScopeError(format!("Cannot open Artifact input: {error}")).into()
}
})?;
if exact && !platform::exact_name(&file, name).map_err(|error| ScopeError(error.to_string()))? {
return Err(
ScopeError("Artifact path must use the exact directory entry name.".into()).into(),
);
}
let metadata = file.metadata().map_err(|e| ScopeError(e.to_string()))?;
if !metadata.is_file() && !metadata.is_dir() {
return Err(
ScopeError("Artifact input must be a regular file or directory.".into()).into(),
);
}
Ok(file)
}
pub fn logical_path(path: &str) -> Result<(), ScopeError> {
if path.encode_utf16().count() > MAX_PATH_UNITS
|| path
.bytes()
.any(|byte| byte.is_ascii_control() || matches!(byte, b'\\' | b':'))
|| (!path.is_empty()
&& path
.split('/')
.any(|part| part.is_empty() || part == "." || part == ".."))
{
return Err(ScopeError(
"Artifact path must be a safe relative logical path.".into(),
));
}
Ok(())
}
pub fn logical_from_native(path: &Path) -> Option<String> {
platform::logical_from_native(path)
}
pub fn scoped_path(root: &Path, path: &Path) -> Result<PathBuf, ScopeError> {
let relative =
platform::scoped_relative(path).map_err(|error| ScopeError(error.to_string()))?;
match open_scoped(root, &relative) {
Ok(_) => Ok(relative
.split('/')
.filter(|part| !part.is_empty())
.fold(root.to_owned(), |path, part| path.join(part))),
Err(OpenError::NotFound) => Err(ScopeError(format!(
"Artifact input does not exist: {relative}"
))),
Err(OpenError::Link) => Err(ScopeError("Artifact symlinks are not supported.".into())),
Err(OpenError::Refused(error)) => Err(error),
}
}