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::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
180/// Normalize an arbitrary OCI reference into its repository form
181/// ("registry/repository", no tag), so it can be recorded once and re-queried
182/// for available tags later without a pinned tag going stale.
183pub 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
192/// List the tags published under a pack's repository.
193///
194/// `repository_reference` is the "registry/repository" form produced by
195/// [`normalize_pack_repository`]; any tag on it is ignored, since listing
196/// tags does not require pinning one.
197pub 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/// What a pack tag points at right now, learned from the manifest alone.
212///
213/// Resolving a tag costs one manifest fetch (a few hundred bytes of JSON)
214/// and no blob download: a Tuff pack manifest carries exactly one layer,
215/// and that layer's digest *is* the `.tuffpack` artifact digest the lockfile
216/// records. Comparing the two answers "is the tag still the bytes I
217/// installed?" without pulling the pack again.
218#[derive(Debug, Clone, Serialize)]
219#[serde(rename_all = "camelCase")]
220pub struct OciResolvedTag {
221    pub manifest_digest: String,
222    /// The pack layer digest, `sha256:<hex>`, equal to the artifact digest.
223    pub artifact_digest: String,
224    /// Pack name from the manifest annotations, when present.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub name: Option<String>,
227    /// Pack version from the manifest annotations, when present.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub version: Option<String>,
230}
231
232/// Resolves a tag (or digest) reference to the pack it currently names.
233///
234/// Returns `Ok(None)` when the registry reports the tag as missing, which is
235/// a distinct answer from a network or authentication failure: a deleted tag
236/// is something the caller should say out loud, not fold into "error".
237///
238/// # Errors
239///
240/// Returns an error for an invalid reference, credential and TLS failures,
241/// unsupported OCI metadata, or registry protocol failures other than a
242/// missing manifest.
243pub 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
265/// Pulls the manifest behind an already-resolved digest and validates its
266/// shape as a Tuff pack manifest. Shared by [`resolve_pack_tag`] and
267/// [`pull_pack`], which differ only in whether the layer is downloaded.
268async 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
294/// Pulls one OCI-distributed pack, verifies both OCI and Tuff integrity, and persists it atomically.
295///
296/// # Errors
297///
298/// Returns an error for an invalid reference, existing output, credential and TLS failures,
299/// unsupported OCI metadata, registry protocol failures, digest mismatches, or invalid pack bytes.
300pub 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}