use std::collections::BTreeMap;
use crate::ghcr;
pub const FORMAT_VERSION: u32 = 1;
const MANIFEST_ASSET_NAME: &str = "release-images.yaml";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ManifestImage {
pub name: String,
pub image: String,
pub digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ReleaseManifest {
pub format_version: u32,
pub version: String,
pub images: Vec<ManifestImage>,
}
impl ReleaseManifest {
#[must_use]
pub fn digest_for(&self, name: &str) -> Option<&str> {
self.images
.iter()
.find(|i| i.name == name)
.map(|i| i.digest.as_str())
}
}
#[derive(Debug, thiserror::Error)]
pub enum ReleaseManifestError {
#[error(
"this polychrome binary reads release-manifest format {supported}; release {version} \
publishes format {found} — update the binary, then retry"
)]
UnknownFormatVersion {
found: u32,
supported: u32,
version: String,
},
#[error("release manifest version `{found}` does not match the requested release `{expected}`")]
VersionMismatch {
expected: String,
found: String,
},
#[error("component `{name}` names image `{image}`, not `ghcr.io/officialunofficial/{name}`")]
MalformedImage {
name: String,
image: String,
},
#[error("component `{name}` carries digest `{digest}`, not a sha256:<64-hex> content address")]
MalformedDigest {
name: String,
digest: String,
},
#[error("release manifest is missing component `{0}` — refusing to resolve a partial set")]
MissingComponent(String),
#[error("release `{0}` predates the digest manifest — pick a newer release")]
AssetMissing(String),
#[error("release manifest is not valid YAML: {0}")]
Yaml(#[from] serde_yaml_ng::Error),
#[error("release-manifest request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("live registry resolve failed: {0}")]
Ghcr(#[from] ghcr::GhcrError),
}
fn is_valid_digest(digest: &str) -> bool {
digest.strip_prefix("sha256:").is_some_and(|hex| {
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
}
fn is_valid_component_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
pub fn parse_and_validate(
bytes: &[u8],
expected_version: &str,
) -> Result<ReleaseManifest, ReleaseManifestError> {
let manifest: ReleaseManifest = serde_yaml_ng::from_slice(bytes)?;
if manifest.format_version != FORMAT_VERSION {
return Err(ReleaseManifestError::UnknownFormatVersion {
found: manifest.format_version,
supported: FORMAT_VERSION,
version: expected_version.to_owned(),
});
}
if manifest.version != expected_version {
return Err(ReleaseManifestError::VersionMismatch {
expected: expected_version.to_owned(),
found: manifest.version,
});
}
for entry in &manifest.images {
let expected_image = format!("ghcr.io/officialunofficial/{}", entry.name);
if !is_valid_component_name(&entry.name) || entry.image != expected_image {
return Err(ReleaseManifestError::MalformedImage {
name: entry.name.clone(),
image: entry.image.clone(),
});
}
if !is_valid_digest(&entry.digest) {
return Err(ReleaseManifestError::MalformedDigest {
name: entry.name.clone(),
digest: entry.digest.clone(),
});
}
}
Ok(manifest)
}
pub async fn fetch(
client: &reqwest::Client,
base_url: &str,
owner: &str,
repo: &str,
release_tag: &str,
expected_version: &str,
) -> Result<ReleaseManifest, ReleaseManifestError> {
let url = format!("{base_url}/repos/{owner}/{repo}/releases/tags/{release_tag}");
let user_agent = concat!("polychrome/", env!("CARGO_PKG_VERSION"));
let release: serde_json::Value = client
.get(&url)
.header(reqwest::header::USER_AGENT, user_agent)
.send()
.await?
.error_for_status()?
.json()
.await?;
let asset_url = release["assets"]
.as_array()
.into_iter()
.flatten()
.find(|asset| asset["name"].as_str() == Some(MANIFEST_ASSET_NAME))
.and_then(|asset| asset["url"].as_str())
.map(str::to_owned)
.ok_or_else(|| ReleaseManifestError::AssetMissing(release_tag.to_owned()))?;
let bytes = client
.get(&asset_url)
.header(reqwest::header::USER_AGENT, user_agent)
.header(reqwest::header::ACCEPT, "application/octet-stream")
.send()
.await?
.error_for_status()?
.bytes()
.await?;
parse_and_validate(&bytes, expected_version)
}
pub async fn resolve_release_digests<F>(
client: &reqwest::Client,
base_url: &str,
owner: &str,
repo: &str,
version: &str,
env_lookup: F,
) -> Result<BTreeMap<&'static str, String>, ReleaseManifestError>
where
F: Fn(&str) -> Option<String> + Copy,
{
let image_tag = ghcr::image_tag_for_version(version)?;
let release_tag = format!("v{image_tag}");
let manifest_needed = ghcr::COMPONENTS
.iter()
.any(|c| env_lookup(c.image_env).is_none());
let manifest = if manifest_needed {
Some(fetch(client, base_url, owner, repo, &release_tag, &image_tag).await?)
} else {
None
};
let mut digests = BTreeMap::new();
for component in &ghcr::COMPONENTS {
let digest = if env_lookup(component.image_env).is_some() {
let image = ghcr::image_ref(component, env_lookup);
ghcr::resolve_digest(client, &image.registry, &image.repository, &image_tag).await?
} else {
manifest
.as_ref()
.and_then(|m| m.digest_for(component.ghcr_basename()))
.ok_or_else(|| {
ReleaseManifestError::MissingComponent(component.ghcr_basename().to_owned())
})?
.to_owned()
};
digests.insert(component.name, digest);
}
Ok(digests)
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery)]
use super::*;
fn literal_yaml(digest: &str) -> String {
format!(
"format_version: 1\n\
version: 2026.8.0\n\
images:\n\
\x20\x20- name: polychrome\n\
\x20\x20\x20\x20image: ghcr.io/officialunofficial/polychrome\n\
\x20\x20\x20\x20digest: {digest}\n"
)
}
fn digest64(byte: char) -> String {
format!("sha256:{}", byte.to_string().repeat(64))
}
fn sample_manifest() -> ReleaseManifest {
ReleaseManifest {
format_version: FORMAT_VERSION,
version: "2026.8.0".to_owned(),
images: vec![
ManifestImage {
name: "polychrome".to_owned(),
image: "ghcr.io/officialunofficial/polychrome".to_owned(),
digest: digest64('1'),
},
ManifestImage {
name: "polychrome-slack".to_owned(),
image: "ghcr.io/officialunofficial/polychrome-slack".to_owned(),
digest: digest64('2'),
},
],
}
}
fn to_yaml(manifest: &ReleaseManifest) -> String {
serde_yaml_ng::to_string(manifest).unwrap()
}
#[test]
fn valid_manifest_parses() {
let manifest = sample_manifest();
let yaml = to_yaml(&manifest);
let parsed = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect("valid manifest");
assert_eq!(parsed, manifest);
assert_eq!(
parsed.digest_for("polychrome"),
Some(digest64('1')).as_deref()
);
assert_eq!(parsed.digest_for("nope"), None);
}
#[test]
fn unknown_format_version_is_refused() {
let mut manifest = sample_manifest();
manifest.format_version = 2;
let yaml = to_yaml(&manifest);
let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must refuse");
let msg = err.to_string();
assert!(msg.contains("format 1"), "{msg}");
assert!(msg.contains("format 2"), "{msg}");
assert!(matches!(
err,
ReleaseManifestError::UnknownFormatVersion {
found: 2,
supported: 1,
..
}
));
}
#[test]
fn version_mismatch_is_rejected() {
let manifest = sample_manifest();
let yaml = to_yaml(&manifest);
let err = parse_and_validate(yaml.as_bytes(), "2026.9.0").expect_err("must reject");
assert!(matches!(err, ReleaseManifestError::VersionMismatch { .. }));
}
#[test]
fn wrong_repo_owner_is_malformed() {
let mut manifest = sample_manifest();
manifest.images[0].image = "ghcr.io/evil/polychrome".to_owned();
let yaml = to_yaml(&manifest);
let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must reject");
assert!(matches!(err, ReleaseManifestError::MalformedImage { .. }));
}
#[test]
fn short_digest_is_malformed() {
let mut manifest = sample_manifest();
manifest.images[0].digest = "sha256:abc".to_owned();
let yaml = to_yaml(&manifest);
let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must reject");
assert!(matches!(err, ReleaseManifestError::MalformedDigest { .. }));
}
#[test]
fn uppercase_digest_is_malformed() {
let mut manifest = sample_manifest();
manifest.images[0].digest = format!("sha256:{}", "A".repeat(64));
let yaml = to_yaml(&manifest);
let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must reject");
assert!(matches!(err, ReleaseManifestError::MalformedDigest { .. }));
}
#[test]
fn non_sha256_digest_is_malformed() {
let mut manifest = sample_manifest();
manifest.images[0].digest = format!("sha512:{}", "1".repeat(64));
let yaml = to_yaml(&manifest);
let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must reject");
assert!(matches!(err, ReleaseManifestError::MalformedDigest { .. }));
}
#[test]
fn missing_component_is_named() {
let manifest = sample_manifest();
assert_eq!(manifest.digest_for("polychrome-telegram"), None);
}
#[test]
fn literal_manifest_yaml_parses() {
let digest = digest64('1');
let parsed =
parse_and_validate(literal_yaml(&digest).as_bytes(), "2026.8.0").expect("valid");
assert_eq!(parsed.format_version, 1);
assert_eq!(parsed.digest_for("polychrome"), Some(digest.as_str()));
}
}