Skip to main content

stow_types/
registry.rs

1//! GHCR OCI reference construction and parsing for stow artifacts.
2
3use sha2::Digest as _;
4
5use crate::artifact::ArtifactKey;
6
7/// The one literal every GHCR path derives from, so the repository can only
8/// ever be spelled once. `concat!` needs a literal, hence the macro.
9macro_rules! ghcr_repository {
10    () => {
11        "water-rs/stow-cache"
12    };
13}
14
15/// The single GHCR repository every stow artifact is a tag of:
16/// `ghcr.io/water-rs/stow-cache`.
17///
18/// GHCR creates every package private and offers no API to change
19/// visibility, so the whole cache shares one package whose visibility is
20/// flipped once.
21pub const GHCR_REPOSITORY: &str = ghcr_repository!();
22/// Base path for OCI references: `ghcr.io/water-rs/stow-cache`.
23pub const GHCR_BASE: &str = concat!("ghcr.io/", ghcr_repository!());
24/// Registry API base the edge fetches blobs and manifests from.
25pub const GHCR_V2_BASE_URL: &str = concat!("https://ghcr.io/v2/", ghcr_repository!());
26
27/// Tag suffix of the assembled bundle artifact published next to every
28/// signed artifact: `ghcr.io/water-rs/stow-cache:<tag>.bundle` carries the
29/// bundle tar as its single layer.
30pub const BUNDLE_TAG_SUFFIX: &str = ".bundle";
31
32/// The OCI distribution spec's tag limit, which GHCR enforces:
33/// `[A-Za-z0-9_][A-Za-z0-9._-]{0,127}`.
34pub const MAX_OCI_TAG_LEN: usize = 128;
35
36/// Whether `tag` is a legal OCI tag.
37fn is_oci_tag(tag: &str) -> bool {
38    let mut chars = tag.chars();
39    let Some(first) = chars.next() else {
40        return false;
41    };
42    tag.len() <= MAX_OCI_TAG_LEN
43        && (first.is_ascii_alphanumeric() || first == '_')
44        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
45}
46
47/// The tag of a canonical stow `oci_reference` produced by [`oci_reference`]
48/// — everything after `ghcr.io/water-rs/stow-cache:`. The edge builds
49/// `/manifests/<tag>` request URLs from it.
50///
51/// Returns `None` when the reference lacks the canonical prefix, or when
52/// what follows is not a legal OCI tag — so a reference carrying a second
53/// `:`, an `@digest`, a path separator, or an over-long tag is rejected
54/// here rather than becoming a request URL that can only 404.
55#[must_use]
56pub fn oci_reference_tag(reference: &str) -> Option<&str> {
57    let tag = reference.strip_prefix(GHCR_BASE)?.strip_prefix(':')?;
58    is_oci_tag(tag).then_some(tag)
59}
60
61/// Extract the crate-name segment from a canonical stow `oci_reference`
62/// produced by [`oci_reference`].
63///
64/// The crate is the tag's first `.`-separated segment: crates.io names are
65/// `[A-Za-z0-9_-]` and never contain `.`, so `sha-1.0.10.0-…` splits
66/// unambiguously into crate `sha-1` and version `0.10.0`.
67///
68/// Returns `None` when the reference lacks the canonical
69/// `ghcr.io/water-rs/stow-cache:` prefix, when the crate segment is empty,
70/// or when nothing follows the first `.`.
71#[must_use]
72pub fn oci_reference_name(reference: &str) -> Option<&str> {
73    let tag = oci_reference_tag(reference)?;
74    let (name, rest) = tag.split_once('.')?;
75    (!name.is_empty() && !rest.is_empty()).then_some(name)
76}
77
78/// The repository path of an OCI reference.
79///
80/// The segments between the registry host and the tag or digest
81/// (`water-rs/stow-cache` in `ghcr.io/water-rs/stow-cache:serde.1.0.0-…`).
82/// Registry `pull` scopes name this path (`repository:<path>:pull`), not the
83/// crate segment inside the tag.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub struct RepositoryPath<'a>(&'a str);
86
87impl<'a> RepositoryPath<'a> {
88    /// The full `a/b/c` repository path.
89    #[must_use]
90    pub const fn as_str(&self) -> &'a str {
91        self.0
92    }
93}
94
95impl std::fmt::Display for RepositoryPath<'_> {
96    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        formatter.write_str(self.0)
98    }
99}
100
101/// Extract the repository path from an OCI reference.
102///
103/// `ghcr.io/water-rs/stow-cache:serde.1.0.0-…` → `water-rs/stow-cache`.
104/// Both `:tag` and `@digest` reference forms are accepted, and a leading
105/// `scheme://` is ignored.
106///
107/// Returns `None` when the reference is not `<host>/<path>` followed by a
108/// tag or digest.
109#[must_use]
110pub fn repository_path(reference: &str) -> Option<RepositoryPath<'_>> {
111    let without_scheme = reference
112        .split_once("://")
113        .map_or(reference, |(_, rest)| rest);
114    let path = match without_scheme.split_once('@') {
115        Some((head, _digest)) => head,
116        None => without_scheme
117            .rsplit_once(':')
118            .map_or(without_scheme, |(head, _tag)| head),
119    };
120    let (_host, repository) = path.split_once('/')?;
121    (!repository.is_empty()).then_some(RepositoryPath(repository))
122}
123
124/// `sha256:<hex>` of `bytes` — the OCI digest form manifests and blobs are
125/// addressed by (`manifests/<digest>`, `blobs/<digest>`).
126#[must_use]
127pub fn sha256_digest(bytes: &[u8]) -> String {
128    format!("sha256:{}", hex::encode(sha2::Sha256::digest(bytes)))
129}
130
131/// Content addressed by a digest did not hash to it.
132#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
133#[error("OCI digest mismatch: expected {expected}, content hashes to {actual}")]
134pub struct OciDigestMismatch {
135    /// The digest the content was addressed by.
136    pub expected: String,
137    /// The digest the content actually hashes to.
138    pub actual: String,
139}
140
141/// Verify that `bytes` hash to the `sha256:<hex>` `expected` digest.
142///
143/// A digest reference is only as trustworthy as the content behind it —
144/// registries can serve inconsistent bytes, so a fetch by
145/// `manifests/<digest>` must recompute and compare rather than assume.
146///
147/// # Errors
148///
149/// [`OciDigestMismatch`] carrying both digests when they differ.
150pub fn verify_oci_digest(bytes: &[u8], expected: &str) -> Result<(), OciDigestMismatch> {
151    let actual = sha256_digest(bytes);
152    if actual != expected {
153        return Err(OciDigestMismatch {
154            expected: expected.to_owned(),
155            actual,
156        });
157    }
158    Ok(())
159}
160
161/// Compute the OCI reference for an artifact.
162///
163/// Format: `ghcr.io/water-rs/stow-cache:{name}.{version}-{target_short}-{rustc_short}-{feat_hash}-{c_metadata}{kind_suffix}`
164///
165/// Every artifact is a tag of the single `water-rs/stow-cache` package; the
166/// crate name is the tag's first `.`-separated segment, so
167/// `sha-1.0.10.0-…` is crate `sha-1`, version `0.10.0`.
168///
169/// Tags are capped at [`MAX_OCI_TAG_LEN`]. Short forms for target and rustc
170/// and a short hash of the feature set keep a typical tag near 50 characters
171/// after the crate name, but neither the crate name (up to 128 by
172/// [`crate::identity::CrateName`]) nor a semver prerelease is bounded
173/// tightly enough to guarantee that, so the readable `{name}.{version}` head
174/// is truncated to whatever the tail leaves. The tail is what carries
175/// identity — `c_metadata` is a prefix of the blake3 compile key over the
176/// whole five-element identity — so a truncated head can never make two
177/// artifacts share a tag.
178#[must_use]
179pub fn oci_reference(key: &ArtifactKey, c_metadata: &str) -> String {
180    let name = crate_tag_segment(&key.crate_id.name);
181    let version = sanitize_oci_tag_component(&key.crate_id.version.to_string());
182    let target_short = key.target.short();
183    let rustc_short = key.rustc_version.short();
184    let feat_hash = key.features.short_hash();
185    let kind_suffix = match key.kind {
186        crate::artifact::ArtifactKind::Rlib => "",
187        crate::artifact::ArtifactKind::Dylib => "-dy",
188        crate::artifact::ArtifactKind::ProcMacro => "-pm",
189    };
190
191    let tail = format!("-{target_short}-{rustc_short}-{feat_hash}-{c_metadata}{kind_suffix}");
192    let mut head = format!("{name}.{version}");
193    // Every component is ASCII by construction — crate names are
194    // `[A-Za-z0-9_-]` and `sanitize_oci_tag_component` maps anything else to
195    // `_` — so truncating by bytes cannot split a character. The budget
196    // reserves room for [`BUNDLE_TAG_SUFFIX`], so the bundle tag derived by
197    // [`bundle_oci_reference`] fits the same limit.
198    head.truncate(MAX_OCI_TAG_LEN.saturating_sub(tail.len() + BUNDLE_TAG_SUFFIX.len()));
199    format!("{GHCR_BASE}:{head}{tail}")
200}
201
202/// The reference of the assembled bundle artifact published for a canonical
203/// stow `oci_reference`: the same repository, the tag with
204/// [`BUNDLE_TAG_SUFFIX`] appended.
205///
206/// Returns `None` when `reference` is not a canonical stow reference or the
207/// suffixed tag would exceed [`MAX_OCI_TAG_LEN`]; [`oci_reference`] reserves
208/// the suffix in its budget, so every reference it produced fits.
209#[must_use]
210pub fn bundle_oci_reference(reference: &str) -> Option<String> {
211    let tag = oci_reference_tag(reference)?;
212    let bundle_tag = format!("{tag}{BUNDLE_TAG_SUFFIX}");
213    is_oci_tag(&bundle_tag).then(|| format!("{GHCR_BASE}:{bundle_tag}"))
214}
215
216/// The crate segment stays lowercase even though OCI tags are
217/// case-sensitive and crate names need not be (`Inflector`, `RustyXML`, …).
218/// crates.io already rejects a new name that differs from a published one
219/// only by case (or by `-` vs `_`), so folding case cannot make two distinct
220/// published crates collide on one tag prefix.
221fn crate_tag_segment(name: &str) -> String {
222    name.to_ascii_lowercase()
223}
224
225pub(crate) fn sanitize_oci_tag_component(value: &str) -> String {
226    value
227        .chars()
228        .map(|ch| {
229            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
230                ch
231            } else {
232                '_'
233            }
234        })
235        .collect()
236}
237
238#[cfg(test)]
239mod tests {
240    use std::collections::BTreeSet;
241
242    use super::*;
243    use crate::artifact::{ArtifactKey, ArtifactKind, RustCrateType};
244    use crate::crate_info::{CrateId, FeatureSet};
245    use crate::platform::{PanicStrategy, Profile, RustcVersion, Target};
246
247    #[test]
248    fn oci_reference_format() {
249        let key = ArtifactKey {
250            crate_id: CrateId {
251                name: "serde".into(),
252                version: semver::Version::new(1, 0, 210),
253            },
254            features: FeatureSet(BTreeSet::from(["derive".into()])),
255            crate_types: vec![RustCrateType::Rlib],
256            target: Target("x86_64-unknown-linux-gnu".into()),
257            rustc_version: RustcVersion {
258                version: semver::Version::new(1, 83, 0),
259                commit_hash: "90b35a623".into(),
260                llvm_version: "19.1.4".into(),
261            },
262            profile: Profile {
263                opt_level: "0".into(),
264                debuginfo: 2,
265                debug_assertions: true,
266                overflow_checks: true,
267                panic: PanicStrategy::Unwind,
268                strip: crate::platform::StripLevel::None,
269            },
270            kind: ArtifactKind::Rlib,
271        };
272
273        let reference = oci_reference(&key, "abcdef0123456789");
274        assert!(reference.starts_with("ghcr.io/water-rs/stow-cache:serde."));
275        assert!(reference.contains("1.0.210"));
276        assert!(reference.contains("x86_64-linux"));
277        assert!(reference.contains("1.83.0"));
278        assert!(reference.contains("abcdef0123456789"));
279        assert_eq!(oci_reference_name(&reference), Some("serde"));
280        // Should not end with -pm for Rlib
281        assert!(!reference.ends_with("-pm"));
282    }
283
284    #[test]
285    fn oci_reference_name_splits_at_first_dot() {
286        // `sha-1` 0.10.0: crate names never contain `.`, so the first `.`
287        // splits `sha-1` from `0.10.0-…` unambiguously.
288        let key = ArtifactKey {
289            crate_id: CrateId {
290                name: "sha-1".into(),
291                version: semver::Version::new(0, 10, 0),
292            },
293            features: FeatureSet::new(),
294            crate_types: vec![RustCrateType::Rlib],
295            target: Target("x86_64-unknown-linux-gnu".into()),
296            rustc_version: RustcVersion {
297                version: semver::Version::new(1, 83, 0),
298                commit_hash: "90b35a623".into(),
299                llvm_version: "19.1.4".into(),
300            },
301            profile: Profile {
302                opt_level: "0".into(),
303                debuginfo: 2,
304                debug_assertions: true,
305                overflow_checks: true,
306                panic: PanicStrategy::Unwind,
307                strip: crate::platform::StripLevel::None,
308            },
309            kind: ArtifactKind::Rlib,
310        };
311
312        let reference = oci_reference(&key, "abcdef0123456789");
313        assert!(reference.starts_with("ghcr.io/water-rs/stow-cache:sha-1.0.10.0-"));
314        assert_eq!(oci_reference_name(&reference), Some("sha-1"));
315    }
316
317    #[test]
318    fn proc_macro_has_pm_suffix() {
319        let key = ArtifactKey {
320            crate_id: CrateId {
321                name: "serde_derive".into(),
322                version: semver::Version::new(1, 0, 210),
323            },
324            features: FeatureSet::new(),
325            crate_types: vec![RustCrateType::ProcMacro],
326            target: Target("x86_64-unknown-linux-gnu".into()),
327            rustc_version: RustcVersion {
328                version: semver::Version::new(1, 83, 0),
329                commit_hash: "90b35a623".into(),
330                llvm_version: "19.1.4".into(),
331            },
332            profile: Profile {
333                opt_level: "3".into(),
334                debuginfo: 0,
335                debug_assertions: false,
336                overflow_checks: false,
337                panic: PanicStrategy::Unwind,
338                strip: crate::platform::StripLevel::None,
339            },
340            kind: ArtifactKind::ProcMacro,
341        };
342
343        let reference = oci_reference(&key, "abcdef0123456789");
344        assert!(reference.ends_with("-pm"));
345    }
346
347    #[test]
348    fn oci_tag_within_128_chars() {
349        let key = ArtifactKey {
350            crate_id: CrateId {
351                name: "some-really-long-crate-name-that-exists".into(),
352                version: semver::Version::new(99, 99, 99),
353            },
354            features: FeatureSet(BTreeSet::from([
355                "feature1".into(),
356                "feature2".into(),
357                "feature3".into(),
358            ])),
359            crate_types: vec![RustCrateType::Rlib],
360            target: Target("x86_64-unknown-linux-gnu".into()),
361            rustc_version: RustcVersion {
362                version: semver::Version::new(1, 83, 0),
363                commit_hash: "90b35a623".into(),
364                llvm_version: "19.1.4".into(),
365            },
366            profile: Profile {
367                opt_level: "0".into(),
368                debuginfo: 2,
369                debug_assertions: true,
370                overflow_checks: true,
371                panic: PanicStrategy::Unwind,
372                strip: crate::platform::StripLevel::None,
373            },
374            kind: ArtifactKind::Rlib,
375        };
376
377        let reference = oci_reference(&key, "abcdef0123456789");
378        // The tag is the part after the last ':'
379        let tag = reference.rsplit_once(':').unwrap().1;
380        assert!(
381            tag.len() <= 128,
382            "OCI tag too long: {} chars ({})",
383            tag.len(),
384            tag
385        );
386    }
387
388    /// The worst case the identity newtypes admit: a 128-char crate name
389    /// and a long prerelease. The head gives way, the identity-bearing tail
390    /// survives whole, and the tag stays a legal OCI tag.
391    #[test]
392    fn a_long_name_and_prerelease_truncate_the_head_not_the_identity() {
393        let key = ArtifactKey {
394            crate_id: CrateId {
395                name: "x".repeat(128),
396                version: semver::Version::parse("1.0.0-alpha.20260918.build-candidate.7")
397                    .expect("prerelease version"),
398            },
399            features: FeatureSet(BTreeSet::from(["derive".into()])),
400            crate_types: vec![RustCrateType::Rlib],
401            target: Target("x86_64-pc-windows-msvc".into()),
402            rustc_version: RustcVersion {
403                version: semver::Version::parse("1.93.0-beta.5").expect("beta version"),
404                commit_hash: "90b35a623".into(),
405                llvm_version: "19.1.4".into(),
406            },
407            profile: Profile {
408                opt_level: "0".into(),
409                debuginfo: 2,
410                debug_assertions: true,
411                overflow_checks: true,
412                panic: PanicStrategy::Unwind,
413                strip: crate::platform::StripLevel::None,
414            },
415            kind: ArtifactKind::ProcMacro,
416        };
417
418        let reference = oci_reference(&key, "fedcba9876543210");
419        let tag = oci_reference_tag(&reference).expect("a legal, canonical tag");
420        // The head leaves exactly the room the bundle suffix needs, so both
421        // tags of the artifact are legal.
422        assert_eq!(tag.len(), MAX_OCI_TAG_LEN - BUNDLE_TAG_SUFFIX.len());
423        assert!(tag.ends_with("-fedcba9876543210-pm"), "{tag}");
424        assert!(tag.starts_with("xxxx"), "{tag}");
425        let bundle = bundle_oci_reference(&reference).expect("bundle tag fits");
426        let bundle_tag = bundle
427            .rsplit_once(':')
428            .map(|(_, tag)| tag)
429            .expect("bundle reference has a tag");
430        assert_eq!(bundle_tag.len(), MAX_OCI_TAG_LEN);
431        assert_eq!(bundle_tag, format!("{tag}{BUNDLE_TAG_SUFFIX}"));
432    }
433
434    /// A tag the builder never emits must not be accepted as canonical: the
435    /// edge turns it into a `/manifests/<tag>` URL, and the register path
436    /// gates on the same parser.
437    #[test]
438    fn illegal_tags_are_not_canonical_references() {
439        let over_long = format!("{GHCR_BASE}:s.{}", "1".repeat(MAX_OCI_TAG_LEN));
440        for reference in [
441            // A second `:` — a tag cannot contain one.
442            "ghcr.io/water-rs/stow-cache:serde.1.0.0:extra",
443            // A digest form, not a tag.
444            "ghcr.io/water-rs/stow-cache:sha256@abc",
445            // A path separator, which would escape the manifests URL.
446            "ghcr.io/water-rs/stow-cache:serde.1.0.0/../../evil",
447            // A tag may not start with `.` or `-`.
448            "ghcr.io/water-rs/stow-cache:.serde.1.0.0",
449            over_long.as_str(),
450        ] {
451            assert_eq!(oci_reference_tag(reference), None, "{reference}");
452            assert_eq!(oci_reference_name(reference), None, "{reference}");
453        }
454    }
455
456    #[test]
457    fn oci_crate_segment_is_lowercased() {
458        let key = ArtifactKey {
459            crate_id: CrateId {
460                name: "Inflector".into(),
461                version: semver::Version::new(0, 11, 4),
462            },
463            features: FeatureSet::new(),
464            crate_types: vec![RustCrateType::Rlib],
465            target: Target("x86_64-unknown-linux-gnu".into()),
466            rustc_version: RustcVersion {
467                version: semver::Version::new(1, 83, 0),
468                commit_hash: "90b35a623".into(),
469                llvm_version: "19.1.4".into(),
470            },
471            profile: Profile {
472                opt_level: "0".into(),
473                debuginfo: 2,
474                debug_assertions: true,
475                overflow_checks: true,
476                panic: PanicStrategy::Unwind,
477                strip: crate::platform::StripLevel::None,
478            },
479            kind: ArtifactKind::Rlib,
480        };
481
482        let reference = oci_reference(&key, "abcdef0123456789");
483        let name = oci_reference_name(&reference).expect("canonical reference shape");
484        assert_eq!(name, "inflector");
485        assert!(
486            !oci_reference_tag(&reference)
487                .expect("canonical reference shape")
488                .split('.')
489                .next()
490                .expect("tag is non-empty")
491                .chars()
492                .any(char::is_uppercase)
493        );
494    }
495
496    #[test]
497    fn oci_reference_tag_yields_the_tag() {
498        assert_eq!(
499            oci_reference_tag(
500                "ghcr.io/water-rs/stow-cache:serde.1.0.0-x86_64-linux-1.91.1-abcdef012345-0123"
501            ),
502            Some("serde.1.0.0-x86_64-linux-1.91.1-abcdef012345-0123")
503        );
504    }
505
506    #[test]
507    fn canonical_parsers_reject_non_canonical_references() {
508        for reference in [
509            // The retired per-crate layout.
510            "ghcr.io/water-rs/stow-cache/serde:1.0.0",
511            "ghcr.io/water-rs/other:serde.1.0.0",
512            "ghcr.io/water-rs/stow-cache:",
513            "ghcr.io/water-rs/stow-cache",
514            "",
515        ] {
516            assert_eq!(
517                oci_reference_tag(reference),
518                None,
519                "reference should fail: {reference}"
520            );
521            assert_eq!(
522                oci_reference_name(reference),
523                None,
524                "reference should fail: {reference}"
525            );
526        }
527        // The tag parses but there is no `{crate}.{rest}` split.
528        for reference in [
529            "ghcr.io/water-rs/stow-cache:.1.0.0",
530            "ghcr.io/water-rs/stow-cache:serde",
531            "ghcr.io/water-rs/stow-cache:serde.",
532        ] {
533            assert_eq!(
534                oci_reference_name(reference),
535                None,
536                "reference should fail: {reference}"
537            );
538        }
539    }
540
541    #[test]
542    fn content_hashing_to_the_digest_verifies() {
543        let bytes =
544            br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}"#;
545        let digest = sha256_digest(bytes);
546        assert!(digest.starts_with("sha256:"));
547        assert_eq!(digest.len(), "sha256:".len() + 64);
548        verify_oci_digest(bytes, &digest).expect("content hashes to its digest");
549    }
550
551    #[test]
552    fn content_hashing_to_a_different_digest_is_rejected() {
553        let registered = sha256_digest(b"the manifest the row was registered for");
554        let error = verify_oci_digest(b"repushed manifest bytes", &registered)
555            .expect_err("content not hashing to the expected digest must fail");
556        assert_eq!(error.expected, registered);
557        assert_eq!(error.actual, sha256_digest(b"repushed manifest bytes"));
558    }
559
560    #[test]
561    fn repository_path_from_tag_reference() {
562        let path = repository_path(
563            "ghcr.io/water-rs/stow-cache:serde.1.0.0-x86_64-linux-1.91.1-abcdef012345-0123",
564        )
565        .expect("canonical reference");
566        assert_eq!(path.as_str(), "water-rs/stow-cache");
567        assert_eq!(path.to_string(), "water-rs/stow-cache");
568    }
569
570    #[test]
571    fn repository_path_from_digest_reference() {
572        let path = repository_path("ghcr.io/water-rs/stow-cache@sha256:deadbeef")
573            .expect("digest reference");
574        assert_eq!(path.as_str(), "water-rs/stow-cache");
575    }
576
577    #[test]
578    fn repository_path_handles_scheme_and_single_segment_repo() {
579        let path =
580            repository_path("https://registry.local/serde:tag").expect("single-segment repo");
581        assert_eq!(path.as_str(), "serde");
582    }
583
584    #[test]
585    fn repository_path_rejects_non_reference() {
586        for reference in ["ghcr.io", "ghcr.io/", "serde", "serde:tag", ""] {
587            assert_eq!(
588                repository_path(reference),
589                None,
590                "reference should fail: {reference}"
591            );
592        }
593    }
594
595    #[test]
596    fn oci_tag_sanitizes_build_metadata() {
597        let key = ArtifactKey {
598            crate_id: CrateId {
599                name: "libgit2-sys".into(),
600                version: semver::Version::parse("0.17.0+1.8.1").expect("valid semver"),
601            },
602            features: FeatureSet::new(),
603            crate_types: vec![RustCrateType::Lib],
604            target: Target("aarch64-apple-darwin".into()),
605            rustc_version: RustcVersion {
606                version: semver::Version::new(1, 91, 1),
607                commit_hash: "ed61e7d7e".into(),
608                llvm_version: "21.0.0".into(),
609            },
610            profile: Profile {
611                opt_level: "0".into(),
612                debuginfo: 2,
613                debug_assertions: true,
614                overflow_checks: true,
615                panic: PanicStrategy::Unwind,
616                strip: crate::platform::StripLevel::None,
617            },
618            kind: ArtifactKind::Rlib,
619        };
620
621        let reference = oci_reference(&key, "d44626168446442d");
622        let tag = reference.rsplit_once(':').expect("tag separator").1;
623        assert!(tag.contains("0.17.0_1.8.1"));
624        assert!(!tag.contains('+'));
625    }
626}