1use std::{
4 collections::BTreeMap,
5 env, fs,
6 path::{Path, PathBuf},
7};
8
9use docker_credential::{CredentialRetrievalError, DockerCredential};
10use oci_client::{
11 Client, Reference, RegistryOperation,
12 client::{Certificate, CertificateEncoding, ClientConfig, ClientProtocol},
13 errors::{OciDistributionError, OciErrorCode},
14 manifest::{OCI_IMAGE_MEDIA_TYPE, OciDescriptor, OciImageManifest},
15 secrets::RegistryAuth,
16};
17use serde::Serialize;
18use sha2::{Digest, Sha256};
19
20use crate::{
21 error::{Result, TuffError},
22 pack,
23};
24
25pub const PACK_ARTIFACT_MEDIA_TYPE: &str = "application/vnd.tuff.pack.v1";
27pub const PACK_LAYER_MEDIA_TYPE: &str = "application/vnd.tuff.pack.layer.v1";
29pub const OCI_EMPTY_MEDIA_TYPE: &str = "application/vnd.oci.empty.v1+json";
31
32const OCI_EMPTY_JSON: &[u8] = b"{}";
33const OCI_EMPTY_DIGEST: &str =
34 "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a";
35const OCI_TITLE_ANNOTATION: &str = "org.opencontainers.image.title";
36const OCI_VERSION_ANNOTATION: &str = "org.opencontainers.image.version";
37const OCI_DESCRIPTION_ANNOTATION: &str = "org.opencontainers.image.description";
38
39#[derive(Debug, Clone, Default)]
41pub struct OciTransferOptions {
42 pub plain_http: bool,
44 pub ca_files: Vec<PathBuf>,
46}
47
48#[derive(Debug, Clone, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub struct OciPushResult {
52 pub status: OciPushStatus,
53 pub name: String,
54 pub version: String,
55 pub artifact_digest: String,
56 pub manifest_digest: String,
57 pub tag_reference: String,
58 pub reference: String,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "lowercase")]
64pub enum OciPushStatus {
65 Pushed,
66 Unchanged,
67}
68
69#[derive(Debug, Clone, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct OciPullResult {
73 pub name: String,
74 pub version: String,
75 pub artifact_digest: String,
76 pub manifest_digest: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub tag_reference: Option<String>,
79 pub reference: String,
80 pub output: String,
81}
82
83pub async fn push_pack(
93 artifact_path: &Path,
94 reference: &str,
95 force: bool,
96 options: &OciTransferOptions,
97) -> Result<OciPushResult> {
98 let reference = parse_push_reference(reference)?;
99 let artifact_bytes = fs::read(artifact_path).map_err(|error| {
100 TuffError::of(
101 crate::error::ErrorKind::Io,
102 format!(
103 "could not read pack artifact {}: {error}",
104 artifact_path.display()
105 ),
106 )
107 })?;
108 let artifact = pack::read_artifact_bytes(&artifact_bytes)?;
109 let artifact_digest = format!("sha256:{}", artifact.digest);
110 let manifest = pack_manifest(&artifact, &artifact_digest, artifact_bytes.len())?;
111 let manifest_bytes = serde_json::to_vec(&manifest)?;
112 let expected_manifest_digest = sha256_digest(&manifest_bytes);
113 let tag_reference = reference.whole();
114 let digest_reference = digest_reference(&reference, &expected_manifest_digest);
115
116 let client = registry_client(options)?;
117 let auth = registry_auth(reference.registry())?;
118 let existing = match client.fetch_manifest_digest(&reference, &auth).await {
119 Ok(digest) => Some(digest),
120 Err(error) if manifest_is_missing(&error) => None,
121 Err(error) => return Err(oci_error("check existing OCI tag", error)),
122 };
123 if existing.as_deref() == Some(expected_manifest_digest.as_str()) {
124 return Ok(OciPushResult {
125 status: OciPushStatus::Unchanged,
126 name: artifact.metadata.name,
127 version: artifact.metadata.version,
128 artifact_digest,
129 manifest_digest: expected_manifest_digest,
130 tag_reference,
131 reference: digest_reference,
132 });
133 }
134 if let Some(existing) = existing
135 && !force
136 {
137 return Err(TuffError::refused(format!(
138 "refusing to move existing OCI tag '{tag_reference}' from {existing} to {expected_manifest_digest}"
139 ))
140 .with_hint("pass --force to replace it, or publish a new tag"));
141 }
142
143 client
144 .auth(&reference, &auth, RegistryOperation::Push)
145 .await
146 .map_err(|error| oci_error("authenticate OCI push", error))?;
147 push_blob_if_missing(&client, &reference, OCI_EMPTY_JSON, OCI_EMPTY_DIGEST).await?;
148 push_blob_if_missing(&client, &reference, &artifact_bytes, &artifact_digest).await?;
149 client
150 .push_manifest_raw(
151 &reference,
152 manifest_bytes,
153 OCI_IMAGE_MEDIA_TYPE.parse().map_err(|error| {
154 TuffError::new(format!("invalid OCI manifest media type: {error}"))
155 })?,
156 )
157 .await
158 .map_err(|error| oci_error("publish OCI pack manifest", error))?;
159 let published_digest = client
160 .fetch_manifest_digest(&reference, &auth)
161 .await
162 .map_err(|error| oci_error("read back published OCI manifest", error))?;
163 if published_digest != expected_manifest_digest {
164 return Err(TuffError::source_failed(format!(
165 "published OCI manifest digest mismatch: expected {expected_manifest_digest}, registry returned {published_digest}"
166 )));
167 }
168
169 Ok(OciPushResult {
170 status: OciPushStatus::Pushed,
171 name: artifact.metadata.name,
172 version: artifact.metadata.version,
173 artifact_digest,
174 manifest_digest: expected_manifest_digest,
175 tag_reference,
176 reference: digest_reference,
177 })
178}
179
180pub fn normalize_pack_repository(raw: &str) -> Result<String> {
184 let reference = parse_reference(raw)?;
185 Ok(format!(
186 "{}/{}",
187 reference.registry(),
188 reference.repository()
189 ))
190}
191
192pub async fn list_pack_versions(
198 repository_reference: &str,
199 options: &OciTransferOptions,
200) -> Result<Vec<String>> {
201 let reference = parse_reference(repository_reference)?;
202 let client = registry_client(options)?;
203 let auth = registry_auth(reference.registry())?;
204 let response = client
205 .list_tags(&reference, &auth, None, None)
206 .await
207 .map_err(|error| oci_error("list OCI pack tags", error))?;
208 Ok(response.tags)
209}
210
211#[derive(Debug, Clone, Serialize)]
219#[serde(rename_all = "camelCase")]
220pub struct OciResolvedTag {
221 pub manifest_digest: String,
222 pub artifact_digest: String,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub name: Option<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub version: Option<String>,
230}
231
232pub async fn resolve_pack_tag(
244 reference: &str,
245 options: &OciTransferOptions,
246) -> Result<Option<OciResolvedTag>> {
247 let requested = parse_pull_reference(reference)?;
248 let client = registry_client(options)?;
249 let auth = registry_auth(requested.registry())?;
250 let manifest_digest = match client.fetch_manifest_digest(&requested, &auth).await {
251 Ok(digest) => digest,
252 Err(error) if manifest_is_missing(&error) => return Ok(None),
253 Err(error) => return Err(oci_error("resolve OCI pack reference", error)),
254 };
255 let (_, manifest) = fetch_pack_manifest(&client, &auth, &requested, &manifest_digest).await?;
256 let annotations = manifest.annotations.as_ref();
257 Ok(Some(OciResolvedTag {
258 manifest_digest,
259 artifact_digest: manifest.layers[0].digest.clone(),
260 name: annotations.and_then(|a| a.get(OCI_TITLE_ANNOTATION).cloned()),
261 version: annotations.and_then(|a| a.get(OCI_VERSION_ANNOTATION).cloned()),
262 }))
263}
264
265async fn fetch_pack_manifest(
269 client: &Client,
270 auth: &RegistryAuth,
271 requested: &Reference,
272 manifest_digest: &str,
273) -> Result<(Reference, OciImageManifest)> {
274 let pinned = Reference::with_digest(
275 requested.registry().to_string(),
276 requested.repository().to_string(),
277 manifest_digest.to_string(),
278 );
279 let (manifest_bytes, pulled_digest) = client
280 .pull_manifest_raw(&pinned, auth, &[OCI_IMAGE_MEDIA_TYPE])
281 .await
282 .map_err(|error| oci_error("pull OCI pack manifest", error))?;
283 if pulled_digest != manifest_digest {
284 return Err(TuffError::source_failed(format!(
285 "pulled OCI manifest digest mismatch: resolved {manifest_digest}, received {pulled_digest}"
286 )));
287 }
288 let manifest: OciImageManifest = serde_json::from_slice(&manifest_bytes)
289 .map_err(|error| TuffError::corrupt(format!("invalid OCI pack manifest JSON: {error}")))?;
290 validate_pack_manifest(&manifest)?;
291 Ok((pinned, manifest))
292}
293
294pub async fn pull_pack(
301 reference: &str,
302 output: &Path,
303 options: &OciTransferOptions,
304) -> Result<OciPullResult> {
305 if output.exists() {
306 return Err(TuffError::refused(format!(
307 "refusing to overwrite existing pack artifact: {}",
308 output.display()
309 )));
310 }
311 let requested = parse_pull_reference(reference)?;
312 let tag_reference = requested.tag().map(|_| requested.whole());
313 let client = registry_client(options)?;
314 let auth = registry_auth(requested.registry())?;
315 let manifest_digest = client
316 .fetch_manifest_digest(&requested, &auth)
317 .await
318 .map_err(|error| oci_error("resolve OCI pack reference", error))?;
319 let (pinned, manifest) =
320 fetch_pack_manifest(&client, &auth, &requested, &manifest_digest).await?;
321 let layer = &manifest.layers[0];
322
323 let parent = output.parent().unwrap_or_else(|| Path::new("."));
324 fs::create_dir_all(parent)?;
325 let temporary = tempfile::Builder::new()
326 .prefix("tuff-oci-pull-")
327 .tempfile_in(parent)?;
328 let writer = tokio::fs::File::from_std(temporary.reopen()?);
329 client
330 .pull_blob(&pinned, layer, writer)
331 .await
332 .map_err(|error| oci_error("pull OCI pack layer", error))?;
333 let downloaded_size = temporary.as_file().metadata()?.len();
334 if downloaded_size != layer.size as u64 {
335 return Err(TuffError::source_failed(format!(
336 "pulled OCI pack layer size mismatch: expected {}, received {downloaded_size}",
337 layer.size
338 )));
339 }
340 let artifact_bytes = fs::read(temporary.path())?;
341 let artifact = pack::read_artifact_bytes(&artifact_bytes)?;
342 let artifact_digest = format!("sha256:{}", artifact.digest);
343 if layer.digest != artifact_digest {
344 return Err(TuffError::corrupt(format!(
345 "OCI layer digest {} does not match Tuff artifact digest {artifact_digest}",
346 layer.digest
347 )));
348 }
349 validate_pack_annotations(&manifest, &artifact)?;
350 temporary.persist_noclobber(output).map_err(|error| {
351 TuffError::of(
352 crate::error::ErrorKind::Io,
353 format!(
354 "could not persist pulled pack artifact {}: {}",
355 output.display(),
356 error.error
357 ),
358 )
359 })?;
360 let reference = digest_reference(&pinned, &manifest_digest);
361
362 Ok(OciPullResult {
363 name: artifact.metadata.name,
364 version: artifact.metadata.version,
365 artifact_digest,
366 manifest_digest,
367 tag_reference,
368 reference,
369 output: output.display().to_string(),
370 })
371}
372
373fn parse_push_reference(raw: &str) -> Result<Reference> {
374 if raw.contains('@') || !has_explicit_tag(raw) {
375 return Err(TuffError::usage(
376 "OCI push reference must contain an explicit tag, for example ghcr.io/acme/engineering:1.2.0",
377 ));
378 }
379 parse_reference(raw)
380}
381
382fn parse_pull_reference(raw: &str) -> Result<Reference> {
383 if !raw.contains('@') && !has_explicit_tag(raw) {
384 return Err(TuffError::usage(
385 "OCI pull reference must contain an explicit tag or digest; implicit 'latest' is not allowed",
386 ));
387 }
388 parse_reference(raw)
389}
390
391fn parse_reference(raw: &str) -> Result<Reference> {
392 if raw.trim() != raw || raw.contains("://") {
393 return Err(TuffError::usage(format!(
394 "invalid OCI reference '{raw}'; use registry/repository:tag or registry/repository@sha256:digest without a URL scheme"
395 )));
396 }
397 raw.parse::<Reference>()
398 .map_err(|error| TuffError::usage(format!("invalid OCI reference '{raw}': {error}")))
399}
400
401fn has_explicit_tag(raw: &str) -> bool {
402 let name = raw.split('@').next().unwrap_or(raw);
403 let slash = name.rfind('/');
404 name.rfind(':')
405 .is_some_and(|colon| slash.is_none_or(|slash| colon > slash))
406}
407
408fn pack_manifest(
409 artifact: &pack::PackArtifact,
410 artifact_digest: &str,
411 artifact_size: usize,
412) -> Result<OciImageManifest> {
413 let artifact_size = i64::try_from(artifact_size)
414 .map_err(|_| TuffError::usage("pack artifact is too large for an OCI descriptor"))?;
415 let mut annotations = BTreeMap::new();
416 annotations.insert(
417 OCI_TITLE_ANNOTATION.to_string(),
418 artifact.metadata.name.clone(),
419 );
420 annotations.insert(
421 OCI_VERSION_ANNOTATION.to_string(),
422 artifact.metadata.version.clone(),
423 );
424 annotations.insert(
425 OCI_DESCRIPTION_ANNOTATION.to_string(),
426 artifact.metadata.description.clone(),
427 );
428 Ok(OciImageManifest {
429 schema_version: 2,
430 media_type: Some(OCI_IMAGE_MEDIA_TYPE.to_string()),
431 config: descriptor(OCI_EMPTY_MEDIA_TYPE, OCI_EMPTY_DIGEST, 2),
432 layers: vec![descriptor(
433 PACK_LAYER_MEDIA_TYPE,
434 artifact_digest,
435 artifact_size,
436 )],
437 subject: None,
438 artifact_type: Some(PACK_ARTIFACT_MEDIA_TYPE.to_string()),
439 annotations: Some(annotations),
440 })
441}
442
443fn descriptor(media_type: &str, digest: &str, size: i64) -> OciDescriptor {
444 OciDescriptor {
445 media_type: media_type.to_string(),
446 digest: digest.to_string(),
447 size,
448 urls: None,
449 annotations: None,
450 artifact_type: None,
451 }
452}
453
454fn validate_pack_manifest(manifest: &OciImageManifest) -> Result<()> {
455 if manifest.schema_version != 2 || manifest.media_type.as_deref() != Some(OCI_IMAGE_MEDIA_TYPE)
456 {
457 return Err(TuffError::corrupt(
458 "OCI object is not an OCI image manifest schema version 2",
459 ));
460 }
461 if manifest.artifact_type.as_deref() != Some(PACK_ARTIFACT_MEDIA_TYPE) {
462 return Err(TuffError::corrupt(format!(
463 "OCI object is not a Tuff pack: expected artifact type {PACK_ARTIFACT_MEDIA_TYPE}"
464 )));
465 }
466 if manifest.subject.is_some() {
467 return Err(TuffError::corrupt(
468 "OCI Tuff pack manifest must not declare a subject",
469 ));
470 }
471 if manifest.config.media_type != OCI_EMPTY_MEDIA_TYPE
472 || manifest.config.digest != OCI_EMPTY_DIGEST
473 || manifest.config.size != 2
474 {
475 return Err(TuffError::corrupt(
476 "OCI Tuff pack manifest has an invalid empty configuration descriptor",
477 ));
478 }
479 if manifest.layers.len() != 1 {
480 return Err(TuffError::corrupt(format!(
481 "OCI Tuff pack manifest must contain exactly one layer, found {}",
482 manifest.layers.len()
483 )));
484 }
485 let layer = &manifest.layers[0];
486 if layer.media_type != PACK_LAYER_MEDIA_TYPE {
487 return Err(TuffError::unsupported(format!(
488 "unsupported OCI Tuff pack layer media type: {}",
489 layer.media_type
490 )));
491 }
492 if layer.size < 0 || !valid_sha256_digest(&layer.digest) {
493 return Err(TuffError::corrupt(
494 "OCI Tuff pack layer has an invalid size or SHA-256 digest",
495 ));
496 }
497 Ok(())
498}
499
500fn validate_pack_annotations(
501 manifest: &OciImageManifest,
502 artifact: &pack::PackArtifact,
503) -> Result<()> {
504 let annotations = manifest
505 .annotations
506 .as_ref()
507 .ok_or_else(|| TuffError::corrupt("OCI Tuff pack manifest is missing annotations"))?;
508 for (key, expected) in [
509 (OCI_TITLE_ANNOTATION, artifact.metadata.name.as_str()),
510 (OCI_VERSION_ANNOTATION, artifact.metadata.version.as_str()),
511 (
512 OCI_DESCRIPTION_ANNOTATION,
513 artifact.metadata.description.as_str(),
514 ),
515 ] {
516 if annotations.get(key).map(String::as_str) != Some(expected) {
517 return Err(TuffError::corrupt(format!(
518 "OCI manifest annotation '{key}' does not match the Tuff pack metadata"
519 )));
520 }
521 }
522 Ok(())
523}
524
525fn registry_client(options: &OciTransferOptions) -> Result<Client> {
526 let mut certificates = Vec::with_capacity(options.ca_files.len());
527 for path in &options.ca_files {
528 let data = fs::read(path).map_err(|error| {
529 TuffError::of(
530 crate::error::ErrorKind::Io,
531 format!(
532 "could not read OCI certificate authority {}: {error}",
533 path.display()
534 ),
535 )
536 })?;
537 certificates.push(Certificate {
538 encoding: CertificateEncoding::Pem,
539 data,
540 });
541 }
542 Client::try_from(ClientConfig {
543 protocol: if options.plain_http {
544 ClientProtocol::Http
545 } else {
546 ClientProtocol::Https
547 },
548 extra_root_certificates: certificates,
549 platform_resolver: None,
550 ..Default::default()
551 })
552 .map_err(|error| oci_error("create OCI registry client", error))
553}
554
555fn registry_auth(registry: &str) -> Result<RegistryAuth> {
556 if docker_config_path().is_some_and(|path| path.is_file()) {
557 match docker_credential::get_credential(registry) {
558 Ok(credential) => return Ok(convert_credential(credential)),
559 Err(CredentialRetrievalError::NoCredentialConfigured) => {}
560 Err(error) => return Err(credential_error("Docker", error)),
561 }
562 }
563 if podman_config_path().is_some_and(|path| path.is_file()) {
564 match docker_credential::get_podman_credential(registry) {
565 Ok(credential) => return Ok(convert_credential(credential)),
566 Err(CredentialRetrievalError::NoCredentialConfigured) => {}
567 Err(error) => return Err(credential_error("Podman", error)),
568 }
569 }
570 Ok(RegistryAuth::Anonymous)
571}
572
573fn convert_credential(credential: DockerCredential) -> RegistryAuth {
574 match credential {
575 DockerCredential::IdentityToken(token) => RegistryAuth::Bearer(token),
576 DockerCredential::UsernamePassword(username, password) => {
577 RegistryAuth::Basic(username, password)
578 }
579 }
580}
581
582fn credential_error(source: &str, error: CredentialRetrievalError) -> TuffError {
583 let detail = match error {
584 CredentialRetrievalError::HelperCommunicationError => {
585 "could not communicate with the configured credential helper".to_string()
586 }
587 CredentialRetrievalError::MalformedHelperResponse => {
588 "the configured credential helper returned a malformed response".to_string()
589 }
590 CredentialRetrievalError::HelperFailure { helper, .. } => {
591 format!("credential helper '{helper}' failed")
592 }
593 CredentialRetrievalError::CredentialDecodingError => {
594 "the stored credential could not be decoded".to_string()
595 }
596 CredentialRetrievalError::CredentialMismatchError => {
597 "the stored credential fields do not agree".to_string()
598 }
599 CredentialRetrievalError::NoCredentialConfigured => {
600 "no credential is configured".to_string()
601 }
602 CredentialRetrievalError::ConfigNotFound => {
603 "the credential configuration was not found".to_string()
604 }
605 CredentialRetrievalError::ConfigReadError => {
606 "the credential configuration could not be read".to_string()
607 }
608 };
609 TuffError::source_failed(format!(
610 "could not load {source} registry credentials: {detail}"
611 ))
612 .with_hint(format!(
613 "run `{} login` for the registry and try again",
614 source.to_ascii_lowercase()
615 ))
616}
617
618fn docker_config_path() -> Option<PathBuf> {
619 env::var_os("DOCKER_CONFIG")
620 .map(PathBuf::from)
621 .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".docker")))
622 .map(|directory| directory.join("config.json"))
623}
624
625fn podman_config_path() -> Option<PathBuf> {
626 if let Some(path) = env::var_os("REGISTRY_AUTH_FILE") {
627 return Some(PathBuf::from(path));
628 }
629 let primary = if cfg!(target_os = "linux") {
630 env::var_os("XDG_RUNTIME_DIR")
631 .map(PathBuf::from)
632 .map(|path| path.join("containers/auth.json"))
633 } else {
634 env::var_os("HOME")
635 .map(PathBuf::from)
636 .map(|path| path.join(".config/containers/auth.json"))
637 };
638 if primary.as_ref().is_some_and(|path| path.is_file()) {
639 return primary;
640 }
641 env::var_os("DOCKER_CONFIG")
642 .map(PathBuf::from)
643 .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".docker")))
644 .map(|directory| directory.join("containers/auth.json"))
645}
646
647async fn push_blob_if_missing(
648 client: &Client,
649 reference: &Reference,
650 bytes: &[u8],
651 digest: &str,
652) -> Result<()> {
653 if !client
654 .blob_exists(reference, digest)
655 .await
656 .map_err(|error| oci_error("check OCI blob", error))?
657 {
658 client
659 .push_blob(reference, bytes.to_vec(), digest)
660 .await
661 .map_err(|error| oci_error("push OCI blob", error))?;
662 }
663 Ok(())
664}
665
666fn manifest_is_missing(error: &OciDistributionError) -> bool {
667 match error {
668 OciDistributionError::ImageManifestNotFoundError(_)
669 | OciDistributionError::ServerError { code: 404, .. } => true,
670 OciDistributionError::RegistryError { envelope, .. } => {
671 envelope.errors.iter().any(|item| {
672 matches!(
673 item.code,
674 OciErrorCode::ManifestUnknown
675 | OciErrorCode::NameUnknown
676 | OciErrorCode::NotFound
677 )
678 })
679 }
680 _ => false,
681 }
682}
683
684fn oci_error(action: &str, error: OciDistributionError) -> TuffError {
685 TuffError::source_failed(format!("could not {action}: {error}"))
686}
687
688fn digest_reference(reference: &Reference, digest: &str) -> String {
689 format!(
690 "{}/{}@{digest}",
691 reference.registry(),
692 reference.repository()
693 )
694}
695
696fn sha256_digest(bytes: &[u8]) -> String {
697 format!("sha256:{:x}", Sha256::digest(bytes))
698}
699
700fn valid_sha256_digest(value: &str) -> bool {
701 value.strip_prefix("sha256:").is_some_and(|digest| {
702 digest.len() == 64 && digest.chars().all(|item| item.is_ascii_hexdigit())
703 })
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709 use crate::pack::PackArtifactMetadata;
710
711 fn artifact() -> pack::PackArtifact {
712 pack::PackArtifact {
713 metadata: PackArtifactMetadata {
714 artifact_version: pack::PACK_ARTIFACT_VERSION,
715 pack_schema: pack::PACK_SCHEMA_VERSION,
716 name: "com.acme/engineering".into(),
717 version: "1.2.0".into(),
718 description: "Acme engineering capabilities.".into(),
719 capabilities: Vec::new(),
720 targets: Vec::new(),
721 files: Vec::new(),
722 },
723 contents: Vec::new(),
724 digest: "a".repeat(64),
725 }
726 }
727
728 #[test]
729 fn push_reference_requires_explicit_tag() {
730 let error = parse_push_reference("ghcr.io/acme/engineering").unwrap_err();
731 assert!(error.to_string().contains("explicit tag"));
732 }
733
734 #[test]
735 fn push_reference_rejects_digest() {
736 let reference = format!("ghcr.io/acme/engineering@sha256:{}", "a".repeat(64));
737 let error = parse_push_reference(&reference).unwrap_err();
738 assert!(error.to_string().contains("explicit tag"));
739 }
740
741 #[test]
742 fn pull_reference_requires_explicit_tag_or_digest() {
743 let error = parse_pull_reference("ghcr.io/acme/engineering").unwrap_err();
744 assert!(error.to_string().contains("implicit 'latest'"));
745 }
746
747 #[test]
748 fn references_accept_registry_ports() {
749 assert!(parse_push_reference("localhost:5000/acme/engineering:1.2.0").is_ok());
750 }
751
752 #[test]
753 fn identity_token_becomes_bearer_auth() {
754 let auth = convert_credential(DockerCredential::IdentityToken("secret".into()));
755 assert_eq!(auth, RegistryAuth::Bearer("secret".into()));
756 }
757
758 #[test]
759 fn credential_helper_error_does_not_include_helper_output() {
760 let error = credential_error(
761 "Docker",
762 CredentialRetrievalError::HelperFailure {
763 helper: "test".into(),
764 stdout: "sensitive-stdout".into(),
765 stderr: "sensitive-stderr".into(),
766 },
767 );
768 let message = error.to_string();
769 assert!(!message.contains("sensitive"));
770 }
771
772 #[test]
773 fn sha256_validation_requires_prefixed_lower_or_upper_hex() {
774 assert!(valid_sha256_digest(&format!("sha256:{}", "a".repeat(64))));
775 assert!(!valid_sha256_digest(&format!("sha512:{}", "a".repeat(64))));
776 }
777
778 #[test]
779 fn manifest_is_deterministic_and_contains_one_pack_layer() {
780 let artifact = artifact();
781 let digest = format!("sha256:{}", artifact.digest);
782 let left = serde_json::to_vec(&pack_manifest(&artifact, &digest, 42).unwrap()).unwrap();
783 let right = serde_json::to_vec(&pack_manifest(&artifact, &digest, 42).unwrap()).unwrap();
784
785 assert_eq!(left, right);
786 let manifest: OciImageManifest = serde_json::from_slice(&left).unwrap();
787 assert_eq!(
788 manifest.artifact_type.as_deref(),
789 Some(PACK_ARTIFACT_MEDIA_TYPE)
790 );
791 assert_eq!(manifest.layers.len(), 1);
792 assert_eq!(manifest.layers[0].digest, digest);
793 }
794
795 #[test]
796 fn manifest_validation_rejects_extra_layers() {
797 let artifact = artifact();
798 let digest = format!("sha256:{}", artifact.digest);
799 let mut manifest = pack_manifest(&artifact, &digest, 42).unwrap();
800 manifest.layers.push(manifest.layers[0].clone());
801
802 let error = validate_pack_manifest(&manifest).unwrap_err();
803 assert!(error.to_string().contains("exactly one layer"));
804 }
805
806 #[test]
807 fn manifest_validation_rejects_wrong_artifact_type() {
808 let artifact = artifact();
809 let digest = format!("sha256:{}", artifact.digest);
810 let mut manifest = pack_manifest(&artifact, &digest, 42).unwrap();
811 manifest.artifact_type = Some("application/vnd.example.other.v1".into());
812
813 let error = validate_pack_manifest(&manifest).unwrap_err();
814 assert!(error.to_string().contains("not a Tuff pack"));
815 }
816
817 #[test]
818 fn manifest_validation_rejects_wrong_layer_media_type() {
819 let artifact = artifact();
820 let digest = format!("sha256:{}", artifact.digest);
821 let mut manifest = pack_manifest(&artifact, &digest, 42).unwrap();
822 manifest.layers[0].media_type = "application/octet-stream".into();
823
824 let error = validate_pack_manifest(&manifest).unwrap_err();
825 assert!(error.to_string().contains("layer media type"));
826 }
827
828 #[test]
829 fn manifest_validation_rejects_non_empty_config_contract() {
830 let artifact = artifact();
831 let digest = format!("sha256:{}", artifact.digest);
832 let mut manifest = pack_manifest(&artifact, &digest, 42).unwrap();
833 manifest.config.size = 0;
834
835 let error = validate_pack_manifest(&manifest).unwrap_err();
836 assert!(error.to_string().contains("empty configuration"));
837 }
838
839 #[test]
840 fn manifest_validation_rejects_subject_on_primary_pack() {
841 let artifact = artifact();
842 let digest = format!("sha256:{}", artifact.digest);
843 let mut manifest = pack_manifest(&artifact, &digest, 42).unwrap();
844 manifest.subject = Some(manifest.layers[0].clone());
845
846 let error = validate_pack_manifest(&manifest).unwrap_err();
847 assert!(error.to_string().contains("must not declare a subject"));
848 }
849
850 #[test]
851 fn annotation_validation_rejects_metadata_mismatch() {
852 let artifact = artifact();
853 let digest = format!("sha256:{}", artifact.digest);
854 let mut manifest = pack_manifest(&artifact, &digest, 42).unwrap();
855 manifest
856 .annotations
857 .as_mut()
858 .unwrap()
859 .insert(OCI_VERSION_ANNOTATION.into(), "9.9.9".into());
860
861 let error = validate_pack_annotations(&manifest, &artifact).unwrap_err();
862 assert!(error.to_string().contains(OCI_VERSION_ANNOTATION));
863 }
864}