Skip to main content

tuff_core/
oci.rs

1//! OCI registry distribution for deterministic Tuff pack artifacts.
2
3use 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
25/// OCI artifact type identifying a Tuff pack manifest.
26pub const PACK_ARTIFACT_MEDIA_TYPE: &str = "application/vnd.tuff.pack.v1";
27/// OCI layer media type containing exact `.tuffpack` bytes.
28pub const PACK_LAYER_MEDIA_TYPE: &str = "application/vnd.tuff.pack.layer.v1";
29/// OCI media type for the standard empty JSON descriptor.
30pub 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/// Network and TLS settings shared by OCI push and pull operations.
40#[derive(Debug, Clone, Default)]
41pub struct OciTransferOptions {
42    /// Use unencrypted HTTP instead of HTTPS for a development registry.
43    pub plain_http: bool,
44    /// Additional PEM-encoded certificate authorities trusted for this operation.
45    pub ca_files: Vec<PathBuf>,
46}
47
48/// Deterministic result returned after publishing a pack.
49#[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/// Whether a push wrote a manifest or found the same manifest already published.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "lowercase")]
64pub enum OciPushStatus {
65    Pushed,
66    Unchanged,
67}
68
69/// Deterministic result returned after pulling and verifying a pack.
70#[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
83/// Publishes one verified `.tuffpack` artifact under an explicit OCI tag.
84///
85/// The existing tag is treated as immutable unless `force` is true. Publishing the exact same
86/// manifest is idempotent and returns [`OciPushStatus::Unchanged`].
87///
88/// # Errors
89///
90/// Returns an error for an invalid artifact or reference, credential and TLS failures, a
91/// conflicting tag, registry protocol failures, or a digest mismatch after publication.
92pub 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
176/// Normalize an arbitrary OCI reference into its repository form
177/// ("registry/repository", no tag), so it can be recorded once and re-queried
178/// for available tags later without a pinned tag going stale.
179pub 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
188/// List the tags published under a pack's repository.
189///
190/// `repository_reference` is the "registry/repository" form produced by
191/// [`normalize_pack_repository`]; any tag on it is ignored, since listing
192/// tags does not require pinning one.
193pub 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
207/// Pulls one OCI-distributed pack, verifies both OCI and Tuff integrity, and persists it atomically.
208///
209/// # Errors
210///
211/// Returns an error for an invalid reference, existing output, credential and TLS failures,
212/// unsupported OCI metadata, registry protocol failures, digest mismatches, or invalid pack bytes.
213pub 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}