use std::{
collections::{HashMap, HashSet},
fs::{self, File},
io::{Read, Write},
path::{Component, Path, PathBuf},
};
use thiserror::Error;
use zip::{
write::{FileOptions, SimpleFileOptions},
CompressionMethod, ZipWriter,
};
use crate::build_info::{BuildInfo, BUILD_INFO_FILE};
use crate::fit::newest_fitting_level;
use crate::manifest::{
load_manifest, parse_model_limits, parse_provider_tool, refuse_retired_auth_block, Manifest,
ManifestError,
};
use crate::manifest_path::{resolve_manifest_path, MANIFEST_FILENAME};
use crate::payload_shape::{select_root_wasm_from_entries, PayloadShapeError};
use crate::registry::RuntimeType;
use crate::wit_contract::extract_wit_contracts;
use crate::wit_level::WitLevel;
use crate::zip_guard::{sanitize_entry_path, ZipGuardError};
pub const PACKED_MANIFEST_ENTRY: &str = MANIFEST_FILENAME;
pub const MAX_ARTIFACT_NAME_LEN: usize = 100;
#[derive(Debug, Error)]
pub enum BuildError {
#[error(transparent)]
Manifest(#[from] ManifestError),
#[error("output path must end with .mur.zip: {0}")]
InvalidOutputExtension(String),
#[error("missing required file '{file}' in {path}")]
MissingRequiredFile { file: String, path: String },
#[error("{path} declares runtime: skill; a skill is built from a folder holding SKILL.md, not from murmur.yaml")]
SkillManifest { path: String },
#[error("failed to create output file at {path}: {source}")]
CreateOutput {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to read source at {path}: {source}")]
ReadSource {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to determine working directory: {0}")]
WorkingDir(std::io::Error),
#[error("failed to package file {path}: {source}")]
PackageFile {
path: String,
#[source]
source: std::io::Error,
},
#[error("zip error while writing {path}: {source}")]
Zip {
path: String,
#[source]
source: zip::result::ZipError,
},
#[error("invalid artifact name '{name}': {reason}")]
InvalidArtifactName { name: String, reason: String },
#[error(
"invalid artifact name '{name}': an artifact's own name carries no scope, so it may not \
contain '/'"
)]
ScopedArtifactName { name: String },
#[error("requires_files entry '{entry}' has an unsafe path: {reason}")]
UnsafeRequiredPath { entry: String, reason: String },
#[error("requires_files entry '{entry}' is a symlink ({path}); declare the file it points to instead")]
SymlinkedRequiredFile { entry: String, path: String },
#[error(
"requires_files entries '{first}' and '{second}' both pack as the archive entry '{entry}'"
)]
DuplicateArchiveEntry {
first: String,
second: String,
entry: String,
},
#[error(transparent)]
PayloadShape(#[from] PayloadShapeError),
#[error(
"requires_files entry '{declared}' packs as the archive entry '{BUILD_INFO_FILE}', which \
is reserved for mur build"
)]
ReservedBuildInfoEntry { declared: String },
#[error(transparent)]
RetiredAuthBlock(ManifestError),
#[error(transparent)]
ModelLimits(ManifestError),
#[error(transparent)]
ProviderTool(ManifestError),
#[error(transparent)]
ProgramNeeds(ManifestError),
}
pub(crate) struct PackedEntry {
pub(crate) declared: String,
pub(crate) source_path: PathBuf,
pub(crate) archive_name: String,
}
pub(crate) struct ShadowedDeclaration {
pub(crate) declared: String,
pub(crate) archive_name: String,
}
pub(crate) struct PackedPlan {
pub(crate) entries: Vec<PackedEntry>,
pub(crate) shadowed: Vec<ShadowedDeclaration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuiltArtifact {
pub path: PathBuf,
pub built_at_level: Option<u64>,
}
pub fn refuse_skill_manifest(manifest: &Manifest, manifest_path: &Path) -> Result<(), BuildError> {
if manifest.runtime == "skill" {
return Err(BuildError::SkillManifest {
path: manifest_path.display().to_string(),
});
}
Ok(())
}
#[must_use = "artifact path is needed for caller output and follow-up actions"]
pub fn build_artifact(
source_dir: &Path,
output_path: &Path,
levels: &[WitLevel],
) -> Result<BuiltArtifact, BuildError> {
let manifest_path = resolve_manifest_path(source_dir);
let manifest = load_manifest(&manifest_path)?;
refuse_skill_manifest(&manifest, &manifest_path)?;
let manifest_yaml =
fs::read_to_string(&manifest_path).map_err(|source| BuildError::ReadSource {
path: manifest_path.display().to_string(),
source,
})?;
refuse_retired_auth_block(&manifest_yaml).map_err(BuildError::RetiredAuthBlock)?;
parse_model_limits(&manifest_yaml).map_err(BuildError::ModelLimits)?;
parse_provider_tool(&manifest_yaml).map_err(BuildError::ProviderTool)?;
crate::parse_program_needs(&manifest_yaml).map_err(BuildError::ProgramNeeds)?;
let plan = plan_packed_entries(source_dir, &manifest, output_path)?;
if !output_path.to_string_lossy().ends_with(".mur.zip") {
return Err(BuildError::InvalidOutputExtension(
output_path.display().to_string(),
));
}
let root_wasm = if manifest.registry_runtime() == RuntimeType::Wasm {
Some(select_root_wasm_from_entries(
plan.entries.iter().map(|entry| entry.archive_name.as_str()),
)?)
} else {
None
};
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).map_err(|source| BuildError::CreateOutput {
path: output_path.display().to_string(),
source,
})?;
}
let output_file = File::create(output_path).map_err(|source| BuildError::CreateOutput {
path: output_path.display().to_string(),
source,
})?;
let mut zip = ZipWriter::new(output_file);
let options: SimpleFileOptions =
FileOptions::default().compression_method(CompressionMethod::Deflated);
let mut contracts = None;
for entry in plan.entries {
zip.start_file(&entry.archive_name, options)
.map_err(|source| BuildError::Zip {
path: entry.source_path.display().to_string(),
source,
})?;
let mut content = Vec::new();
File::open(&entry.source_path)
.and_then(|mut f| f.read_to_end(&mut content))
.map_err(|source| BuildError::PackageFile {
path: entry.source_path.display().to_string(),
source,
})?;
zip.write_all(&content)
.map_err(|source| BuildError::PackageFile {
path: entry.source_path.display().to_string(),
source,
})?;
if root_wasm.as_deref() == Some(entry.archive_name.as_str()) {
contracts = extract_wit_contracts(&content).ok().flatten();
}
}
let built_at_level = newest_fitting_level(&contracts.unwrap_or_default(), levels);
if let Some(built_at_level) = built_at_level {
zip.start_file(BUILD_INFO_FILE, options)
.map_err(|source| BuildError::Zip {
path: output_path.display().to_string(),
source,
})?;
zip.write_all(BuildInfo { built_at_level }.to_json().as_bytes())
.map_err(|source| BuildError::PackageFile {
path: output_path.display().to_string(),
source,
})?;
}
zip.finish().map_err(|source| BuildError::Zip {
path: output_path.display().to_string(),
source,
})?;
Ok(BuiltArtifact {
path: output_path.to_path_buf(),
built_at_level,
})
}
pub(crate) fn plan_packed_entries(
source_dir: &Path,
manifest: &Manifest,
output_path: &Path,
) -> Result<PackedPlan, BuildError> {
validate_artifact_name(&manifest.name)?;
for required in &manifest.requires_files {
check_declared_entry(source_dir, required)?;
}
let output_abs = absolute_normalized(output_path)?;
let source_abs = source_dir
.canonicalize()
.map_err(|source| BuildError::ReadSource {
path: source_dir.display().to_string(),
source,
})?;
let declared = std::iter::once(MANIFEST_FILENAME)
.chain(manifest.requires_files.iter().map(String::as_str));
let mut entries: Vec<PackedEntry> = Vec::new();
let mut shadowed: Vec<ShadowedDeclaration> = Vec::new();
let mut packed: HashSet<PathBuf> = HashSet::new();
for rel in declared {
let abs = absolute_normalized(&source_abs.join(rel))?;
let archive_name = archive_entry_name(rel);
if packed.insert(abs.clone()) {
entries.push(PackedEntry {
declared: rel.to_string(),
source_path: abs,
archive_name,
});
} else {
shadowed.push(ShadowedDeclaration {
declared: rel.to_string(),
archive_name,
});
}
}
entries.retain(|entry| entry.source_path != output_abs);
let mut claimed: HashMap<&str, &str> = HashMap::new();
for entry in &entries {
if entry.archive_name == BUILD_INFO_FILE {
return Err(BuildError::ReservedBuildInfoEntry {
declared: entry.declared.clone(),
});
}
if let Some(first) = claimed.insert(&entry.archive_name, &entry.declared) {
return Err(BuildError::DuplicateArchiveEntry {
first: first.to_string(),
second: entry.declared.clone(),
entry: entry.archive_name.clone(),
});
}
}
Ok(PackedPlan { entries, shadowed })
}
fn archive_entry_name(rel: &str) -> String {
if Path::new(rel) == Path::new(MANIFEST_FILENAME) {
PACKED_MANIFEST_ENTRY.to_string()
} else {
rel.replace('\\', "/")
}
}
fn check_declared_entry(source_dir: &Path, declared: &str) -> Result<(), BuildError> {
if declared.starts_with('/') || Path::new(declared).is_absolute() {
return Err(BuildError::UnsafeRequiredPath {
entry: declared.to_string(),
reason: "is an absolute path".to_string(),
});
}
sanitize_entry_path(declared).map_err(|error| BuildError::UnsafeRequiredPath {
entry: declared.to_string(),
reason: match error {
ZipGuardError::UnsafeEntryPath { reason, .. } => reason,
other => other.to_string(),
},
})?;
let path = source_dir.join(declared);
if !path.exists() {
return Err(BuildError::MissingRequiredFile {
file: declared.to_string(),
path: source_dir.display().to_string(),
});
}
let metadata = fs::symlink_metadata(&path).map_err(|source| BuildError::ReadSource {
path: path.display().to_string(),
source,
})?;
if metadata.file_type().is_symlink() {
return Err(BuildError::SymlinkedRequiredFile {
entry: declared.to_string(),
path: path.display().to_string(),
});
}
Ok(())
}
pub fn validate_artifact_name(name: &str) -> Result<(), BuildError> {
if name.contains('/') {
return Err(BuildError::ScopedArtifactName {
name: name.to_string(),
});
}
match artifact_name_format_error(name) {
Some(reason) => Err(BuildError::InvalidArtifactName {
name: name.to_string(),
reason,
}),
None => Ok(()),
}
}
pub fn artifact_name_format_error(name: &str) -> Option<String> {
if name.is_empty() {
return Some("must not be empty".to_string());
}
if name.chars().count() > MAX_ARTIFACT_NAME_LEN {
return Some(format!(
"must be at most {MAX_ARTIFACT_NAME_LEN} characters"
));
}
if let Some(bad) = name
.chars()
.find(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-'))
{
return Some(format!(
"may contain only lowercase letters, digits and '-' (found {bad:?})"
));
}
if name.starts_with('-') || name.ends_with('-') {
return Some("must not start or end with '-'".to_string());
}
None
}
fn absolute_normalized(path: &Path) -> Result<PathBuf, BuildError> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(BuildError::WorkingDir)?
.join(path)
};
Ok(normalize_path(&abs))
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payload_shape::SKILL_MD_ENTRY;
use tempfile::tempdir;
use zip::ZipArchive;
#[test]
fn a_skill_manifest_is_refused_before_anything_is_written() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: test-skill\nversion: 0.1.0\nruntime: skill\n",
)
.unwrap();
fs::write(dir.path().join(SKILL_MD_ENTRY), "# Skill\nGuidance.\n").unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &[]).unwrap_err();
assert!(matches!(err, BuildError::SkillManifest { .. }), "{err}");
assert!(
err.to_string()
.ends_with("declares runtime: skill; a skill is built from a folder holding SKILL.md, not from murmur.yaml"),
"{err}"
);
assert!(!out.exists(), "no zip should be created on failure");
}
#[test]
fn requires_files_is_enforced_for_non_skill_role() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: test-tool\nversion: 0.1.0\nruntime: tool\nrequires_files:\n - config.json\n - tool.wasm\n",
)
.unwrap();
fs::write(dir.path().join("tool.wasm"), b"\0asm").unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &[]).unwrap_err();
assert!(
matches!(&err, BuildError::MissingRequiredFile { file, .. } if file == "config.json"),
"expected MissingRequiredFile for config.json; got: {err}"
);
assert!(!out.exists(), "no zip should be created on failure");
fs::write(dir.path().join("config.json"), "{}\n").unwrap();
build_artifact(dir.path(), &out, &[])
.expect("build succeeds once the declared file exists");
}
fn entry_names(archive_path: &Path) -> Vec<String> {
let file = File::open(archive_path).unwrap();
let mut archive = ZipArchive::new(file).unwrap();
(0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().unwrap().into_owned())
.collect()
}
#[test]
fn packs_only_the_manifest_and_declared_files() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: hook-compact\nversion: 0.3.0\nruntime: hook\nexecution: wasm\nrequires_files:\n - murmur_hook_compact.wasm\n",
)
.unwrap();
fs::write(dir.path().join("murmur_hook_compact.wasm"), b"\0asm").unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/lib.rs"), "fn main() {}").unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\n").unwrap();
fs::write(dir.path().join("README.md"), "# hook\n").unwrap();
fs::write(dir.path().join(".DS_Store"), "junk").unwrap();
let out = dir.path().join("artifact.mur.zip");
build_artifact(dir.path(), &out, &[]).unwrap();
assert_eq!(
entry_names(&out),
vec![
PACKED_MANIFEST_ENTRY.to_string(),
"murmur_hook_compact.wasm".to_string()
],
"a curated artifact carries its manifest and its declared payload, nothing else"
);
}
#[test]
fn declaring_a_nested_asset_excludes_its_undeclared_siblings() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: nested\nversion: 0.1.0\nruntime: tool\nrequires_files:\n - assets/logo.png\n - tool.wasm\n",
)
.unwrap();
fs::write(dir.path().join("tool.wasm"), b"\0asm").unwrap();
fs::create_dir_all(dir.path().join("assets")).unwrap();
fs::write(dir.path().join("assets/logo.png"), "png").unwrap();
fs::write(dir.path().join("assets/other.txt"), "not declared").unwrap();
let out = dir.path().join("artifact.mur.zip");
build_artifact(dir.path(), &out, &[]).unwrap();
assert_eq!(
entry_names(&out),
vec![
PACKED_MANIFEST_ENTRY.to_string(),
"assets/logo.png".to_string(),
"tool.wasm".to_string()
],
"only the declared member of assets/ ships"
);
}
#[test]
fn empty_requires_files_packs_a_manifest_only_zip() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: undeclared\nversion: 0.1.0\nruntime: tool\nexecution: static\n",
)
.unwrap();
fs::write(dir.path().join("undeclared.wasm"), b"\0asm").unwrap();
fs::write(dir.path().join("notes.txt"), "stray").unwrap();
let out = dir.path().join("artifact.mur.zip");
build_artifact(dir.path(), &out, &[]).expect("nothing declared is not an error");
assert_eq!(
entry_names(&out),
vec![PACKED_MANIFEST_ENTRY.to_string()],
"nothing declared means nothing but the manifest is packed"
);
}
#[test]
fn requires_files_naming_the_manifest_does_not_double_pack_it() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: redundant\nversion: 0.1.0\nruntime: tool\nexecution: static\nrequires_files:\n - murmur.yaml\n - payload.bin\n",
)
.unwrap();
fs::write(dir.path().join("payload.bin"), "bytes").unwrap();
let out = dir.path().join("artifact.mur.zip");
build_artifact(dir.path(), &out, &[]).unwrap();
assert_eq!(
entry_names(&out),
vec![PACKED_MANIFEST_ENTRY.to_string(), "payload.bin".to_string()],
"the manifest is packed once regardless of how often it is declared"
);
}
#[test]
fn missing_declared_payload_fails_before_any_zip_is_written() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: hook-compact\nversion: 0.3.0\nruntime: hook\nexecution: wasm\nrequires_files:\n - murmur_hook_compact.wasm\n",
)
.unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &[]).unwrap_err();
assert!(
matches!(&err, BuildError::MissingRequiredFile { file, .. } if file == "murmur_hook_compact.wasm"),
"expected MissingRequiredFile for the declared wasm; got: {err}"
);
assert!(!out.exists(), "no zip should be created on failure");
}
#[test]
fn builds_zip_with_manifest_at_root() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: hello\nversion: 0.0.1\nruntime: wasm\nrequires_files:\n - assets/file.txt\n - tool.wasm\n",
)
.unwrap();
fs::write(dir.path().join("tool.wasm"), b"\0asm").unwrap();
fs::create_dir_all(dir.path().join("assets")).unwrap();
fs::write(dir.path().join("assets/file.txt"), "ok").unwrap();
let out = dir.path().join("artifact.mur.zip");
build_artifact(dir.path(), &out, &[]).unwrap();
let file = File::open(&out).unwrap();
let mut archive = ZipArchive::new(file).unwrap();
let mut names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().unwrap().into_owned())
.collect();
names.sort();
assert!(
names.contains(&"murmur.yaml".to_string()),
"murmur.yaml must be at archive root, got: {:?}",
names
);
assert!(
names.contains(&"assets/file.txt".to_string()),
"nested file must be included, got: {:?}",
names
);
let mut content = String::new();
archive
.by_name("murmur.yaml")
.unwrap()
.read_to_string(&mut content)
.unwrap();
assert!(content.contains("name: hello"));
assert!(
!names.iter().any(|n| n.ends_with(".mur.zip")),
"output artifact must not appear inside zip"
);
}
#[test]
fn source_manifest_is_the_only_root_manifest_entry() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: root\nversion: 0.0.2\nruntime: wasm\nrequires_files:\n - tools/murmur.yaml\n - tool.wasm\n",
)
.unwrap();
fs::write(dir.path().join("tool.wasm"), b"\0asm").unwrap();
fs::create_dir_all(dir.path().join("tools")).unwrap();
fs::write(
dir.path().join("tools").join(MANIFEST_FILENAME),
"name: nested\nversion: 0.0.1\nruntime: wasm\n",
)
.unwrap();
let out = dir.path().join("artifact.mur.zip");
build_artifact(dir.path(), &out, &[]).unwrap();
let file = File::open(&out).unwrap();
let mut archive = ZipArchive::new(file).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().unwrap().into_owned())
.collect();
assert_eq!(
names.iter().filter(|n| *n == PACKED_MANIFEST_ENTRY).count(),
1,
"exactly one root manifest entry expected, got: {names:?}"
);
assert!(
names.contains(&"tools/murmur.yaml".to_string()),
"a same-named nested file must keep its own path, got: {names:?}"
);
let mut content = String::new();
archive
.by_name(PACKED_MANIFEST_ENTRY)
.unwrap()
.read_to_string(&mut content)
.unwrap();
assert!(
content.contains("name: root"),
"packed entry must come from the root {MANIFEST_FILENAME}, got: {content}"
);
}
#[test]
fn built_artifact_round_trips_through_manifest_readers() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: roundtrip\nversion: 0.4.2\nruntime: wasm\nrequires_files:\n - tool.wasm\n",
)
.unwrap();
fs::write(dir.path().join("tool.wasm"), b"\0asm").unwrap();
let out = dir.path().join("artifact.mur.zip");
build_artifact(dir.path(), &out, &[]).unwrap();
let bytes = fs::read(&out).unwrap();
let yaml = crate::artifact::load_manifest_yaml_from_artifact_bytes(&bytes).unwrap();
assert!(yaml.contains("name: roundtrip"));
let manifest = crate::artifact::load_manifest_from_artifact_bytes(&bytes).unwrap();
assert_eq!(manifest.name, "roundtrip");
assert_eq!(manifest.version, "0.4.2");
}
fn build_named(name: &str) -> Result<BuiltArtifact, BuildError> {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
format!("name: {name}\nversion: 0.1.0\nruntime: tool\nexecution: static\nrequires_files: []\n"),
)
.unwrap();
let out = dir.path().join("artifact.mur.zip");
let result = build_artifact(dir.path(), &out, &[]);
if result.is_err() {
assert!(!out.exists(), "no zip should be created on failure");
}
result
}
#[test]
fn malformed_artifact_names_are_rejected() {
for (name, expected_reason) in [
("My-Tool", "lowercase"),
("my tool", "lowercase"),
("tools/my-tool", "carries no scope"),
("ana/tool-x", "carries no scope"),
("tools\\\\my-tool", "lowercase"),
("my_tool", "lowercase"),
("-my-tool", "start or end"),
("my-tool-", "start or end"),
("''", "empty"),
] {
let err = build_named(name)
.expect_err("expected an invalid-name failure")
.to_string();
assert!(
err.contains("invalid artifact name") && err.contains(expected_reason),
"name {name:?} should be rejected as {expected_reason}; got: {err}"
);
}
}
#[test]
fn an_over_long_artifact_name_is_rejected() {
let name = "a".repeat(MAX_ARTIFACT_NAME_LEN + 1);
let err = build_named(&name).unwrap_err().to_string();
assert!(
err.contains("at most 100 characters"),
"expected a length rejection; got: {err}"
);
build_named(&"a".repeat(MAX_ARTIFACT_NAME_LEN)).expect("the bound itself is allowed");
}
#[test]
fn conventional_artifact_names_including_murmur_prefixed_ones_are_accepted() {
for name in [
"murmur-hook-compact",
"murmur-tool-git",
"murmur-driver-anthropic",
"hello-slice",
"jsonl-line-count",
"tool2",
] {
build_named(name).unwrap_or_else(|err| panic!("{name} should build; got: {err}"));
}
}
#[test]
fn a_traversing_requires_files_entry_is_rejected() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("secret.txt"), "outside").unwrap();
let source = dir.path().join("artifact");
fs::create_dir_all(&source).unwrap();
fs::write(
source.join(MANIFEST_FILENAME),
"name: traversal\nversion: 0.1.0\nruntime: tool\nexecution: static\nrequires_files:\n - ../secret.txt\n",
)
.unwrap();
let out = source.join("artifact.mur.zip");
let err = build_artifact(&source, &out, &[]).unwrap_err();
assert!(
matches!(&err, BuildError::UnsafeRequiredPath { entry, reason } if entry == "../secret.txt" && reason.contains("'..'")),
"expected UnsafeRequiredPath; got: {err}"
);
assert!(!out.exists(), "no zip should be created on failure");
}
#[test]
fn an_absolute_requires_files_entry_is_rejected() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: absolute\nversion: 0.1.0\nruntime: tool\nexecution: static\nrequires_files:\n - /etc/hosts\n",
)
.unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &[]).unwrap_err();
assert!(
matches!(&err, BuildError::UnsafeRequiredPath { entry, reason } if entry == "/etc/hosts" && reason.contains("absolute")),
"expected UnsafeRequiredPath; got: {err}"
);
assert!(!out.exists(), "no zip should be created on failure");
}
#[cfg(unix)]
#[test]
fn a_symlinked_requires_files_entry_is_rejected_without_following_it() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("real.txt"), "real bytes").unwrap();
std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt"))
.unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: symlinked\nversion: 0.1.0\nruntime: tool\nexecution: static\nrequires_files:\n - link.txt\n",
)
.unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &[]).unwrap_err();
assert!(
matches!(&err, BuildError::SymlinkedRequiredFile { entry, .. } if entry == "link.txt"),
"expected SymlinkedRequiredFile; got: {err}"
);
assert!(!out.exists(), "no zip should be created on failure");
}
#[cfg(unix)]
#[test]
fn two_declared_files_may_not_claim_the_same_archive_entry() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("sub/payload.bin"), "nested").unwrap();
fs::write(dir.path().join("sub\\payload.bin"), "flat").unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: collision\nversion: 0.1.0\nruntime: tool\nexecution: static\nrequires_files:\n - sub/payload.bin\n - sub\\payload.bin\n",
)
.unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &[]).unwrap_err();
assert!(
matches!(&err, BuildError::DuplicateArchiveEntry { entry, .. } if entry == "sub/payload.bin"),
"expected DuplicateArchiveEntry; got: {err}"
);
assert!(!out.exists(), "no zip should be created on failure");
}
#[test]
fn wasm_artifact_without_a_root_wasm_fails_before_any_zip_is_written() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: no-payload\nversion: 0.1.0\nruntime: wasm\nrequires_files:\n - README.md\n",
)
.unwrap();
fs::write(dir.path().join("README.md"), "# docs\n").unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &[]).unwrap_err();
assert_eq!(
err.to_string(),
"missing root .wasm file (expected capsule.wasm or one root *.wasm)",
"the build-time message is the runtime's message, verbatim"
);
assert!(!out.exists(), "no zip should be created on failure");
}
#[test]
fn wasm_artifact_with_two_root_wasm_files_fails_with_sorted_names() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: ambiguous\nversion: 0.1.0\nruntime: hook\nexecution: wasm\nrequires_files:\n - zeta.wasm\n - alpha.wasm\n",
)
.unwrap();
fs::write(dir.path().join("zeta.wasm"), b"\0asm").unwrap();
fs::write(dir.path().join("alpha.wasm"), b"\0asm").unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &[]).unwrap_err();
assert_eq!(
err.to_string(),
"multiple root .wasm files found: alpha.wasm, zeta.wasm"
);
assert!(!out.exists(), "no zip should be created on failure");
}
#[test]
fn capsule_wasm_beside_another_root_wasm_still_builds() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: preferred\nversion: 0.1.0\nruntime: wasm\nrequires_files:\n - capsule.wasm\n - tool.wasm\n",
)
.unwrap();
fs::write(dir.path().join("capsule.wasm"), b"\0asm").unwrap();
fs::write(dir.path().join("tool.wasm"), b"\0asm").unwrap();
let out = dir.path().join("artifact.mur.zip");
build_artifact(dir.path(), &out, &[])
.expect("capsule.wasm makes the selection unambiguous");
assert_eq!(
entry_names(&out),
vec![
PACKED_MANIFEST_ENTRY.to_string(),
"capsule.wasm".to_string(),
"tool.wasm".to_string()
]
);
}
#[test]
fn a_native_tool_manifest_is_refused_and_no_archive_is_written() {
for (manifest, field) in [
("name: nat-tool\nversion: 0.1.0\nruntime: tool\nimplementation: native\nrequires_files: []\n", "implementation"),
("name: nat-tool\nversion: 0.1.0\nruntime: tool\nexecution: native\nrequires_files: []\n", "execution"),
] {
let dir = tempdir().unwrap();
fs::write(dir.path().join(MANIFEST_FILENAME), manifest).unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &[]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains(&format!("artifact 'nat-tool' declares {field}: native")),
"{msg}"
);
assert!(!out.exists());
}
}
#[test]
fn static_artifacts_are_not_held_to_the_wasm_payload_shape() {
let manifest =
"name: static-thing\nversion: 0.1.0\nruntime: tool\nexecution: static\nrequires_files: []\n";
let dir = tempdir().unwrap();
fs::write(dir.path().join(MANIFEST_FILENAME), manifest).unwrap();
let out = dir.path().join("artifact.mur.zip");
build_artifact(dir.path(), &out, &[])
.unwrap_or_else(|err| panic!("{manifest} should build; got: {err}"));
assert_eq!(entry_names(&out), vec![PACKED_MANIFEST_ENTRY.to_string()]);
}
fn tool_levels() -> Vec<WitLevel> {
crate::parse_wit_levels("1 murmur:tool@0.0.9\n2 murmur:tool@0.1.0\n").unwrap()
}
fn tool_component(version: &str) -> Vec<u8> {
wat::parse_str(format!(
r#"(component
(core module $m (func (export "run")))
(core instance $i (instantiate $m))
(func $run (canon lift (core func $i "run")))
(instance $iface (export "run" (func $run)))
(export "murmur:tool/run@{version}" (instance $iface)))"#
))
.unwrap()
}
fn read_entry(out: &Path, name: &str) -> Vec<u8> {
let mut archive = ZipArchive::new(File::open(out).unwrap()).unwrap();
let mut entry = archive.by_name(name).unwrap();
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).unwrap();
bytes
}
fn build_tool(wasm: &[u8], levels: &[WitLevel]) -> (tempfile::TempDir, BuiltArtifact) {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: tool-x\nversion: 0.1.0\nruntime: tool\nrequires_files:\n - tool.wasm\n",
)
.unwrap();
fs::write(dir.path().join("tool.wasm"), wasm).unwrap();
let out = dir.path().join("artifact.mur.zip");
let built = build_artifact(dir.path(), &out, levels).unwrap();
(dir, built)
}
#[test]
fn build_labels_a_current_tool_with_the_newest_level_it_fits() {
let wasm = tool_component("0.1.0");
let (dir, built) = build_tool(&wasm, &tool_levels());
assert_eq!(built.built_at_level, Some(2));
assert_eq!(
entry_names(&built.path),
vec![
PACKED_MANIFEST_ENTRY.to_string(),
"tool.wasm".to_string(),
BUILD_INFO_FILE.to_string()
]
);
assert_eq!(
read_entry(&built.path, BUILD_INFO_FILE),
b"{\"built_at_level\":2}\n"
);
assert_eq!(read_entry(&built.path, "tool.wasm"), wasm);
assert_eq!(
read_entry(&built.path, PACKED_MANIFEST_ENTRY),
fs::read(dir.path().join(MANIFEST_FILENAME)).unwrap()
);
}
#[test]
fn build_labels_an_older_tool_with_the_older_level_and_one_fitting_none_with_nothing() {
let (_dir, built) = build_tool(&tool_component("0.0.9"), &tool_levels());
assert_eq!(built.built_at_level, Some(1));
assert_eq!(
read_entry(&built.path, BUILD_INFO_FILE),
b"{\"built_at_level\":1}\n"
);
let (_dir, built) = build_tool(&tool_component("0.0.8"), &tool_levels());
assert_eq!(built.built_at_level, None);
assert_eq!(
entry_names(&built.path),
vec![PACKED_MANIFEST_ENTRY.to_string(), "tool.wasm".to_string()]
);
}
#[test]
fn build_labels_a_static_payload_with_the_newest_level() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: test-notes\nversion: 0.1.0\nruntime: tool\nexecution: static\nrequires_files:\n - notes.md\n",
)
.unwrap();
fs::write(dir.path().join("notes.md"), "# Notes\n").unwrap();
let out = dir.path().join("artifact.mur.zip");
let built = build_artifact(dir.path(), &out, &tool_levels()).unwrap();
assert_eq!(built.built_at_level, Some(2));
assert_eq!(
read_entry(&out, BUILD_INFO_FILE),
b"{\"built_at_level\":2}\n"
);
}
#[test]
fn build_refuses_an_authored_build_info_entry() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: test-skill\nversion: 0.1.0\nruntime: tool\nexecution: static\nrequires_files:\n - notes.md\n - murmur-build.json\n",
)
.unwrap();
fs::write(dir.path().join("notes.md"), "# Notes\n").unwrap();
fs::write(
dir.path().join("murmur-build.json"),
"{\"built_at_level\":9}",
)
.unwrap();
let out = dir.path().join("artifact.mur.zip");
let err = build_artifact(dir.path(), &out, &tool_levels()).unwrap_err();
assert_eq!(
err.to_string(),
"requires_files entry 'murmur-build.json' packs as the archive entry \
'murmur-build.json', which is reserved for mur build"
);
assert!(!out.exists());
}
}