use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, File};
use std::io;
use std::path::{Component, Path, PathBuf};
use serde::Serialize;
use thiserror::Error;
use crate::config::{Artifact, ConfigError, Eval, Fingerprint, Profile, RepoConfig};
use crate::platform;
mod human;
mod instruction;
pub(crate) use human::{resolve_human_argv, validate_human_args};
pub use instruction::instruction_references;
pub(crate) const MAX_PATH_UNITS: usize = 4096;
#[derive(Debug, Error)]
#[error("{0}")]
pub struct ScopeError(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Relation {
pub source: String,
pub target: String,
#[serde(flatten)]
pub kind: RelationKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum RelationKind {
Child {
path: String,
},
Mount {
alias: String,
},
Dependency {
#[serde(rename = "evalId")]
eval_id: String,
name: String,
},
Instruction {
#[serde(rename = "evalId")]
eval_id: String,
name: String,
},
#[serde(rename = "argv")]
Argument {
#[serde(rename = "evalId")]
eval_id: String,
index: usize,
name: String,
path: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopedPath {
pub artifact_id: String,
pub path: String,
}
#[derive(Debug)]
pub struct Scope<'a> {
pub artifacts: BTreeMap<&'a str, &'a Artifact>,
}
impl Scope<'_> {
pub fn resolve_path(&self, artifact_id: &str, 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;
}
let (first, rest) = remaining.split_once('/').unwrap_or((remaining, ""));
if let Some(target) = artifact.mounts.get(first) {
current = target;
remaining = rest;
continue;
}
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.to_owned(),
path: remaining.to_owned(),
})
}
pub fn resolve_input(
&self,
root: &Path,
artifact_id: &str,
path: &str,
) -> Result<PathBuf, ScopeError> {
let location = self.resolve_path(artifact_id, path)?;
let artifact = self.artifacts[location.artifact_id.as_str()];
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))?;
if artifact.file_name().is_some() && !resolved.is_file() {
return Err(ScopeError(
"File Artifact target must remain a regular file.".into(),
));
}
Ok(resolved)
}
}
pub(crate) fn validate_file_target(root: &Path, artifact: &Artifact) -> Result<(), ScopeError> {
if artifact.file_name().is_some() {
let target = artifact
.path
.to_str()
.ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
let file = open_scoped(root, target).map_err(|error| {
ScopeError(format!(
"File Artifact {} target {} is unavailable: {error}",
artifact.name,
artifact.path.display()
))
})?;
if !file
.metadata()
.map_err(|error| ScopeError(error.to_string()))?
.is_file()
{
return Err(ScopeError(format!(
"File Artifact {} target {} must remain a regular file.",
artifact.name,
artifact.path.display()
)));
}
}
Ok(())
}
#[derive(Debug, Error)]
pub(crate) enum OpenError {
#[error("No such file or directory.")]
NotFound,
#[error(transparent)]
Refused(#[from] ScopeError),
}
pub(crate) fn open_input(root: &Path, artifact: &Artifact, path: &str) -> Result<File, OpenError> {
if !root.is_absolute() || artifact.path.is_absolute() {
return Err(
ScopeError("Artifact roots must be absolute and owner paths relative.".into()).into(),
);
}
file_input(artifact, path)?;
let file = open_scoped(&root.join(artifact.folder()), path)?;
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} and mounts.",
artifact.name
)));
}
Ok(())
}
fn reference_path(config: &RepoConfig, id: &str, path: &str) -> Result<(), ScopeError> {
if config.artifacts[id].file_name().is_some() && !path.is_empty() {
return Err(ScopeError(format!(
"File Artifact {{{id}}} cannot have a /path suffix."
)));
}
Ok(())
}
pub(crate) fn open_scoped(root: &Path, path: &str) -> Result<File, OpenError> {
logical_path(path)?;
if !root.is_absolute() {
return Err(ScopeError("Scoped roots must be absolute.".into()).into());
}
let target = root.join(path);
let filesystem_root = target.ancestors().last().unwrap_or(&target);
let mut directory =
platform::open_directory(filesystem_root).map_err(|e| ScopeError(e.to_string()))?;
for component in target.components() {
directory = match component {
Component::Prefix(_) | Component::RootDir => continue,
Component::Normal(name) => open_child(&directory, name)?,
_ => {
return Err(ScopeError(
"Artifact path must not traverse parent directories.".into(),
)
.into());
}
};
}
Ok(directory)
}
pub(crate) fn open_child(directory: &File, name: &std::ffi::OsStr) -> Result<File, OpenError> {
let name = platform::EntryName::new(name)
.ok_or_else(|| ScopeError("Invalid Artifact path.".into()))?;
let file = platform::open_entry(directory, &name).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
OpenError::NotFound
} else if platform::is_link_refusal(&error) {
ScopeError("Cannot open Artifact input without symlink traversal.".into()).into()
} else {
ScopeError(format!("Cannot open Artifact input: {error}")).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 artifact_scope<'a>(config: &'a RepoConfig, roots: &[&str]) -> Result<Scope<'a>, ScopeError> {
let mut artifacts = BTreeMap::new();
let mut pending = roots.to_vec();
while let Some(id) = pending.pop() {
let (id, artifact) = config
.artifacts
.get_key_value(id)
.ok_or_else(|| ScopeError(format!("Unknown Artifact: {id}")))?;
if artifacts.insert(id.as_str(), artifact).is_some() {
continue;
}
pending.extend(artifact.children.values().map(String::as_str));
pending.extend(artifact.mounts.values().map(String::as_str));
}
Ok(Scope { artifacts })
}
pub fn eval_scope<'a>(config: &'a RepoConfig, eval: &Eval) -> Result<Scope<'a>, ScopeError> {
let roots: Vec<_> = std::iter::once(eval.target.as_str())
.chain(eval.deps.iter().map(String::as_str))
.collect();
artifact_scope(config, &roots)
}
pub fn argv_scope<'a>(
config: &'a RepoConfig,
owner: &str,
args: &[String],
) -> Result<Scope<'a>, ScopeError> {
let mut roots = vec![owner];
for argument in args {
if let Some(reference) = argument_reference(argument)? {
roots.push(reference_target(config, owner, reference.name)?);
}
}
artifact_scope(config, &roots)
}
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 scoped_path(root: &Path, path: &Path) -> Result<PathBuf, ScopeError> {
let path = path
.to_str()
.ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
if path.contains(['\0', '\\'])
|| Path::new(path).is_absolute()
|| path.split('/').any(|part| part == "." || part == "..")
{
return Err(ScopeError(
"Artifact path must be relative to its declared root.".into(),
));
}
let mut target = root.to_owned();
for component in path.split('/').filter(|part| !part.is_empty()) {
target.push(component);
let metadata = fs::symlink_metadata(&target)
.map_err(|error| ScopeError(format!("{}: {error}", target.display())))?;
if metadata.is_symlink() {
return Err(ScopeError("Artifact symlinks are not supported.".into()));
}
}
let actual = platform::canonicalize(&target)
.map_err(|error| ScopeError(format!("{}: {error}", target.display())))?;
if !actual.starts_with(root) {
return Err(ScopeError(
"Artifact path escapes its declared root.".into(),
));
}
let metadata = fs::metadata(&actual)
.map_err(|error| ScopeError(format!("{}: {error}", actual.display())))?;
if !metadata.is_file() && !metadata.is_dir() {
return Err(ScopeError(
"Artifact input must be a file or directory.".into(),
));
}
Ok(actual)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstructionPart {
Text(String),
Artifact(String),
}
pub fn parse_artifact_instruction(
source: &str,
scope: &Scope<'_>,
references: &BTreeMap<String, String>,
) -> Vec<InstructionPart> {
let mut parts = Vec::new();
let mut start = 0;
for reference in instruction::references(source) {
let id = references
.get(reference.name)
.map_or(reference.name, String::as_str);
if !scope.artifacts.contains_key(id) {
continue;
}
if start < reference.start {
parts.push(InstructionPart::Text(source[start..reference.start].into()));
}
parts.push(InstructionPart::Artifact(id.into()));
start = reference.end;
}
if start < source.len() {
parts.push(InstructionPart::Text(source[start..].into()));
}
parts
}
struct ArgumentReference<'a> {
name: &'a str,
prefix: &'a str,
path: &'a str,
}
fn argument_reference(argument: &str) -> Result<Option<ArgumentReference<'_>>, ScopeError> {
let references = instruction::references(argument);
let Some(reference) = references.first() else {
return Ok(None);
};
let prefix = &argument[..reference.start];
let flag = prefix
.strip_prefix("--")
.and_then(|flag| flag.strip_suffix('='))
.is_some_and(|flag| {
!flag.is_empty()
&& flag
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
});
if references.len() != 1 || (!prefix.is_empty() && !flag) {
return Err(ScopeError(
"Artifact arguments must be {name}[/path] or --flag={name}[/path].".into(),
));
}
let suffix = &argument[reference.end..];
let path = if suffix.is_empty() {
""
} else {
suffix
.strip_prefix('/')
.filter(|path| !path.is_empty())
.ok_or_else(|| {
ScopeError("Artifact argument suffix must be a nonempty /path.".into())
})?
};
logical_path(path)?;
Ok(Some(ArgumentReference {
name: reference.name,
prefix,
path,
}))
}
fn reference_target<'a>(
config: &'a RepoConfig,
owner: &str,
name: &str,
) -> Result<&'a str, ScopeError> {
let artifact = config
.artifacts
.get(owner)
.ok_or_else(|| ScopeError(format!("Unknown Artifact: {owner}")))?;
let target = artifact.mounts.get(name).map_or(name, String::as_str);
config
.artifacts
.get_key_value(target)
.map(|(id, _)| id.as_str())
.ok_or_else(|| {
ScopeError(format!(
"Unknown Artifact reference {{{name}}}. Escape literal braces with a backslash."
))
})
}
pub fn resolve_argv(
config: &RepoConfig,
scope: &Scope<'_>,
owner: &str,
args: &[String],
) -> Result<Vec<String>, ScopeError> {
args.iter()
.map(|argument| {
let Some(reference) = argument_reference(argument)? else {
return Ok(argument.clone());
};
let id = reference_target(config, owner, reference.name)?;
reference_path(config, id, reference.path)?;
let path = scope.resolve_input(&config.root, id, reference.path)?;
let path = path
.to_str()
.ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
Ok(format!("{}{path}", reference.prefix))
})
.collect()
}
pub(crate) fn resolve_config(config: &mut RepoConfig) -> Result<(), ConfigError> {
let mut relations = Vec::new();
for (id, artifact) in &config.artifacts {
let error = |keys: &[&str], message: String| {
ConfigError::declaration(config.root.join(artifact.declaration_path()), keys, message)
};
for (name, tool) in &artifact.views.agent_tools {
if let crate::config::AgentTool::Command(tool) = tool
&& tool.protocol == crate::config::ToolProtocol::Json
{
for argument in &tool.args {
if let Some(reference) = argument_reference(argument).map_err(|failure| {
error(
&["views", "agent_tools", name, "args"],
format!("Agent tool {name}: {failure}"),
)
})? {
let target =
reference_target(config, id, reference.name).map_err(|failure| {
error(
&["views", "agent_tools", name, "args"],
format!("Agent tool {name}: {failure}"),
)
})?;
reference_path(config, target, reference.path).map_err(|failure| {
error(
&["views", "agent_tools", name, "args"],
format!("Agent tool {name}: {failure}"),
)
})?;
}
}
}
}
if let Some(Fingerprint::Script { args, .. }) = &artifact.fingerprint {
for argument in args {
if let Some(reference) = argument_reference(argument).map_err(|failure| {
error(
&["fingerprint", "script", "args"],
format!("fingerprint.script: {failure}"),
)
})? {
let target =
reference_target(config, id, reference.name).map_err(|failure| {
error(
&["fingerprint", "script", "args"],
format!("fingerprint.script: {failure}"),
)
})?;
reference_path(config, target, reference.path).map_err(|failure| {
error(
&["fingerprint", "script", "args"],
format!("fingerprint.script: {failure}"),
)
})?;
}
}
}
for (path, source) in &artifact.children {
relations.push(Relation {
source: source.clone(),
target: id.clone(),
kind: RelationKind::Child { path: path.clone() },
});
}
for (alias, source) in &artifact.mounts {
if !config.artifacts.contains_key(source) {
return Err(error(
&["mounts", alias],
format!("Unknown mount target {source} in {id}."),
));
}
if config.artifacts.contains_key(alias) && alias != source {
return Err(error(
&["mounts", alias],
format!("Ambiguous mount alias {alias} in {id}."),
));
}
if artifact.file_name().is_some() {
if artifact.file_name() == Some(alias.as_str()) {
return Err(error(
&["mounts", alias],
format!("Mount {id}/{alias} conflicts with the target file."),
));
}
} else {
match fs::symlink_metadata(config.root.join(artifact.folder()).join(alias)) {
Ok(_) => {
return Err(error(
&["mounts", alias],
format!("Mount {id}/{alias} conflicts with a physical entry."),
));
}
Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => {}
Err(failure) => return Err(error(&["mounts", alias], failure.to_string())),
}
}
relations.push(Relation {
source: source.clone(),
target: id.clone(),
kind: RelationKind::Mount {
alias: alias.clone(),
},
});
}
}
for (id, artifact) in &config.artifacts {
let Some(Fingerprint::Artifactsum { files: inputs, .. }) = &artifact.fingerprint else {
continue;
};
let error = |message: String| {
ConfigError::declaration(
config.root.join(artifact.declaration_path()),
&["fingerprint", "files"],
format!("fingerprint.files: {message}"),
)
};
let scope = artifact_scope(config, &[id]).map_err(|failure| error(failure.0))?;
for input in inputs.iter().filter(|input| *input != ".") {
if input.ends_with(".artf")
&& !fs::symlink_metadata(config.root.join(artifact.folder()).join(input))
.is_ok_and(|metadata| metadata.is_dir())
{
return Err(error(
"Artifact declarations (*.artf) cannot be explicit artifactsum inputs; declarations are excluded from artifactsum."
.into(),
));
}
let location = scope
.resolve_path(id, input)
.map_err(|failure| error(failure.0))?;
if location.artifact_id != *id {
return Err(error(format!(
"{input} belongs to Artifact {}; dependencies come from children, mounts and references.",
location.artifact_id
)));
}
}
}
let mut resolved = Vec::new();
for eval in &config.evals {
let error = |field: &[&str], failure: ScopeError| {
let mut keys = vec!["evals", &eval.declaration.id];
keys.extend_from_slice(field);
ConfigError::declaration(
config
.root
.join(config.artifacts[&eval.target].declaration_path()),
&keys,
format!("Eval {}: {failure}", eval.id),
)
};
let mut references = BTreeMap::new();
let mut deps = BTreeSet::new();
if let Profile::Dependency { depends_on } = &eval.declaration.profile {
for name in depends_on {
let source = reference_target(config, &eval.target, name)
.map_err(|failure| error(&["profile", "depends_on"], failure))?;
if source == eval.target {
return Err(error(
&["profile", "depends_on"],
ScopeError("Dependency Evals cannot depend on their own Artifact.".into()),
));
}
if !deps.insert(source.to_owned()) {
return Err(error(
&["profile", "depends_on"],
ScopeError(
"Dependency profile depends_on must resolve to unique Artifacts."
.into(),
),
));
}
relations.push(Relation {
source: source.to_owned(),
target: eval.target.clone(),
kind: RelationKind::Dependency {
eval_id: eval.id.clone(),
name: name.clone(),
},
});
}
}
let instruction = eval
.declaration
.payload
.as_ref()
.map_or("", |payload| payload.instruction.as_str());
for reference in instruction::references(instruction) {
let name = reference.name;
let source = reference_target(config, &eval.target, name)
.map_err(|failure| error(&["payload", "instruction"], failure))?;
if instruction[reference.end..].starts_with('/') {
reference_path(config, source, "/")
.map_err(|failure| error(&["payload", "instruction"], failure))?;
}
if references
.insert(name.to_owned(), source.to_owned())
.is_some()
{
continue;
}
if source != eval.target {
deps.insert(source.to_owned());
relations.push(Relation {
source: source.to_owned(),
target: eval.target.clone(),
kind: RelationKind::Instruction {
eval_id: eval.id.clone(),
name: name.to_owned(),
},
});
}
}
if let Profile::Runtime { args, .. } = &eval.declaration.profile {
for (index, argument) in args.iter().enumerate() {
let Some(reference) = argument_reference(argument)
.map_err(|failure| error(&["profile", "args"], failure))?
else {
continue;
};
let source = reference_target(config, &eval.target, reference.name)
.map_err(|failure| error(&["profile", "args"], failure))?;
reference_path(config, source, reference.path)
.map_err(|failure| error(&["profile", "args"], failure))?;
if source != eval.target {
deps.insert(source.to_owned());
relations.push(Relation {
source: source.to_owned(),
target: eval.target.clone(),
kind: RelationKind::Argument {
eval_id: eval.id.clone(),
index,
name: reference.name.to_owned(),
path: reference.path.to_owned(),
},
});
}
}
}
resolved.push((references, deps.into_iter().collect()));
}
for (eval, (references, deps)) in config.evals.iter_mut().zip(resolved) {
eval.references = references;
eval.deps = deps;
}
config.relations = relations;
human::validate_references(config)?;
crate::graph::Graph::new(config).map_err(|error| ConfigError::new(&config.root, error))?;
Ok(())
}
#[cfg(test)]
mod tests;