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