use std::path::{Path, PathBuf};
use super::uri::ParsedUri;
use super::{ResolveError, ResolvedNamespace};
pub(super) fn resolve(
parsed: &ParsedUri,
original_uri: &str,
workspace_root: &Path,
) -> Result<ResolvedNamespace, ResolveError> {
debug_assert_eq!(parsed.scheme, "path");
if parsed.has_fragment() || parsed.has_query() {
return Err(ResolveError::PathUriHasFragmentOrQuery {
uri: original_uri.to_string(),
});
}
let body = &parsed.body;
let candidate = if Path::new(body).is_absolute() {
PathBuf::from(body)
} else {
workspace_root.join(body)
};
let normalized = lexically_normalize(&candidate).ok_or_else(|| ResolveError::RootEscape {
path: candidate.clone(),
})?;
let normalized_root =
lexically_normalize(workspace_root).ok_or_else(|| ResolveError::RootEscape {
path: candidate.clone(),
})?;
if !normalized.starts_with(&normalized_root) {
return Err(ResolveError::RootEscape { path: candidate });
}
let metadata = std::fs::metadata(&candidate).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
ResolveError::PathNotADirectory {
path: candidate.clone(),
}
} else {
ResolveError::Io {
path: candidate.clone(),
source,
}
}
})?;
if !metadata.is_dir() {
return Err(ResolveError::PathNotADirectory { path: candidate });
}
Ok(ResolvedNamespace {
schema_dir: candidate,
source_uri: original_uri.to_string(),
})
}
fn lexically_normalize(path: &Path) -> Option<PathBuf> {
let mut out = PathBuf::new();
for c in path.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !out.pop() {
return None;
}
}
other => out.push(other.as_os_str()),
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(uri: &str) -> ParsedUri {
ParsedUri::parse(uri).unwrap()
}
#[test]
fn path_uri_resolves_to_directory() {
let workspace = tempfile::tempdir().unwrap();
let labels_dir = workspace.path().join("acme-labels");
std::fs::create_dir(&labels_dir).unwrap();
std::fs::write(
labels_dir.join("task.yaml"),
"schema_version: 1\nlabel: acme.task\n",
)
.unwrap();
let resolved = resolve(
&parse("path:acme-labels"),
"path:acme-labels",
workspace.path(),
)
.unwrap();
assert_eq!(resolved.schema_dir, labels_dir);
assert_eq!(resolved.source_uri, "path:acme-labels");
}
#[test]
fn path_uri_with_absolute_path_resolves() {
let workspace = tempfile::tempdir().unwrap();
let labels_dir = workspace.path().join("labels");
std::fs::create_dir(&labels_dir).unwrap();
let uri = format!("path:{}", labels_dir.display());
let resolved = resolve(&parse(&uri), &uri, workspace.path()).unwrap();
assert_eq!(resolved.schema_dir, labels_dir);
}
#[test]
fn path_uri_root_escape_is_rejected() {
let workspace = tempfile::tempdir().unwrap();
let err = resolve(
&parse("path:../../../etc/passwd"),
"path:../../../etc/passwd",
workspace.path(),
)
.unwrap_err();
assert!(matches!(err, ResolveError::RootEscape { .. }));
}
#[test]
fn relative_workspace_does_not_let_dotdot_escape() {
let relative = std::path::Path::new(".");
let err = resolve(
&parse("path:../../../etc/passwd"),
"path:../../../etc/passwd",
relative,
)
.unwrap_err();
assert!(
matches!(err, ResolveError::RootEscape { .. }),
"expected RootEscape, got: {err}"
);
}
#[test]
fn lexically_normalize_returns_none_on_underflow() {
assert!(lexically_normalize(std::path::Path::new("../foo")).is_none());
assert!(lexically_normalize(std::path::Path::new("../../etc")).is_none());
assert_eq!(
lexically_normalize(std::path::Path::new("a/./b/../c")),
Some(PathBuf::from("a/c"))
);
}
#[test]
fn path_uri_missing_directory_yields_path_not_a_directory() {
let workspace = tempfile::tempdir().unwrap();
let err = resolve(
&parse("path:does-not-exist"),
"path:does-not-exist",
workspace.path(),
)
.unwrap_err();
assert!(matches!(err, ResolveError::PathNotADirectory { .. }));
}
#[test]
fn path_uri_with_empty_query_is_rejected() {
let workspace = tempfile::tempdir().unwrap();
let dir = workspace.path().join("acme");
std::fs::create_dir(&dir).unwrap();
let err = resolve(&parse("path:acme?"), "path:acme?", workspace.path()).unwrap_err();
assert!(
matches!(err, ResolveError::PathUriHasFragmentOrQuery { .. }),
"expected PathUriHasFragmentOrQuery, got: {err}"
);
}
#[test]
fn path_uri_with_empty_fragment_is_rejected() {
let workspace = tempfile::tempdir().unwrap();
let dir = workspace.path().join("acme");
std::fs::create_dir(&dir).unwrap();
let err = resolve(&parse("path:acme#"), "path:acme#", workspace.path()).unwrap_err();
assert!(
matches!(err, ResolveError::PathUriHasFragmentOrQuery { .. }),
"expected PathUriHasFragmentOrQuery, got: {err}"
);
}
#[test]
fn path_uri_pointing_at_file_yields_path_not_a_directory() {
let workspace = tempfile::tempdir().unwrap();
std::fs::write(workspace.path().join("not-a-dir.txt"), "x").unwrap();
let err = resolve(
&parse("path:not-a-dir.txt"),
"path:not-a-dir.txt",
workspace.path(),
)
.unwrap_err();
assert!(matches!(err, ResolveError::PathNotADirectory { .. }));
}
}