use std::{
fs,
io::Write,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::artifact_name::ArtifactName;
pub const LOCK_VERSION: u32 = 3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MurmurLock {
pub lock_version: u32,
pub artifacts: Vec<LockedArtifact>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "RawLockedArtifact", into = "RawLockedArtifact")]
pub struct LockedArtifact {
pub name: String,
pub resolved_version: String,
pub sha256: LockedSha256,
pub origin: LockOrigin,
pub owner_id: Option<u64>,
pub signer: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum LockOrigin {
#[default]
Operator,
Runtime { session: String },
}
const ORIGIN_OPERATOR: &str = "operator";
const ORIGIN_RUNTIME: &str = "runtime";
impl LockOrigin {
pub fn as_str(&self) -> &'static str {
match self {
Self::Operator => ORIGIN_OPERATOR,
Self::Runtime { .. } => ORIGIN_RUNTIME,
}
}
}
#[derive(Serialize, Deserialize)]
struct RawLockedArtifact {
name: String,
resolved_version: String,
sha256: RawLockedSha256,
#[serde(default, skip_serializing_if = "Option::is_none")]
origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
owner_id: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
signer: Option<String>,
}
impl TryFrom<RawLockedArtifact> for LockedArtifact {
type Error = String;
fn try_from(raw: RawLockedArtifact) -> Result<Self, Self::Error> {
if raw.sha256.platforms.is_some() {
return Err(format!(
"artifact '{}' pins per-platform hashes (sha256.platforms), which murmur does not \
read: an entry pins one sha256.any {REGENERATE_HINT}",
raw.name
));
}
let Some(any) = raw.sha256.any else {
return Err(format!(
"artifact '{}' pins no hash (expected sha256.any)",
raw.name
));
};
let origin = match (raw.origin.as_deref(), raw.session) {
(None | Some(ORIGIN_OPERATOR), None) => LockOrigin::Operator,
(None | Some(ORIGIN_OPERATOR), Some(_)) => {
return Err(format!(
"artifact '{}' is origin: operator but carries a session key (only an \
origin: runtime entry names a session)",
raw.name
))
}
(Some(ORIGIN_RUNTIME), Some(session)) => LockOrigin::Runtime { session },
(Some(ORIGIN_RUNTIME), None) => {
return Err(format!(
"artifact '{}' is origin: runtime but has no session key",
raw.name
))
}
(Some(other), _) => {
return Err(format!(
"artifact '{}' has unknown origin '{other}' (expected {ORIGIN_OPERATOR} or \
{ORIGIN_RUNTIME})",
raw.name
))
}
};
Ok(Self {
name: raw.name,
resolved_version: raw.resolved_version,
sha256: LockedSha256 { any },
origin,
owner_id: raw.owner_id,
signer: raw.signer,
})
}
}
impl From<LockedArtifact> for RawLockedArtifact {
fn from(entry: LockedArtifact) -> Self {
let (origin, session) = match entry.origin {
LockOrigin::Operator => (ORIGIN_OPERATOR, None),
LockOrigin::Runtime { session } => (ORIGIN_RUNTIME, Some(session)),
};
Self {
name: entry.name,
resolved_version: entry.resolved_version,
sha256: RawLockedSha256 {
any: Some(entry.sha256.any),
platforms: None,
},
origin: Some(origin.to_string()),
session,
owner_id: entry.owner_id,
signer: entry.signer,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockedSha256 {
pub any: String,
}
#[derive(Serialize, Deserialize)]
struct RawLockedSha256 {
#[serde(default, skip_serializing_if = "Option::is_none")]
any: Option<String>,
#[serde(default, skip_serializing)]
platforms: Option<serde_norway::Value>,
}
impl LockedSha256 {
#[must_use]
pub fn any(sha256: impl Into<String>) -> Self {
Self { any: sha256.into() }
}
fn validate(&self, artifact_name: &str) -> Result<(), LockfileError> {
if self.any.trim().is_empty() {
return Err(LockfileError::Invalid(format!(
"artifact '{artifact_name}' has empty sha256.any"
)));
}
Ok(())
}
}
impl LockedArtifact {
pub fn pin(&mut self, resolved_version: &str, sha256: LockedSha256, origin: LockOrigin) {
if origin == LockOrigin::Operator {
self.origin = LockOrigin::Operator;
}
if self.sha256 != sha256 {
self.signer = None;
}
self.sha256 = sha256;
self.resolved_version = resolved_version.to_string();
}
#[must_use]
pub fn conflict_with(&self, version: &str, sha256: &LockedSha256) -> Option<String> {
if self.resolved_version != version {
return Some(format!(
"pinned {}@{}, but {}@{version} was resolved",
self.name, self.resolved_version, self.name
));
}
self.same_version_conflict_with(version, sha256)
}
#[must_use]
pub fn same_version_conflict_with(
&self,
version: &str,
sha256: &LockedSha256,
) -> Option<String> {
if self.resolved_version != version {
return None;
}
if self.sha256.any != sha256.any {
return Some(format!(
"'{}' is pinned at sha256 {}, but {} was resolved",
self.name, self.sha256.any, sha256.any
));
}
None
}
}
#[derive(Debug, Error)]
pub enum LockfileError {
#[error("murmur.lock not found at {0}")]
NotFound(String),
#[error("invalid murmur.lock: {0}")]
Invalid(String),
#[error("failed to read murmur.lock at {path}: {source}")]
ReadIo {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to write murmur.lock at {path}: {source}")]
WriteIo {
path: String,
#[source]
source: std::io::Error,
},
}
impl MurmurLock {
#[must_use]
pub fn artifact_for(&self, name: &str) -> Option<&LockedArtifact> {
self.artifacts.iter().find(|entry| entry.name == name)
}
pub fn upsert(
&mut self,
name: &str,
version: &str,
sha256: LockedSha256,
origin: LockOrigin,
) -> Option<LockOrigin> {
if let Some(entry) = self.artifacts.iter_mut().find(|entry| entry.name == name) {
let previous = entry.origin.clone();
entry.pin(version, sha256, origin);
Some(previous)
} else {
self.artifacts.push(LockedArtifact {
name: name.to_string(),
resolved_version: version.to_string(),
sha256,
origin,
owner_id: None,
signer: None,
});
None
}
}
pub fn set_owner_id(&mut self, name: &str, owner_id: u64) -> bool {
match self.artifacts.iter_mut().find(|entry| entry.name == name) {
Some(entry) => {
entry.owner_id = Some(owner_id);
true
}
None => false,
}
}
pub fn set_signer(&mut self, name: &str, signer: Option<String>) -> bool {
match self.artifacts.iter_mut().find(|entry| entry.name == name) {
Some(entry) => {
entry.signer = signer;
true
}
None => false,
}
}
#[must_use]
pub fn owner_conflict(&self, scope: &str, owner_id: u64) -> Option<&LockedArtifact> {
self.artifacts.iter().find(|entry| {
entry.owner_id.is_some_and(|recorded| recorded != owner_id)
&& entry
.name
.split_once('/')
.is_some_and(|(entry_scope, _)| entry_scope == scope)
})
}
pub fn remove(&mut self, name: &str) -> Option<LockedArtifact> {
let index = self.artifacts.iter().position(|entry| entry.name == name)?;
Some(self.artifacts.remove(index))
}
pub fn validate(&self) -> Result<(), LockfileError> {
check_lock_version(self.lock_version)?;
for entry in &self.artifacts {
if entry.name.trim().is_empty() {
return Err(LockfileError::Invalid(
"artifact entry has empty name".to_string(),
));
}
if let Err(error) = ArtifactName::parse(&entry.name) {
return Err(LockfileError::Invalid(format!(
"artifact '{}' is not named <scope>/<name> ({error}) {REGENERATE_HINT}",
entry.name
)));
}
if entry.resolved_version.trim().is_empty() {
return Err(LockfileError::Invalid(format!(
"artifact '{}' has empty resolved_version",
entry.name
)));
}
entry.sha256.validate(&entry.name)?;
if entry
.signer
.as_deref()
.is_some_and(|signer| signer.trim().is_empty())
{
return Err(LockfileError::Invalid(format!(
"artifact '{}' has an empty signer",
entry.name
)));
}
if let LockOrigin::Runtime { session } = &entry.origin {
if session.trim().is_empty() {
return Err(LockfileError::Invalid(format!(
"artifact '{}' is origin: runtime with an empty session",
entry.name
)));
}
}
}
Ok(())
}
}
const REGENERATE_HINT: &str = "\u{2014} delete murmur.lock and run `mur install` to regenerate it";
fn check_lock_version(lock_version: u32) -> Result<(), LockfileError> {
if lock_version == LOCK_VERSION {
return Ok(());
}
Err(LockfileError::Invalid(format!(
"unsupported lock_version {lock_version} (expected {LOCK_VERSION}) {REGENERATE_HINT}"
)))
}
#[derive(Deserialize)]
struct LockVersionOnly {
lock_version: u32,
}
pub fn read_lockfile(path: &Path) -> Result<MurmurLock, LockfileError> {
let raw = fs::read_to_string(path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
return LockfileError::NotFound(path.display().to_string());
}
LockfileError::ReadIo {
path: path.display().to_string(),
source,
}
})?;
let parse_error =
|err: serde_norway::Error| LockfileError::Invalid(format!("YAML parse error: {err}"));
let version: LockVersionOnly = serde_norway::from_str(&raw).map_err(parse_error)?;
check_lock_version(version.lock_version)?;
let lock: MurmurLock = serde_norway::from_str(&raw).map_err(parse_error)?;
lock.validate()?;
Ok(lock)
}
pub fn write_lockfile_atomic(path: &Path, lock: &MurmurLock) -> Result<(), LockfileError> {
lock.validate()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| LockfileError::WriteIo {
path: parent.display().to_string(),
source,
})?;
}
let yaml = serde_norway::to_string(lock)
.map_err(|err| LockfileError::Invalid(format!("YAML serialize error: {err}")))?;
let tmp = temp_path(path);
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp)
.map_err(|source| LockfileError::WriteIo {
path: tmp.display().to_string(),
source,
})?;
file.write_all(yaml.as_bytes())
.map_err(|source| LockfileError::WriteIo {
path: tmp.display().to_string(),
source,
})?;
file.sync_all().map_err(|source| LockfileError::WriteIo {
path: tmp.display().to_string(),
source,
})?;
fs::rename(&tmp, path).map_err(|source| LockfileError::WriteIo {
path: format!("{} -> {}", tmp.display(), path.display()),
source,
})?;
Ok(())
}
fn temp_path(path: &Path) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("murmur.lock");
path.with_file_name(format!(".{file_name}.{nanos}.tmp"))
}
#[cfg(test)]
mod tests {
use super::*;
fn lock_with(entries: Vec<LockedArtifact>) -> MurmurLock {
MurmurLock {
lock_version: LOCK_VERSION,
artifacts: entries,
}
}
fn entry(name: &str, version: &str, sha256: LockedSha256) -> LockedArtifact {
LockedArtifact {
name: name.to_string(),
resolved_version: version.to_string(),
sha256,
origin: LockOrigin::Operator,
owner_id: None,
signer: None,
}
}
fn runtime(session: &str) -> LockOrigin {
LockOrigin::Runtime {
session: session.to_string(),
}
}
#[test]
fn lock_origin_as_str_round_trips_through_the_lock_file() {
assert_eq!(LockOrigin::Operator.as_str(), "operator");
assert_eq!(runtime("ses_puller").as_str(), "runtime");
assert_eq!(runtime("").as_str(), "runtime");
for origin in [LockOrigin::Operator, runtime("ses_puller")] {
let raw = RawLockedArtifact::from(LockedArtifact {
origin: origin.clone(),
..entry("local/a", "1.0.0", LockedSha256::any("abc"))
});
assert_eq!(raw.origin.as_deref(), Some(origin.as_str()));
let read_back = LockedArtifact::try_from(raw).unwrap();
assert_eq!(read_back.origin, origin);
}
}
#[test]
fn write_then_read_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
let lock = lock_with(vec![
entry("local/echo-tool", "0.0.1", LockedSha256::any("abc123")),
LockedArtifact {
origin: runtime("ses_0190a1b2"),
..entry("local/pulled-tool", "1.2.3", LockedSha256::any("fed789"))
},
]);
write_lockfile_atomic(&path, &lock).unwrap();
let read_back = read_lockfile(&path).unwrap();
assert_eq!(read_back, lock);
}
#[test]
fn only_the_populated_shape_is_serialised() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
write_lockfile_atomic(
&path,
&lock_with(vec![
entry("local/component-tool", "0.1.0", LockedSha256::any("aaa")),
entry("local/skill", "0.1.0", LockedSha256::any("bbb")),
]),
)
.unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert!(raw.contains("lock_version: 3"), "{raw}");
assert!(
!raw.contains("wasm"),
"a v1 sha256.wasm key survived: {raw}"
);
assert!(raw.contains("any: aaa"), "{raw}");
assert!(raw.contains("any: bbb"), "{raw}");
assert!(!raw.contains("platforms"), "{raw}");
assert_eq!(raw.matches("origin: operator").count(), 2, "{raw}");
assert!(!raw.contains("session:"), "{raw}");
}
#[test]
fn a_lock_without_origin_reads_as_all_operator() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
fs::write(
&path,
"lock_version: 3\nartifacts:\n - name: local/echo-tool\n resolved_version: 0.0.1\n sha256:\n any: abc\n - name: local/skill\n resolved_version: 0.1.0\n sha256:\n any: def\n",
)
.unwrap();
let lock = read_lockfile(&path).unwrap();
assert!(lock
.artifacts
.iter()
.all(|entry| entry.origin == LockOrigin::Operator));
write_lockfile_atomic(&path, &lock).unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert_eq!(raw.matches("origin: operator").count(), 2, "{raw}");
assert!(!raw.contains("session:"), "{raw}");
}
#[test]
fn a_runtime_upsert_keeps_the_existing_origin() {
let mut lock = lock_with(vec![
entry("local/operator-tool", "0.1.0", LockedSha256::any("aaa")),
LockedArtifact {
origin: runtime("ses_first"),
..entry("local/pulled-tool", "0.1.0", LockedSha256::any("bbb"))
},
]);
let previous = lock.upsert(
"local/operator-tool",
"0.1.0",
LockedSha256::any("aaa"),
runtime("ses_second"),
);
assert_eq!(previous, Some(LockOrigin::Operator));
let previous = lock.upsert(
"local/pulled-tool",
"0.2.0",
LockedSha256::any("ccc"),
runtime("ses_second"),
);
assert_eq!(previous, Some(runtime("ses_first")));
assert_eq!(
lock.artifact_for("local/operator-tool").unwrap().origin,
LockOrigin::Operator
);
let pulled = lock.artifact_for("local/pulled-tool").unwrap();
assert_eq!(pulled.origin, runtime("ses_first"));
assert_eq!(pulled.resolved_version, "0.2.0");
let created = lock.upsert(
"local/new-tool",
"0.1.0",
LockedSha256::any("ddd"),
runtime("ses_second"),
);
assert_eq!(created, None);
assert_eq!(
lock.artifact_for("local/new-tool").unwrap().origin,
runtime("ses_second")
);
}
#[test]
fn an_operator_upsert_adopts_a_runtime_entry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
let mut lock = lock_with(vec![LockedArtifact {
origin: runtime("ses_puller"),
..entry("local/pulled-tool", "0.1.0", LockedSha256::any("aaa"))
}]);
let previous = lock.upsert(
"local/pulled-tool",
"0.1.0",
LockedSha256::any("aaa"),
LockOrigin::Operator,
);
assert_eq!(previous, Some(runtime("ses_puller")));
assert_eq!(
lock.artifact_for("local/pulled-tool").unwrap().origin,
LockOrigin::Operator
);
write_lockfile_atomic(&path, &lock).unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert!(raw.contains("origin: operator"), "{raw}");
assert!(!raw.contains("session"), "{raw}");
}
#[test]
fn removing_a_lock_entry_leaves_every_other_entry_untouched() {
let first = entry("local/operator-tool", "0.1.0", LockedSha256::any("aaa"));
let middle = LockedArtifact {
origin: runtime("ses_puller"),
..entry("local/pulled-tool", "1.2.3", LockedSha256::any("bbb"))
};
let last = entry("local/skill", "0.2.0", LockedSha256::any("ccc"));
let mut lock = lock_with(vec![first.clone(), middle.clone(), last.clone()]);
assert_eq!(lock.remove("local/pulled-tool"), Some(middle));
assert_eq!(lock.artifacts, vec![first, last]);
assert_eq!(lock.remove("local/absent"), None);
assert_eq!(lock.artifacts.len(), 2);
lock.remove("local/operator-tool").unwrap();
lock.remove("local/skill").unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
write_lockfile_atomic(&path, &lock).unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert!(raw.contains("artifacts: []"), "{raw}");
assert_eq!(read_lockfile(&path).unwrap(), lock_with(Vec::new()));
}
#[test]
fn malformed_origin_fields_are_invalid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
let refusal = |origin_keys: &str| {
fs::write(
&path,
format!(
"lock_version: 3\nartifacts:\n - name: local/odd-tool\n resolved_version: 0.1.0\n sha256:\n any: abc\n{origin_keys}"
),
)
.unwrap();
match read_lockfile(&path).unwrap_err() {
LockfileError::Invalid(message) => message,
other => panic!("expected Invalid, got {other:?}"),
}
};
let message = refusal(" origin: runtime\n");
assert!(message.contains("'local/odd-tool'"), "{message}");
assert!(message.contains("session"), "{message}");
let message = refusal(" origin: runtime\n session: \" \"\n");
assert!(message.contains("'local/odd-tool'"), "{message}");
assert!(message.contains("session"), "{message}");
let message = refusal(" origin: operator\n session: ses_x\n");
assert!(message.contains("'local/odd-tool'"), "{message}");
assert!(message.contains("session"), "{message}");
let message = refusal(" origin: capsule\n");
assert!(message.contains("'local/odd-tool'"), "{message}");
assert!(message.contains("origin"), "{message}");
assert!(message.contains("capsule"), "{message}");
let blank_on_write = lock_with(vec![LockedArtifact {
origin: runtime(""),
..entry("local/odd-tool", "0.1.0", LockedSha256::any("abc"))
}]);
assert!(write_lockfile_atomic(&path, &blank_on_write).is_err());
}
#[test]
fn lockfile_version_two_is_refused_for_its_version_before_any_entry_is_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
fs::write(
&path,
"lock_version: 2\nartifacts:\n - name: echo-tool\n resolved_version: 0.0.1\n sha256:\n any: abc\n origin: capsule\n",
)
.unwrap();
let message = read_lockfile(&path).unwrap_err().to_string();
assert!(
message.contains(
"unsupported lock_version 2 (expected 3) \u{2014} delete murmur.lock and run \
`mur install` to regenerate it"
),
"{message}"
);
assert!(!message.contains("echo-tool"), "{message}");
}
#[test]
fn lockfile_v3_entry_with_a_bare_name_is_refused_naming_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
fs::write(
&path,
"lock_version: 3\nartifacts:\n - name: echo-tool\n resolved_version: 0.0.1\n sha256:\n any: abc\n",
)
.unwrap();
let message = read_lockfile(&path).unwrap_err().to_string();
assert!(message.contains("'echo-tool'"), "{message}");
assert!(
message.contains("delete murmur.lock and run `mur install` to regenerate it"),
"{message}"
);
}
#[test]
fn lockfile_owner_id_is_kept_when_present_and_omitted_when_absent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
fs::write(
&path,
"lock_version: 3\nartifacts:\n - name: ana/tool-x\n resolved_version: 1.0.0\n sha256:\n any: abc\n origin: operator\n owner_id: 12345\n",
)
.unwrap();
let mut lock = read_lockfile(&path).unwrap();
assert_eq!(
lock.artifact_for("ana/tool-x").unwrap().owner_id,
Some(12345)
);
lock.upsert(
"ana/tool-x",
"1.0.0",
LockedSha256::any("abc"),
LockOrigin::Operator,
);
lock.upsert(
"local/echo-tool",
"0.0.1",
LockedSha256::any("def"),
LockOrigin::Operator,
);
write_lockfile_atomic(&path, &lock).unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert_eq!(raw.matches("owner_id: 12345").count(), 1, "{raw}");
assert_eq!(raw.matches("owner_id").count(), 1, "{raw}");
assert_eq!(read_lockfile(&path).unwrap(), lock);
}
#[test]
fn lockfile_signer_is_kept_when_present_and_omitted_when_absent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
fs::write(
&path,
"lock_version: 3\nartifacts:\n - name: ana/tool-x\n resolved_version: 1.0.0\n sha256:\n any: abc\n origin: operator\n owner_id: 12345\n signer: https://github.com/ana/artifacts/.github/workflows/release.yml\n - name: ana/tool-y\n resolved_version: 1.0.0\n sha256:\n any: def\n",
)
.unwrap();
let mut lock = read_lockfile(&path).unwrap();
assert_eq!(LOCK_VERSION, 3);
assert_eq!(
lock.artifact_for("ana/tool-x").unwrap().signer.as_deref(),
Some("https://github.com/ana/artifacts/.github/workflows/release.yml")
);
assert_eq!(lock.artifact_for("ana/tool-y").unwrap().signer, None);
assert!(lock.set_signer(
"ana/tool-y",
Some("https://github.com/ana/artifacts/.github/workflows/other.yml".to_string())
));
assert!(!lock.set_signer("ana/absent", Some("x".to_string())));
write_lockfile_atomic(&path, &lock).unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert_eq!(raw.matches("signer:").count(), 2, "{raw}");
assert_eq!(read_lockfile(&path).unwrap(), lock);
assert!(lock.set_signer("ana/tool-x", None));
assert!(lock.set_signer("ana/tool-y", None));
write_lockfile_atomic(&path, &lock).unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert!(!raw.contains("signer"), "{raw}");
assert!(raw.starts_with("lock_version: 3\n"), "{raw}");
}
#[test]
fn an_empty_signer_is_refused() {
let mut lock = lock_with(vec![entry("ana/tool-x", "1.0.0", LockedSha256::any("abc"))]);
lock.set_signer("ana/tool-x", Some(" ".to_string()));
let error = lock.validate().unwrap_err().to_string();
assert!(
error.contains("artifact 'ana/tool-x' has an empty signer"),
"{error}"
);
}
#[test]
fn a_new_pin_drops_the_signer_of_the_bytes_it_replaced() {
let mut lock = lock_with(vec![entry("ana/tool-x", "1.0.0", LockedSha256::any("abc"))]);
lock.set_signer("ana/tool-x", Some("https://example/wf.yml".to_string()));
lock.upsert(
"ana/tool-x",
"1.0.0",
LockedSha256::any("abc"),
LockOrigin::Operator,
);
assert!(lock.artifact_for("ana/tool-x").unwrap().signer.is_some());
lock.upsert(
"ana/tool-x",
"1.1.0",
LockedSha256::any("def"),
LockOrigin::Operator,
);
assert_eq!(lock.artifact_for("ana/tool-x").unwrap().signer, None);
}
#[test]
fn a_version_one_lockfile_is_refused_with_the_fix() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
fs::write(
&path,
"lock_version: 1\nartifacts:\n - name: local/echo-tool\n resolved_version: 0.0.1\n sha256:\n wasm: abc\n",
)
.unwrap();
let err = read_lockfile(&path).unwrap_err();
let message = err.to_string();
assert!(message.contains("lock_version 1"), "{message}");
assert!(message.contains("expected 3"), "{message}");
assert!(message.contains("delete murmur.lock"), "{message}");
assert!(message.contains("mur install"), "{message}");
}
#[test]
fn an_entry_pinning_per_platform_hashes_is_refused_by_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
fs::write(
&path,
"lock_version: 3\nartifacts:\n - name: local/echo-tool\n resolved_version: 0.0.1\n sha256:\n any: abc\n - name: local/nat-tool\n resolved_version: 0.1.0\n sha256:\n platforms:\n linux-x86_64: def\n",
)
.unwrap();
let message = read_lockfile(&path).unwrap_err().to_string();
assert!(message.contains("'local/nat-tool'"), "{message}");
assert!(message.contains("sha256.platforms"), "{message}");
assert!(message.contains("mur install"), "{message}");
}
#[test]
fn an_entry_pinning_no_hash_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
fs::write(
&path,
"lock_version: 3\nartifacts:\n - name: local/tool\n resolved_version: 0.1.0\n sha256: {}\n",
)
.unwrap();
let message = read_lockfile(&path).unwrap_err().to_string();
assert!(message.contains("pins no hash"), "{message}");
}
#[test]
fn a_wasm_only_version_3_lock_round_trips_byte_for_byte() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("murmur.lock");
let written = "lock_version: 3\nartifacts:\n- name: local/echo-tool\n resolved_version: 0.0.1\n sha256:\n any: abc\n origin: operator\n";
fs::write(&path, written).unwrap();
let lock = read_lockfile(&path).unwrap();
write_lockfile_atomic(&path, &lock).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), written);
}
#[test]
fn an_empty_hash_is_rejected() {
let empty_any = lock_with(vec![entry("local/tool", "0.1.0", LockedSha256::any(" "))]);
assert!(empty_any
.validate()
.unwrap_err()
.to_string()
.contains("empty sha256.any"));
}
#[test]
fn a_new_pin_replaces_the_old_one() {
let mut lock = lock_with(vec![entry("local/tool", "0.1.0", LockedSha256::any("old"))]);
lock.upsert(
"local/tool",
"0.2.0",
LockedSha256::any("new"),
LockOrigin::Operator,
);
let pinned = lock.artifact_for("local/tool").unwrap();
assert_eq!(pinned.resolved_version, "0.2.0");
assert_eq!(pinned.sha256.any, "new");
}
#[test]
fn a_differing_version_or_hash_is_a_conflict() {
let pinned = entry("local/tool", "0.1.0", LockedSha256::any("hash"));
assert_eq!(
pinned.conflict_with("0.1.0", &LockedSha256::any("hash")),
None
);
let new_hash = pinned
.conflict_with("0.1.0", &LockedSha256::any("other"))
.unwrap();
assert!(new_hash.contains("other"), "{new_hash}");
let new_version = pinned
.conflict_with("0.2.0", &LockedSha256::any("hash"))
.unwrap();
assert!(new_version.contains("0.2.0"), "{new_version}");
}
#[test]
fn a_same_version_conflict_ignores_a_new_version() {
let pinned = entry("local/tool", "1.0.0", LockedSha256::any("wasm-hash"));
assert_eq!(
pinned.same_version_conflict_with("1.1.0", &LockedSha256::any("other-hash")),
None
);
assert_eq!(
pinned.same_version_conflict_with("1.0.0", &LockedSha256::any("wasm-hash")),
None
);
let new_bytes = pinned
.same_version_conflict_with("1.0.0", &LockedSha256::any("other-hash"))
.unwrap();
assert_eq!(
new_bytes,
"'local/tool' is pinned at sha256 wasm-hash, but other-hash was resolved"
);
assert_eq!(
pinned.conflict_with("1.0.0", &LockedSha256::any("other-hash")),
Some(new_bytes)
);
}
fn owned_lock() -> MurmurLock {
let mut lock = MurmurLock {
lock_version: LOCK_VERSION,
artifacts: Vec::new(),
};
for name in [
"ana/tool-git",
"ana/tool-git-validate",
"anabel/tool-x",
"bob/tool-y",
] {
lock.upsert(
name,
"1.0.0",
LockedSha256::any("abc"),
LockOrigin::Operator,
);
}
lock
}
#[test]
fn set_owner_id_records_the_id_on_that_entry_alone() {
let mut lock = owned_lock();
assert!(lock.set_owner_id("ana/tool-git", 1111));
assert!(!lock.set_owner_id("ana/absent", 1111));
assert_eq!(
lock.artifact_for("ana/tool-git").unwrap().owner_id,
Some(1111)
);
assert_eq!(
lock.artifact_for("ana/tool-git-validate").unwrap().owner_id,
None
);
}
#[test]
fn owner_conflict_names_an_entry_of_the_scope_recording_another_id() {
let mut lock = owned_lock();
assert!(
lock.owner_conflict("ana", 2222).is_none(),
"no id recorded yet"
);
lock.set_owner_id("ana/tool-git-validate", 1111);
lock.set_owner_id("anabel/tool-x", 2222);
lock.set_owner_id("bob/tool-y", 3333);
assert!(lock.owner_conflict("ana", 1111).is_none());
let conflict = lock.owner_conflict("ana", 2222).unwrap();
assert_eq!(conflict.name, "ana/tool-git-validate");
assert_eq!(conflict.owner_id, Some(1111));
assert!(lock.owner_conflict("anabel", 2222).is_none());
assert_eq!(lock.owner_conflict("bob", 1111).unwrap().name, "bob/tool-y");
}
}