1use sha2::Digest as _;
4
5use crate::artifact::ArtifactKey;
6
7macro_rules! ghcr_repository {
10 () => {
11 "water-rs/stow-cache"
12 };
13}
14
15pub const GHCR_REPOSITORY: &str = ghcr_repository!();
22pub const GHCR_BASE: &str = concat!("ghcr.io/", ghcr_repository!());
24pub const GHCR_V2_BASE_URL: &str = concat!("https://ghcr.io/v2/", ghcr_repository!());
26
27pub const BUNDLE_TAG_SUFFIX: &str = ".bundle";
31
32pub const MAX_OCI_TAG_LEN: usize = 128;
35
36fn 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub struct RepositoryPath<'a>(&'a str);
86
87impl<'a> RepositoryPath<'a> {
88 #[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#[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#[must_use]
127pub fn sha256_digest(bytes: &[u8]) -> String {
128 format!("sha256:{}", hex::encode(sha2::Sha256::digest(bytes)))
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
133#[error("OCI digest mismatch: expected {expected}, content hashes to {actual}")]
134pub struct OciDigestMismatch {
135 pub expected: String,
137 pub actual: String,
139}
140
141pub 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#[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 head.truncate(MAX_OCI_TAG_LEN.saturating_sub(tail.len() + BUNDLE_TAG_SUFFIX.len()));
199 format!("{GHCR_BASE}:{head}{tail}")
200}
201
202#[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
216fn 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 assert!(!reference.ends_with("-pm"));
282 }
283
284 #[test]
285 fn oci_reference_name_splits_at_first_dot() {
286 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 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 #[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 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 #[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 "ghcr.io/water-rs/stow-cache:serde.1.0.0:extra",
443 "ghcr.io/water-rs/stow-cache:sha256@abc",
445 "ghcr.io/water-rs/stow-cache:serde.1.0.0/../../evil",
447 "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 "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 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", ®istered)
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}