use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use crate::manifest::{Component, ComponentKind};
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InvalidArtifactId {
#[error("invalid artifact id: must not be empty")]
Empty,
#[error("invalid artifact id '{0}': must be a single path segment, not a path")]
NotASegment(String),
#[error("invalid artifact id '{0}': '.' and '..' are not valid filenames")]
RelativeSegment(String),
#[error("invalid artifact id '{0}': must not begin with '-', which reads as a CLI flag")]
LeadingDash(String),
#[error("invalid artifact id '{0}': must not contain a NUL byte")]
InteriorNul(String),
#[error("invalid artifact id '{0}': must not contain an empty path segment")]
EmptySegment(String),
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum DestinationError {
#[error(transparent)]
InvalidId(#[from] InvalidArtifactId),
#[error(
"cannot compute a destination for component '{component}': its kind '{tag}' is not \
supported by this version of midenup"
)]
UnsupportedKind { component: String, tag: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Destination {
pub path: PathBuf,
pub mode: u32,
}
#[derive(Default)]
pub(crate) struct DestinationClaims(BTreeMap<PathBuf, String>);
pub(crate) struct DestinationConflict {
pub path: PathBuf,
pub owner: String,
}
impl DestinationClaims {
pub fn claim(&mut self, path: &Path, owner: &str) -> Result<(), DestinationConflict> {
for (previous_path, previous_owner) in &self.0 {
let conflicts = if previous_path == path {
previous_owner != owner
} else {
previous_path.starts_with(path) || path.starts_with(previous_path)
};
if conflicts {
return Err(DestinationConflict {
path: previous_path.clone(),
owner: previous_owner.clone(),
});
}
}
self.0.insert(path.to_path_buf(), owner.to_string());
Ok(())
}
}
pub const MODE_EXECUTABLE: u32 = 0o755;
pub const MODE_DATA: u32 = 0o644;
pub fn validate_artifact_id(id: &str) -> Result<(), InvalidArtifactId> {
if id.is_empty() {
return Err(InvalidArtifactId::Empty);
}
if id.contains('\0') {
return Err(InvalidArtifactId::InteriorNul(id.to_string()));
}
if id == "." || id == ".." {
return Err(InvalidArtifactId::RelativeSegment(id.to_string()));
}
if id.contains('/') || id.contains('\\') {
return Err(InvalidArtifactId::NotASegment(id.to_string()));
}
if id.starts_with('-') {
return Err(InvalidArtifactId::LeadingDash(id.to_string()));
}
Ok(())
}
pub fn validate_artifact_path(id: &str) -> Result<(), InvalidArtifactId> {
if id.is_empty() {
return Err(InvalidArtifactId::Empty);
}
for segment in id.split('/') {
if segment.is_empty() {
return Err(InvalidArtifactId::EmptySegment(id.to_string()));
}
validate_artifact_id(segment)?;
}
Ok(())
}
pub fn validate_artifact_id_for(component: &Component, id: &str) -> Result<(), InvalidArtifactId> {
match component.kind() {
ComponentKind::Asset | ComponentKind::Command { .. } => validate_artifact_path(id),
_ => validate_artifact_id(id),
}
}
pub fn destination_for(
component: &Component,
artifact_id: &str,
sysroot: &Path,
) -> Result<Destination, DestinationError> {
validate_artifact_id_for(component, artifact_id)?;
let destination = match component.kind() {
ComponentKind::Executable { spec, .. } | ComponentKind::CargoExtension { spec, .. } => {
validate_artifact_id(&spec.installed_executable)?;
Destination {
path: sysroot.join("bin").join(&spec.installed_executable),
mode: MODE_EXECUTABLE,
}
},
ComponentKind::Package => Destination {
path: sysroot.join("lib").join(artifact_id),
mode: MODE_DATA,
},
ComponentKind::LegacyPackage { .. } => {
Destination {
path: sysroot.join("lib").join(artifact_id),
mode: MODE_DATA,
}
},
ComponentKind::Asset | ComponentKind::Command { .. } => Destination {
path: sysroot.join("etc").join(component.name.as_ref()).join(artifact_id),
mode: MODE_DATA,
},
ComponentKind::Unsupported { tag, .. } => {
return Err(DestinationError::UnsupportedKind {
component: component.name.to_string(),
tag: tag.clone(),
});
},
};
Ok(destination)
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use super::*;
use crate::{
artifact::Artifacts,
exec::Executable,
manifest::{ExecutableComponent, InstallationMethod},
profile::Profile,
version::Authority,
};
fn component(name: &'static str, kind: ComponentKind) -> Component {
Component {
name: Cow::Borrowed(name),
version: Authority::Registry { version: semver::Version::new(0, 1, 0) },
kind,
profiles: vec![Profile::Minimal],
requires: vec![],
artifacts: Artifacts::default(),
extra: Default::default(),
}
}
fn executable(name: &'static str, installed: &str) -> Component {
component(
name,
ComponentKind::Executable {
installation_method: InstallationMethod::Prebuilt,
spec: ExecutableComponent {
installed_executable: installed.to_string(),
..Default::default()
},
},
)
}
#[test]
fn destination_claims_reject_ancestors_in_either_order() {
let parent = Path::new("/etc/node/compose");
let child = Path::new("/etc/node/compose/node.yml");
for (first, second) in [(parent, child), (child, parent)] {
for owner in ["node", "other"] {
let mut claims = DestinationClaims::default();
assert!(claims.claim(first, "node").is_ok());
let conflict = claims.claim(second, owner).expect_err("paths overlap");
assert_eq!(conflict.path, first);
assert_eq!(conflict.owner, "node");
assert!(claims.claim(first, "node").is_ok(), "rejection keeps the prior claim");
}
}
}
#[test]
fn only_one_component_can_claim_the_same_file() {
let mut claims = DestinationClaims::default();
let path = Path::new("/bin/miden-vm");
assert!(claims.claim(path, "vm").is_ok());
assert!(claims.claim(path, "vm").is_ok());
let conflict = claims.claim(path, "other").expect_err("different owners collide");
assert_eq!(conflict.path, path);
assert_eq!(conflict.owner, "vm");
}
#[test]
fn rejects_unsafe_artifact_ids() {
for bad in ["", ".", "..", "a/b", "a\\b", "-leading", "with\0nul"] {
assert!(validate_artifact_id(bad).is_err(), "should reject {bad:?}");
}
for good in ["core.masp", "miden-vm", "docker-compose.yml", "a.b.c"] {
assert!(validate_artifact_id(good).is_ok(), "should accept {good:?}");
}
}
#[test]
fn rejects_unsafe_artifact_paths() {
for bad in ["", "/a", "a/", "a//b", "a/./b", "a/../b", "a/-b", "a\\b", "a/b\0"] {
assert!(validate_artifact_path(bad).is_err(), "should reject {bad:?}");
}
for good in ["docker-compose.yml", "compose/node.yml", "a/b/c.d"] {
assert!(validate_artifact_path(good).is_ok(), "should accept {good:?}");
}
}
#[test]
fn a_traversal_id_never_produces_a_path_outside_the_sysroot() {
let pkg = component("core", ComponentKind::Package);
let err = destination_for(&pkg, "../../etc/passwd", Path::new("/sysroot")).unwrap_err();
assert!(matches!(err, DestinationError::InvalidId(InvalidArtifactId::NotASegment(_))));
let asset = component("node", ComponentKind::Asset);
let err = destination_for(&asset, "../../etc/passwd", Path::new("/sysroot")).unwrap_err();
assert!(matches!(
err,
DestinationError::InvalidId(InvalidArtifactId::RelativeSegment(_))
));
}
#[test]
fn a_path_id_is_accepted_only_for_assets_and_commands() {
let root = Path::new("/sysroot");
let pkg = component("core", ComponentKind::Package);
let err = destination_for(&pkg, "sub/core.masp", root).unwrap_err();
assert!(matches!(err, DestinationError::InvalidId(InvalidArtifactId::NotASegment(_))));
let asset = component("node", ComponentKind::Asset);
let d = destination_for(&asset, "compose/node.yml", root).unwrap();
assert_eq!(d.path, root.join("etc").join("node").join("compose").join("node.yml"));
}
#[test]
fn destinations_and_modes_match_the_spec() {
let root = Path::new("/sysroot");
let exe = executable("vm", "miden-vm");
let d = destination_for(&exe, "miden-vm", root).unwrap();
assert_eq!(d.path, root.join("bin").join("miden-vm"));
assert_eq!(d.mode, MODE_EXECUTABLE);
let pkg = component("core", ComponentKind::Package);
let d = destination_for(&pkg, "core.masp", root).unwrap();
assert_eq!(d.path, root.join("lib").join("core.masp"));
assert_eq!(d.mode, MODE_DATA, "packages are data, not programs");
let asset = component("node", ComponentKind::Asset);
let d = destination_for(&asset, "docker-compose.yml", root).unwrap();
assert_eq!(d.path, root.join("etc").join("node").join("docker-compose.yml"));
assert_eq!(d.mode, MODE_DATA);
let cmd = component(
"node",
ComponentKind::Command {
command_name: None,
format: Executable::default(),
subcommands: Default::default(),
aliases: Default::default(),
},
);
let d = destination_for(&cmd, "telemetry.yml", root).unwrap();
assert_eq!(d.path, root.join("etc").join("node").join("telemetry.yml"));
assert_eq!(d.mode, MODE_DATA);
}
#[test]
fn an_executable_is_named_by_its_installed_executable() {
let exe = executable("vm", "miden-vm");
let d = destination_for(&exe, "miden-vm", Path::new("/s")).unwrap();
assert_eq!(d.path.file_name().unwrap(), "miden-vm");
}
#[test]
fn an_unsupported_kind_has_no_destination() {
let unknown = component(
"futurething",
ComponentKind::Unsupported {
tag: "wasm-module".to_string(),
body: crate::manifest::OpaqueBody(Default::default()),
},
);
let err = destination_for(&unknown, "x", Path::new("/s")).unwrap_err();
assert!(matches!(err, DestinationError::UnsupportedKind { .. }));
}
}