use super::ArtifactRef;
use crate::registry_type::RegistryType;
use crate::validation::validate_storage_key;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Compat {
Full,
Partial,
Unsupported,
}
pub fn normalize_format(src_format: &str) -> Option<RegistryType> {
RegistryType::from_str_opt(src_format)
}
pub fn compat(rt: RegistryType) -> Compat {
match rt {
RegistryType::Maven | RegistryType::Raw => Compat::Full,
RegistryType::Npm => Compat::Partial,
_ => Compat::Unsupported,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mapping {
Key(String),
Skip(&'static str),
Reject(String),
}
pub fn map_artifact(rt: RegistryType, art: &ArtifactRef) -> Mapping {
for (label, s) in [("repo", art.repo.as_str()), ("path", art.path.as_str())] {
if let Some(bad) = first_unsafe_segment(s) {
return Mapping::Reject(format!("unsafe {label} segment {bad:?} in {s:?}"));
}
}
let key = match rt {
RegistryType::Maven => crate::registry::maven_storage_key(&art.path),
RegistryType::Raw => crate::registry::raw_storage_key(&join_repo(&art.repo, &art.path)),
RegistryType::Npm => match npm_tarball_key(&art.path) {
Some(k) => k,
None => {
return Mapping::Skip(
"npm non-tarball asset (metadata regenerated by NORA on serve)",
)
}
},
_ => return Mapping::Skip("format not hosted by NORA import"),
};
match validate_storage_key(&key) {
Ok(()) => Mapping::Key(key),
Err(e) => Mapping::Reject(format!("invalid storage key {key:?}: {e}")),
}
}
fn npm_tarball_key(path: &str) -> Option<String> {
let (package, file) = path.split_once("/-/")?;
if package.is_empty() || file.is_empty() || file.contains('/') {
return None;
}
Some(format!("npm/{package}/tarballs/{file}"))
}
fn join_repo(repo: &str, path: &str) -> String {
let repo = repo.trim_matches('/');
let path = path.trim_start_matches('/');
if repo.is_empty() {
path.to_string()
} else {
format!("{repo}/{path}")
}
}
fn first_unsafe_segment(path: &str) -> Option<&str> {
if path.is_empty() {
return Some("");
}
path.split('/').find(|seg| {
seg.is_empty() || *seg == "." || *seg == ".." || seg.contains('\\') || seg.contains('\0')
})
}
#[cfg(test)]
mod tests {
use super::*;
fn art(repo: &str, path: &str) -> ArtifactRef {
ArtifactRef {
repo: repo.to_string(),
path: path.to_string(),
name: path.rsplit('/').next().unwrap_or(path).to_string(),
size: None,
sha256: None,
sha1: None,
}
}
#[test]
fn normalize_covers_source_aliases() {
assert_eq!(normalize_format("maven"), Some(RegistryType::Maven));
assert_eq!(normalize_format("maven2"), Some(RegistryType::Maven)); assert_eq!(normalize_format("raw"), Some(RegistryType::Raw));
assert_eq!(normalize_format("npm"), Some(RegistryType::Npm));
assert_eq!(normalize_format("yum"), Some(RegistryType::Rpm));
assert_eq!(normalize_format("docker"), Some(RegistryType::Docker));
assert_eq!(normalize_format("nonsense"), None);
}
#[test]
fn compat_classes() {
assert_eq!(compat(RegistryType::Maven), Compat::Full);
assert_eq!(compat(RegistryType::Raw), Compat::Full);
assert_eq!(compat(RegistryType::Npm), Compat::Partial);
assert_eq!(compat(RegistryType::Cargo), Compat::Unsupported);
assert_eq!(compat(RegistryType::Docker), Compat::Unsupported);
}
#[test]
fn maven_key_matches_handler_and_drops_repo() {
let a = art("libs-release-local", "com/example/foo/1.0/foo-1.0.jar");
assert_eq!(
map_artifact(RegistryType::Maven, &a),
Mapping::Key("maven/com/example/foo/1.0/foo-1.0.jar".to_string())
);
}
#[test]
fn raw_key_matches_handler_and_folds_repo() {
let a = art("configs", "app/prod/settings.yaml");
assert_eq!(
map_artifact(RegistryType::Raw, &a),
Mapping::Key("raw/configs/app/prod/settings.yaml".to_string())
);
}
#[test]
fn npm_tarball_maps_scoped_and_unscoped() {
assert_eq!(
map_artifact(
RegistryType::Npm,
&art("npm-local", "left-pad/-/left-pad-1.3.0.tgz")
),
Mapping::Key("npm/left-pad/tarballs/left-pad-1.3.0.tgz".to_string())
);
assert_eq!(
map_artifact(
RegistryType::Npm,
&art("npm-local", "@babel/core/-/core-7.0.0.tgz")
),
Mapping::Key("npm/@babel/core/tarballs/core-7.0.0.tgz".to_string())
);
}
#[test]
fn npm_key_matches_handler_format() {
let handler_literal = format!("npm/{}/tarballs/{}", "left-pad", "left-pad-1.3.0.tgz");
assert_eq!(
npm_tarball_key("left-pad/-/left-pad-1.3.0.tgz"),
Some(handler_literal)
);
}
#[test]
fn npm_non_tarball_is_skipped_not_failed() {
match map_artifact(RegistryType::Npm, &art("npm-local", "left-pad")) {
Mapping::Skip(_) => {}
other => panic!("expected Skip, got {other:?}"),
}
}
#[test]
fn unsupported_format_is_skipped() {
match map_artifact(
RegistryType::Cargo,
&art("crates", "some/crate-1.0.0.crate"),
) {
Mapping::Skip(_) => {}
other => panic!("expected Skip, got {other:?}"),
}
}
#[test]
fn traversal_in_path_is_rejected() {
for bad in [
"../../etc/passwd",
"a/../../b",
"/abs/path",
"a//b",
"a/./b",
] {
match map_artifact(RegistryType::Maven, &art("r", bad)) {
Mapping::Reject(_) => {}
other => panic!("expected Reject for {bad:?}, got {other:?}"),
}
}
}
#[test]
fn traversal_in_repo_is_rejected() {
match map_artifact(RegistryType::Raw, &art("../../etc", "file")) {
Mapping::Reject(_) => {}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn npm_malformed_tarball_paths() {
assert!(matches!(
map_artifact(RegistryType::Npm, &art("npm", "pkg/-/nested/file.tgz")),
Mapping::Skip(_)
));
assert!(matches!(
map_artifact(RegistryType::Npm, &art("npm", "pkg/-/")),
Mapping::Reject(_)
));
}
#[test]
fn empty_path_is_rejected() {
assert!(matches!(
map_artifact(RegistryType::Maven, &art("r", "")),
Mapping::Reject(_)
));
}
use proptest::prelude::*;
proptest! {
#[test]
fn maven_keys_always_validate(
segs in prop::collection::vec("[a-zA-Z0-9._-]{1,12}", 1..6)
) {
let path = segs.join("/");
let a = art("repo", &path);
match map_artifact(RegistryType::Maven, &a) {
Mapping::Key(k) => {
prop_assert!(validate_storage_key(&k).is_ok());
prop_assert!(k.starts_with("maven/"));
}
Mapping::Reject(_) => {}
Mapping::Skip(s) => prop_assert!(false, "unexpected skip: {s}"),
}
}
}
}