use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
use crate::paths::service_home;
pub const MANIFEST_VERSION: u32 = 1;
pub const MANIFEST_FILENAME: &str = "service.manifest";
#[derive(Debug, Clone)]
pub struct ManifestEntry {
pub path: PathBuf,
pub sha256: String,
}
#[derive(Debug, Clone)]
pub struct EnvEntry {
pub key: String,
pub value: String,
}
pub fn hash_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
pub fn hash_file(path: &Path) -> Result<String> {
let bytes = std::fs::read(path).map_err(|source| Error::FileRead {
path: path.to_path_buf(),
source,
})?;
Ok(hash_bytes(&bytes))
}
pub fn manifest_path(service_name: &str) -> Result<PathBuf> {
Ok(service_home(service_name)?.join(MANIFEST_FILENAME))
}
pub fn format(entries: &[ManifestEntry], envs: &[EnvEntry]) -> String {
let mut sorted: Vec<&ManifestEntry> = entries.iter().collect();
sorted.sort_by(|a, b| a.path.cmp(&b.path));
let mut sorted_envs: Vec<&EnvEntry> = envs.iter().collect();
sorted_envs.sort_by(|a, b| a.key.cmp(&b.key));
let mut out = String::new();
out.push_str("# ryra service.manifest — render manifest\n");
out.push_str(&format!("# ryra-manifest-version: {MANIFEST_VERSION}\n"));
out.push_str("# Generated by ryra. Verify with `sha256sum -c`.\n");
out.push('\n');
for entry in sorted {
out.push_str(&entry.sha256);
out.push_str(" ");
out.push_str(&entry.path.to_string_lossy());
out.push('\n');
}
if !sorted_envs.is_empty() {
out.push('\n');
out.push_str("# env: static env vars the registry expects in .env\n");
for env in sorted_envs {
out.push_str(&format!("# env: {}={}\n", env.key, env.value));
}
}
out
}
pub fn parse(content: &str) -> Result<(Vec<ManifestEntry>, Vec<EnvEntry>)> {
let mut entries = Vec::new();
let mut envs = Vec::new();
for (lineno, raw) in content.lines().enumerate() {
let line = raw.trim_start();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("# env: ") {
if let Some((key, value)) = rest.split_once('=') {
envs.push(EnvEntry {
key: key.trim().to_string(),
value: value.to_string(),
});
}
continue;
}
if line.starts_with('#') {
continue;
}
let (hash, path) = line.split_once(" ").ok_or_else(|| {
Error::Template(format!(
"service.manifest line {} is malformed: {raw}",
lineno + 1
))
})?;
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(Error::Template(format!(
"service.manifest line {} has non-sha256 hash: {hash}",
lineno + 1
)));
}
entries.push(ManifestEntry {
path: PathBuf::from(path),
sha256: hash.to_string(),
});
}
Ok((entries, envs))
}
pub fn load(service_name: &str) -> Result<Option<(Vec<ManifestEntry>, Vec<EnvEntry>)>> {
let path = manifest_path(service_name)?;
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
path: path.clone(),
source,
})?;
Ok(Some(parse(&content)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_round_trips_through_parse() {
let entries = vec![
ManifestEntry {
path: PathBuf::from("/a/b/c.container"),
sha256: "a".repeat(64),
},
ManifestEntry {
path: PathBuf::from("/a/b/scripts/x.sh"),
sha256: "b".repeat(64),
},
];
let rendered = format(&entries, &[]);
let (parsed, envs) = parse(&rendered).expect("round-trip parse must succeed");
assert_eq!(parsed.len(), 2);
assert!(envs.is_empty());
assert_eq!(parsed[0].path, PathBuf::from("/a/b/c.container"));
assert_eq!(parsed[0].sha256, "a".repeat(64));
assert_eq!(parsed[1].path, PathBuf::from("/a/b/scripts/x.sh"));
assert_eq!(parsed[1].sha256, "b".repeat(64));
}
#[test]
fn parse_ignores_comments_and_blank_lines() {
let input = "# header\n\n# another comment\n\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa /x\n";
let (parsed, envs) = parse(input).expect("parse must succeed");
assert_eq!(parsed.len(), 1);
assert!(envs.is_empty());
assert_eq!(parsed[0].path, PathBuf::from("/x"));
}
#[test]
fn parse_rejects_non_hex_hash() {
let input = "zzzz /x\n";
let err = parse(input).expect_err("non-hex hash must be rejected");
let msg = err.to_string();
assert!(
msg.contains("non-sha256") || msg.contains("malformed"),
"{msg}"
);
}
#[test]
fn parse_rejects_short_hash() {
let input = "abc /x\n";
let err = parse(input).expect_err("short hash must be rejected");
assert!(err.to_string().contains("non-sha256"), "{err}");
}
#[test]
fn hash_bytes_is_deterministic() {
let h1 = hash_bytes(b"hello");
let h2 = hash_bytes(b"hello");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
}
#[test]
fn format_sorts_entries_by_path() {
let entries = vec![
ManifestEntry {
path: PathBuf::from("/z"),
sha256: "0".repeat(64),
},
ManifestEntry {
path: PathBuf::from("/a"),
sha256: "1".repeat(64),
},
ManifestEntry {
path: PathBuf::from("/m"),
sha256: "2".repeat(64),
},
];
let rendered = format(&entries, &[]);
let positions: Vec<usize> = ["/a", "/m", "/z"]
.iter()
.map(|p| rendered.find(p).expect("path must appear"))
.collect();
assert!(positions[0] < positions[1] && positions[1] < positions[2]);
}
#[test]
fn env_round_trips_through_format_and_parse() {
let entries = vec![ManifestEntry {
path: PathBuf::from("/svc/file"),
sha256: "0".repeat(64),
}];
let envs = vec![
EnvEntry {
key: "FOO".into(),
value: "bar".into(),
},
EnvEntry {
key: "BAR".into(),
value: "baz=qux".into(), },
];
let rendered = format(&entries, &envs);
let (parsed_files, parsed_envs) = parse(&rendered).expect("round-trip");
assert_eq!(parsed_files.len(), 1);
assert_eq!(parsed_envs.len(), 2);
assert_eq!(parsed_envs[0].key, "BAR");
assert_eq!(parsed_envs[0].value, "baz=qux");
assert_eq!(parsed_envs[1].key, "FOO");
assert_eq!(parsed_envs[1].value, "bar");
}
#[test]
fn parse_skips_env_section_header_lines() {
let input = "# env: static env vars the registry expects in .env\n\
# env: KEY=val\n";
let (files, envs) = parse(input).expect("parse should succeed");
assert!(files.is_empty());
assert_eq!(envs.len(), 1);
assert_eq!(envs[0].key, "KEY");
assert_eq!(envs[0].value, "val");
}
}