use sha2::Digest as _;
use crate::artifact::ArtifactKey;
macro_rules! ghcr_repository {
() => {
"water-rs/stow-cache"
};
}
pub const GHCR_REPOSITORY: &str = ghcr_repository!();
pub const GHCR_BASE: &str = concat!("ghcr.io/", ghcr_repository!());
pub const GHCR_V2_BASE_URL: &str = concat!("https://ghcr.io/v2/", ghcr_repository!());
pub const BUNDLE_TAG_SUFFIX: &str = ".bundle";
pub const MAX_OCI_TAG_LEN: usize = 128;
fn is_oci_tag(tag: &str) -> bool {
let mut chars = tag.chars();
let Some(first) = chars.next() else {
return false;
};
tag.len() <= MAX_OCI_TAG_LEN
&& (first.is_ascii_alphanumeric() || first == '_')
&& chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
}
#[must_use]
pub fn oci_reference_tag(reference: &str) -> Option<&str> {
let tag = reference.strip_prefix(GHCR_BASE)?.strip_prefix(':')?;
is_oci_tag(tag).then_some(tag)
}
#[must_use]
pub fn oci_reference_name(reference: &str) -> Option<&str> {
let tag = oci_reference_tag(reference)?;
let (name, rest) = tag.split_once('.')?;
(!name.is_empty() && !rest.is_empty()).then_some(name)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RepositoryPath<'a>(&'a str);
impl<'a> RepositoryPath<'a> {
#[must_use]
pub const fn as_str(&self) -> &'a str {
self.0
}
}
impl std::fmt::Display for RepositoryPath<'_> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.0)
}
}
#[must_use]
pub fn repository_path(reference: &str) -> Option<RepositoryPath<'_>> {
let without_scheme = reference
.split_once("://")
.map_or(reference, |(_, rest)| rest);
let path = match without_scheme.split_once('@') {
Some((head, _digest)) => head,
None => without_scheme
.rsplit_once(':')
.map_or(without_scheme, |(head, _tag)| head),
};
let (_host, repository) = path.split_once('/')?;
(!repository.is_empty()).then_some(RepositoryPath(repository))
}
#[must_use]
pub fn sha256_digest(bytes: &[u8]) -> String {
format!("sha256:{}", hex::encode(sha2::Sha256::digest(bytes)))
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("OCI digest mismatch: expected {expected}, content hashes to {actual}")]
pub struct OciDigestMismatch {
pub expected: String,
pub actual: String,
}
pub fn verify_oci_digest(bytes: &[u8], expected: &str) -> Result<(), OciDigestMismatch> {
let actual = sha256_digest(bytes);
if actual != expected {
return Err(OciDigestMismatch {
expected: expected.to_owned(),
actual,
});
}
Ok(())
}
#[must_use]
pub fn oci_reference(key: &ArtifactKey, c_metadata: &str) -> String {
let name = crate_tag_segment(&key.crate_id.name);
let version = sanitize_oci_tag_component(&key.crate_id.version.to_string());
let target_short = key.target.short();
let rustc_short = key.rustc_version.short();
let feat_hash = key.features.short_hash();
let kind_suffix = match key.kind {
crate::artifact::ArtifactKind::Rlib => "",
crate::artifact::ArtifactKind::Dylib => "-dy",
crate::artifact::ArtifactKind::ProcMacro => "-pm",
};
let tail = format!("-{target_short}-{rustc_short}-{feat_hash}-{c_metadata}{kind_suffix}");
let mut head = format!("{name}.{version}");
head.truncate(MAX_OCI_TAG_LEN.saturating_sub(tail.len() + BUNDLE_TAG_SUFFIX.len()));
format!("{GHCR_BASE}:{head}{tail}")
}
#[must_use]
pub fn bundle_oci_reference(reference: &str) -> Option<String> {
let tag = oci_reference_tag(reference)?;
let bundle_tag = format!("{tag}{BUNDLE_TAG_SUFFIX}");
is_oci_tag(&bundle_tag).then(|| format!("{GHCR_BASE}:{bundle_tag}"))
}
fn crate_tag_segment(name: &str) -> String {
name.to_ascii_lowercase()
}
pub(crate) fn sanitize_oci_tag_component(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
ch
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
use crate::artifact::{ArtifactKey, ArtifactKind, RustCrateType};
use crate::crate_info::{CrateId, FeatureSet};
use crate::platform::{PanicStrategy, Profile, RustcVersion, Target};
#[test]
fn oci_reference_format() {
let key = ArtifactKey {
crate_id: CrateId {
name: "serde".into(),
version: semver::Version::new(1, 0, 210),
},
features: FeatureSet(BTreeSet::from(["derive".into()])),
crate_types: vec![RustCrateType::Rlib],
target: Target("x86_64-unknown-linux-gnu".into()),
rustc_version: RustcVersion {
version: semver::Version::new(1, 83, 0),
commit_hash: "90b35a623".into(),
llvm_version: "19.1.4".into(),
},
profile: Profile {
opt_level: "0".into(),
debuginfo: 2,
debug_assertions: true,
overflow_checks: true,
panic: PanicStrategy::Unwind,
strip: crate::platform::StripLevel::None,
},
kind: ArtifactKind::Rlib,
};
let reference = oci_reference(&key, "abcdef0123456789");
assert!(reference.starts_with("ghcr.io/water-rs/stow-cache:serde."));
assert!(reference.contains("1.0.210"));
assert!(reference.contains("x86_64-linux"));
assert!(reference.contains("1.83.0"));
assert!(reference.contains("abcdef0123456789"));
assert_eq!(oci_reference_name(&reference), Some("serde"));
assert!(!reference.ends_with("-pm"));
}
#[test]
fn oci_reference_name_splits_at_first_dot() {
let key = ArtifactKey {
crate_id: CrateId {
name: "sha-1".into(),
version: semver::Version::new(0, 10, 0),
},
features: FeatureSet::new(),
crate_types: vec![RustCrateType::Rlib],
target: Target("x86_64-unknown-linux-gnu".into()),
rustc_version: RustcVersion {
version: semver::Version::new(1, 83, 0),
commit_hash: "90b35a623".into(),
llvm_version: "19.1.4".into(),
},
profile: Profile {
opt_level: "0".into(),
debuginfo: 2,
debug_assertions: true,
overflow_checks: true,
panic: PanicStrategy::Unwind,
strip: crate::platform::StripLevel::None,
},
kind: ArtifactKind::Rlib,
};
let reference = oci_reference(&key, "abcdef0123456789");
assert!(reference.starts_with("ghcr.io/water-rs/stow-cache:sha-1.0.10.0-"));
assert_eq!(oci_reference_name(&reference), Some("sha-1"));
}
#[test]
fn proc_macro_has_pm_suffix() {
let key = ArtifactKey {
crate_id: CrateId {
name: "serde_derive".into(),
version: semver::Version::new(1, 0, 210),
},
features: FeatureSet::new(),
crate_types: vec![RustCrateType::ProcMacro],
target: Target("x86_64-unknown-linux-gnu".into()),
rustc_version: RustcVersion {
version: semver::Version::new(1, 83, 0),
commit_hash: "90b35a623".into(),
llvm_version: "19.1.4".into(),
},
profile: Profile {
opt_level: "3".into(),
debuginfo: 0,
debug_assertions: false,
overflow_checks: false,
panic: PanicStrategy::Unwind,
strip: crate::platform::StripLevel::None,
},
kind: ArtifactKind::ProcMacro,
};
let reference = oci_reference(&key, "abcdef0123456789");
assert!(reference.ends_with("-pm"));
}
#[test]
fn oci_tag_within_128_chars() {
let key = ArtifactKey {
crate_id: CrateId {
name: "some-really-long-crate-name-that-exists".into(),
version: semver::Version::new(99, 99, 99),
},
features: FeatureSet(BTreeSet::from([
"feature1".into(),
"feature2".into(),
"feature3".into(),
])),
crate_types: vec![RustCrateType::Rlib],
target: Target("x86_64-unknown-linux-gnu".into()),
rustc_version: RustcVersion {
version: semver::Version::new(1, 83, 0),
commit_hash: "90b35a623".into(),
llvm_version: "19.1.4".into(),
},
profile: Profile {
opt_level: "0".into(),
debuginfo: 2,
debug_assertions: true,
overflow_checks: true,
panic: PanicStrategy::Unwind,
strip: crate::platform::StripLevel::None,
},
kind: ArtifactKind::Rlib,
};
let reference = oci_reference(&key, "abcdef0123456789");
let tag = reference.rsplit_once(':').unwrap().1;
assert!(
tag.len() <= 128,
"OCI tag too long: {} chars ({})",
tag.len(),
tag
);
}
#[test]
fn a_long_name_and_prerelease_truncate_the_head_not_the_identity() {
let key = ArtifactKey {
crate_id: CrateId {
name: "x".repeat(128),
version: semver::Version::parse("1.0.0-alpha.20260918.build-candidate.7")
.expect("prerelease version"),
},
features: FeatureSet(BTreeSet::from(["derive".into()])),
crate_types: vec![RustCrateType::Rlib],
target: Target("x86_64-pc-windows-msvc".into()),
rustc_version: RustcVersion {
version: semver::Version::parse("1.93.0-beta.5").expect("beta version"),
commit_hash: "90b35a623".into(),
llvm_version: "19.1.4".into(),
},
profile: Profile {
opt_level: "0".into(),
debuginfo: 2,
debug_assertions: true,
overflow_checks: true,
panic: PanicStrategy::Unwind,
strip: crate::platform::StripLevel::None,
},
kind: ArtifactKind::ProcMacro,
};
let reference = oci_reference(&key, "fedcba9876543210");
let tag = oci_reference_tag(&reference).expect("a legal, canonical tag");
assert_eq!(tag.len(), MAX_OCI_TAG_LEN - BUNDLE_TAG_SUFFIX.len());
assert!(tag.ends_with("-fedcba9876543210-pm"), "{tag}");
assert!(tag.starts_with("xxxx"), "{tag}");
let bundle = bundle_oci_reference(&reference).expect("bundle tag fits");
let bundle_tag = bundle
.rsplit_once(':')
.map(|(_, tag)| tag)
.expect("bundle reference has a tag");
assert_eq!(bundle_tag.len(), MAX_OCI_TAG_LEN);
assert_eq!(bundle_tag, format!("{tag}{BUNDLE_TAG_SUFFIX}"));
}
#[test]
fn illegal_tags_are_not_canonical_references() {
let over_long = format!("{GHCR_BASE}:s.{}", "1".repeat(MAX_OCI_TAG_LEN));
for reference in [
"ghcr.io/water-rs/stow-cache:serde.1.0.0:extra",
"ghcr.io/water-rs/stow-cache:sha256@abc",
"ghcr.io/water-rs/stow-cache:serde.1.0.0/../../evil",
"ghcr.io/water-rs/stow-cache:.serde.1.0.0",
over_long.as_str(),
] {
assert_eq!(oci_reference_tag(reference), None, "{reference}");
assert_eq!(oci_reference_name(reference), None, "{reference}");
}
}
#[test]
fn oci_crate_segment_is_lowercased() {
let key = ArtifactKey {
crate_id: CrateId {
name: "Inflector".into(),
version: semver::Version::new(0, 11, 4),
},
features: FeatureSet::new(),
crate_types: vec![RustCrateType::Rlib],
target: Target("x86_64-unknown-linux-gnu".into()),
rustc_version: RustcVersion {
version: semver::Version::new(1, 83, 0),
commit_hash: "90b35a623".into(),
llvm_version: "19.1.4".into(),
},
profile: Profile {
opt_level: "0".into(),
debuginfo: 2,
debug_assertions: true,
overflow_checks: true,
panic: PanicStrategy::Unwind,
strip: crate::platform::StripLevel::None,
},
kind: ArtifactKind::Rlib,
};
let reference = oci_reference(&key, "abcdef0123456789");
let name = oci_reference_name(&reference).expect("canonical reference shape");
assert_eq!(name, "inflector");
assert!(
!oci_reference_tag(&reference)
.expect("canonical reference shape")
.split('.')
.next()
.expect("tag is non-empty")
.chars()
.any(char::is_uppercase)
);
}
#[test]
fn oci_reference_tag_yields_the_tag() {
assert_eq!(
oci_reference_tag(
"ghcr.io/water-rs/stow-cache:serde.1.0.0-x86_64-linux-1.91.1-abcdef012345-0123"
),
Some("serde.1.0.0-x86_64-linux-1.91.1-abcdef012345-0123")
);
}
#[test]
fn canonical_parsers_reject_non_canonical_references() {
for reference in [
"ghcr.io/water-rs/stow-cache/serde:1.0.0",
"ghcr.io/water-rs/other:serde.1.0.0",
"ghcr.io/water-rs/stow-cache:",
"ghcr.io/water-rs/stow-cache",
"",
] {
assert_eq!(
oci_reference_tag(reference),
None,
"reference should fail: {reference}"
);
assert_eq!(
oci_reference_name(reference),
None,
"reference should fail: {reference}"
);
}
for reference in [
"ghcr.io/water-rs/stow-cache:.1.0.0",
"ghcr.io/water-rs/stow-cache:serde",
"ghcr.io/water-rs/stow-cache:serde.",
] {
assert_eq!(
oci_reference_name(reference),
None,
"reference should fail: {reference}"
);
}
}
#[test]
fn content_hashing_to_the_digest_verifies() {
let bytes =
br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}"#;
let digest = sha256_digest(bytes);
assert!(digest.starts_with("sha256:"));
assert_eq!(digest.len(), "sha256:".len() + 64);
verify_oci_digest(bytes, &digest).expect("content hashes to its digest");
}
#[test]
fn content_hashing_to_a_different_digest_is_rejected() {
let registered = sha256_digest(b"the manifest the row was registered for");
let error = verify_oci_digest(b"repushed manifest bytes", ®istered)
.expect_err("content not hashing to the expected digest must fail");
assert_eq!(error.expected, registered);
assert_eq!(error.actual, sha256_digest(b"repushed manifest bytes"));
}
#[test]
fn repository_path_from_tag_reference() {
let path = repository_path(
"ghcr.io/water-rs/stow-cache:serde.1.0.0-x86_64-linux-1.91.1-abcdef012345-0123",
)
.expect("canonical reference");
assert_eq!(path.as_str(), "water-rs/stow-cache");
assert_eq!(path.to_string(), "water-rs/stow-cache");
}
#[test]
fn repository_path_from_digest_reference() {
let path = repository_path("ghcr.io/water-rs/stow-cache@sha256:deadbeef")
.expect("digest reference");
assert_eq!(path.as_str(), "water-rs/stow-cache");
}
#[test]
fn repository_path_handles_scheme_and_single_segment_repo() {
let path =
repository_path("https://registry.local/serde:tag").expect("single-segment repo");
assert_eq!(path.as_str(), "serde");
}
#[test]
fn repository_path_rejects_non_reference() {
for reference in ["ghcr.io", "ghcr.io/", "serde", "serde:tag", ""] {
assert_eq!(
repository_path(reference),
None,
"reference should fail: {reference}"
);
}
}
#[test]
fn oci_tag_sanitizes_build_metadata() {
let key = ArtifactKey {
crate_id: CrateId {
name: "libgit2-sys".into(),
version: semver::Version::parse("0.17.0+1.8.1").expect("valid semver"),
},
features: FeatureSet::new(),
crate_types: vec![RustCrateType::Lib],
target: Target("aarch64-apple-darwin".into()),
rustc_version: RustcVersion {
version: semver::Version::new(1, 91, 1),
commit_hash: "ed61e7d7e".into(),
llvm_version: "21.0.0".into(),
},
profile: Profile {
opt_level: "0".into(),
debuginfo: 2,
debug_assertions: true,
overflow_checks: true,
panic: PanicStrategy::Unwind,
strip: crate::platform::StripLevel::None,
},
kind: ArtifactKind::Rlib,
};
let reference = oci_reference(&key, "d44626168446442d");
let tag = reference.rsplit_once(':').expect("tag separator").1;
assert!(tag.contains("0.17.0_1.8.1"));
assert!(!tag.contains('+'));
}
}