use std::{
borrow::Cow,
collections::BTreeMap,
fmt,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::{
manifest::{Component, ComponentKind},
version::Authority,
};
#[derive(Debug, thiserror::Error)]
pub enum InvalidArtifactError {
#[error("invalid artifact: no uri scheme for artifact {id} of {component}")]
MissingScheme { id: String, component: String },
#[error(
"invalid artifact: invalid scheme for artifact {id} of {component}: expected file://, http://, or https://, got '{scheme}'"
)]
InvalidScheme {
id: String,
component: String,
scheme: String,
},
#[error(
"invalid artifact: %version substitution is invalid for artifact {id} of {component}: \
component has no known semantic version"
)]
VersionUnavailable { id: String, component: String },
#[error("invalid artifact: {substitution} is not defined for artifact {id} of {component}")]
UndefinedSubstitution {
id: String,
component: String,
substitution: &'static str,
},
#[error(
"invalid artifact: uri is missing %target substitution for artifact {id} of {component} \
when target is '{target}'"
)]
MissingTarget {
id: String,
component: String,
target: String,
},
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, Hash, PartialEq)]
#[serde(transparent)]
pub struct Artifacts {
pub(crate) artifacts: BTreeMap<String, Artifact>,
}
impl Artifacts {
pub fn is_empty(&self) -> bool {
self.artifacts.is_empty()
}
pub fn insert(&mut self, id: String, artifact: Artifact) -> bool {
self.artifacts.insert(id, artifact).is_none()
}
pub fn get_default_artifacts_for_target<'a>(
&'a self,
target: &str,
component: &'a Component,
) -> Result<Vec<(&'a str, ArtifactUri)>, InvalidArtifactError> {
match component.kind() {
ComponentKind::Asset => self.get_artifacts_for_target(target, component),
ComponentKind::CargoExtension { spec, .. } | ComponentKind::Executable { spec, .. } => {
let id = spec.installed_executable.as_str();
let artifact = self.get_artifact_for_target(id, target, component)?;
Ok(artifact.into_iter().map(|uri| (id, uri)).collect())
},
ComponentKind::Command { .. } | ComponentKind::Unsupported { .. } => Ok(vec![]),
ComponentKind::LegacyPackage { .. } | ComponentKind::Package => {
self.get_artifacts_for_target(target, component)
},
}
}
pub fn get_artifacts_for_target(
&self,
target: &str,
component: &Component,
) -> Result<Vec<(&str, ArtifactUri)>, InvalidArtifactError> {
let mut artifacts = Vec::with_capacity(self.artifacts.len());
for (id, artifact) in self.artifacts.iter() {
if let Some(uri) = artifact.get_uri_for(id, target, component)? {
artifacts.push((id.as_str(), uri));
}
}
Ok(artifacts)
}
pub fn get_artifact_for_target(
&self,
id: &str,
target: &str,
component: &Component,
) -> Result<Option<ArtifactUri>, InvalidArtifactError> {
let Some(artifact) = self.artifacts.get(id) else {
return Ok(None);
};
artifact.get_uri_for(id, target, component)
}
}
#[derive(Debug, Clone, Hash, PartialEq)]
pub enum Artifact {
TargetSpecific {
uri: String,
substitutions: Option<Substitutions>,
targets: BTreeMap<String, Substitutions>,
digest: Option<Digest>,
extra: crate::manifest::v3::unknown::Extra,
},
TargetAgnostic {
uri: String,
digest: Option<Digest>,
extra: crate::manifest::v3::unknown::Extra,
},
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged, rename_all = "kebab-case")]
enum ArtifactFields {
TargetSpecific {
uri: String,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
substitutions: Option<Substitutions>,
targets: BTreeMap<String, Substitutions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
digest: Option<Digest>,
},
TargetAgnostic {
uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
digest: Option<Digest>,
},
}
impl Artifact {
fn fields(&self) -> ArtifactFields {
match self {
Self::TargetSpecific { uri, substitutions, targets, digest, .. } => {
ArtifactFields::TargetSpecific {
uri: uri.clone(),
substitutions: substitutions.clone(),
targets: targets.clone(),
digest: digest.clone(),
}
},
Self::TargetAgnostic { uri, digest, .. } => {
ArtifactFields::TargetAgnostic { uri: uri.clone(), digest: digest.clone() }
},
}
}
fn extra(&self) -> &crate::manifest::v3::unknown::Extra {
match self {
Self::TargetSpecific { extra, .. } | Self::TargetAgnostic { extra, .. } => extra,
}
}
}
impl Serialize for Artifact {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::Error;
crate::manifest::v3::unknown::merge_extra(&self.fields(), self.extra())
.map_err(S::Error::custom)?
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Artifact {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let value = serde_json::Value::deserialize(deserializer)?;
if value.get("targets").is_some_and(|targets| !targets.is_object()) {
return Err(D::Error::custom(
"an artifact's `targets` must be a map of target triple to substitutions",
));
}
let (fields, extra) = crate::manifest::v3::unknown::split_extra::<ArtifactFields>(value)
.map_err(D::Error::custom)?;
Ok(match fields {
ArtifactFields::TargetSpecific { uri, substitutions, targets, digest } => {
Self::TargetSpecific {
uri,
substitutions,
targets,
digest,
extra,
}
},
ArtifactFields::TargetAgnostic { uri, digest } => {
Self::TargetAgnostic { uri, digest, extra }
},
})
}
}
impl Artifact {
pub fn digest(&self) -> Option<&Digest> {
match self {
Self::TargetSpecific { digest, .. } | Self::TargetAgnostic { digest, .. } => {
digest.as_ref()
},
}
}
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, Hash, PartialEq)]
pub struct Substitutions {
#[serde(skip_serializing_if = "Option::is_none")]
pub basename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extension: Option<String>,
#[serde(flatten)]
pub extra: crate::manifest::v3::unknown::Extra,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArtifactUri {
File(PathBuf),
Http(String),
}
impl ArtifactUri {
pub fn file_name(&self) -> Option<&Path> {
match self {
Self::File(path) => path.file_name().map(Path::new),
Self::Http(uri) => {
let (_, last) = uri.rsplit_once('/')?;
Some(Path::new(last.trim()))
},
}
}
}
impl fmt::Display for ArtifactUri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::File(path) => write!(f, "file://{}", path.display()),
Self::Http(uri) => f.write_str(uri),
}
}
}
impl Artifact {
pub fn get_uris_for(
&self,
id: &str,
component: &Component,
) -> Result<Vec<ArtifactUri>, InvalidArtifactError> {
let mut artifacts = vec![];
match self {
Self::TargetAgnostic { .. } => {
if let Some(artifact) = self.get_uri_for(id, "", component)? {
artifacts.push(artifact);
}
},
Self::TargetSpecific { targets, .. } => {
for target in targets.keys() {
if let Some(artifact) = self.get_uri_for(id, target, component)? {
artifacts.push(artifact);
}
}
},
}
Ok(artifacts)
}
pub fn get_uri_for(
&self,
id: &str,
target: &str,
component: &Component,
) -> Result<Option<ArtifactUri>, InvalidArtifactError> {
match self {
Self::TargetAgnostic { uri, .. } => {
let Some((scheme, rest)) = uri.split_once("://") else {
return Err(InvalidArtifactError::MissingScheme {
id: id.to_string(),
component: component.name.to_string(),
});
};
let rest = if rest.contains("%version") {
let Authority::Registry { version } = &component.version else {
return Err(InvalidArtifactError::VersionUnavailable {
id: id.to_string(),
component: component.name.to_string(),
});
};
Cow::Owned(rest.replace("%version", &version.to_string()))
} else {
Cow::Borrowed(rest)
};
match scheme {
"file" => Ok(Some(ArtifactUri::File(PathBuf::from(rest.into_owned())))),
"http" | "https" => Ok(Some(ArtifactUri::Http(format!("{scheme}://{rest}")))),
scheme => Err(InvalidArtifactError::InvalidScheme {
id: id.to_string(),
component: component.name.to_string(),
scheme: scheme.to_string(),
}),
}
},
Self::TargetSpecific { uri, substitutions, targets, .. } => {
let Some(target_subs) = targets.get(target) else {
return Ok(None);
};
let basename = target_subs
.basename
.as_deref()
.or(substitutions.as_ref().and_then(|subs| subs.basename.as_deref()))
.unwrap_or(component.name.as_ref());
let extension = target_subs
.extension
.as_deref()
.or(substitutions.as_ref().and_then(|subs| subs.extension.as_deref()));
let Some((scheme, rest)) = uri.split_once("://") else {
return Err(InvalidArtifactError::MissingScheme {
id: id.to_string(),
component: component.name.to_string(),
});
};
if !rest.contains("%target") {
return Err(InvalidArtifactError::MissingTarget {
id: id.to_string(),
component: component.name.to_string(),
target: target.to_string(),
});
}
let rest = rest.replace("%target", target);
let rest = if rest.contains("%version") {
let Authority::Registry { version } = &component.version else {
return Err(InvalidArtifactError::VersionUnavailable {
id: id.to_string(),
component: component.name.to_string(),
});
};
rest.replace("%version", &version.to_string())
} else {
rest
};
let rest = if rest.contains("%extension") {
let extension =
extension.ok_or_else(|| InvalidArtifactError::UndefinedSubstitution {
id: id.to_string(),
component: component.name.to_string(),
substitution: "%extension",
})?;
rest.replace("%extension", extension)
} else {
rest
};
let rest = rest.replace("%basename", basename);
match scheme {
"file" => Ok(Some(ArtifactUri::File(PathBuf::from(rest)))),
"http" | "https" => Ok(Some(ArtifactUri::Http(format!("{scheme}://{rest}")))),
scheme => Err(InvalidArtifactError::InvalidScheme {
id: id.to_string(),
component: component.name.to_string(),
scheme: scheme.to_string(),
}),
}
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Digest {
pub algorithm: String,
pub hex: String,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InvalidDigestError {
#[error("invalid digest '{0}': expected the form '<algorithm>:<hex>'")]
Malformed(String),
#[error("invalid digest '{0}': the digest value must be non-empty lowercase hex")]
NotHex(String),
}
impl core::str::FromStr for Digest {
type Err = InvalidDigestError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let Some((algorithm, hex)) = s.split_once(':') else {
return Err(InvalidDigestError::Malformed(s.to_string()));
};
if algorithm.is_empty() {
return Err(InvalidDigestError::Malformed(s.to_string()));
}
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()) {
return Err(InvalidDigestError::NotHex(s.to_string()));
}
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(InvalidDigestError::NotHex(s.to_string()));
}
Ok(Self {
algorithm: algorithm.to_string(),
hex: hex.to_string(),
})
}
}
impl fmt::Display for Digest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.algorithm, self.hex)
}
}
impl Serialize for Digest {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Digest {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let raw = String::deserialize(deserializer)?;
raw.parse().map_err(D::Error::custom)
}
}
#[cfg(test)]
mod digest_tests {
use super::*;
#[test]
fn digest_parses_and_round_trips() {
let d: Digest = "sha256:9f86d081884c7d659a2feaa0c55ad015".parse().unwrap();
assert_eq!(d.algorithm, "sha256");
assert_eq!(d.to_string(), "sha256:9f86d081884c7d659a2feaa0c55ad015");
}
#[test]
fn malformed_digest_is_rejected() {
assert!("9f86d081".parse::<Digest>().is_err(), "missing algorithm");
assert!(":abc".parse::<Digest>().is_err(), "empty algorithm");
assert!("sha256:".parse::<Digest>().is_err(), "empty hex");
assert!("sha256:zzzz".parse::<Digest>().is_err(), "non-hex");
assert!("sha256:ABCD".parse::<Digest>().is_err(), "uppercase hex");
}
#[test]
fn digest_round_trips_through_a_manifest() {
let src = serde_json::json!({
"manifest_version": "3.0.0",
"date": 1735689600,
"channels": [{"name": "0.15.0", "components": [{
"name": "core",
"version": {"kind": "registry", "version": "0.23.4"},
"kind": "package",
"artifacts": {"core.masp": {
"uri": "https://example.invalid/core.masp",
"digest": "sha256:deadbeef"
}}
}]}]
})
.to_string();
let m = crate::manifest::VersionedManifest::parse_str(&src).expect("parse");
let out: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(
out["channels"][0]["components"][0]["artifacts"]["core.masp"]["digest"],
serde_json::json!("sha256:deadbeef")
);
}
#[test]
fn a_malformed_digest_fails_the_parse() {
let src = serde_json::json!({
"manifest_version": "3.0.0",
"date": 1735689600,
"channels": [{"name": "0.15.0", "components": [{
"name": "core",
"version": {"kind": "registry", "version": "0.23.4"},
"kind": "package",
"artifacts": {"core.masp": {
"uri": "https://example.invalid/core.masp",
"digest": "not-a-digest"
}}
}]}]
})
.to_string();
assert!(crate::manifest::VersionedManifest::parse_str(&src).is_err());
}
}
#[cfg(test)]
mod forward_compatibility_tests {
use super::*;
#[test]
fn unknown_artifact_fields_round_trip() {
for source in [
serde_json::json!({
"uri": "https://example.invalid/%target",
"targets": {"aarch64-apple-darwin": {"basename": "vm"}},
"signature": {"alg": "ed25519", "sig": "deadbeef"}
}),
serde_json::json!({
"uri": "https://example.invalid/core.masp",
"digest": "sha256:9f86d081884c7d659a2feaa0c55ad015",
"provenance": ["a", "b"]
}),
] {
let artifact: Artifact = serde_json::from_value(source.clone()).expect("must parse");
let out = serde_json::to_value(&artifact).expect("must serialize");
assert_eq!(out, source, "an artifact must round-trip byte-for-byte");
}
}
#[test]
fn flattened_substitutions_are_not_duplicated_into_the_extras() {
let source = serde_json::json!({
"uri": "https://example.invalid/%target.%extension",
"basename": "miden-vm",
"extension": "tar.gz",
"targets": {"aarch64-apple-darwin": {}}
});
let artifact: Artifact = serde_json::from_value(source.clone()).unwrap();
let out = serde_json::to_value(&artifact).unwrap();
assert_eq!(out, source);
assert_eq!(
out.as_object().unwrap().len(),
4,
"no key may appear twice: {}",
serde_json::to_string(&out).unwrap()
);
}
#[test]
fn a_malformed_targets_map_is_reported_as_such() {
let err = serde_json::from_value::<Artifact>(serde_json::json!({
"uri": "https://example.invalid/%target",
"targets": ["aarch64-apple-darwin"]
}))
.expect_err("must reject");
assert!(err.to_string().contains("targets"), "{err}");
}
}