Skip to main content

a3s_box_runtime/oci/
image.rs

1//! OCI image parsing and representation.
2//!
3//! Handles parsing of OCI image layout including manifest and configuration.
4
5use a3s_box_core::error::{BoxError, Result};
6use a3s_box_core::platform::Platform as BoxPlatform;
7use oci_spec::image::{Descriptor, ImageConfiguration, ImageIndex, ImageManifest};
8use sha2::{Digest, Sha256};
9use std::fs::File;
10use std::io::Read;
11use std::path::{Path, PathBuf};
12
13pub(crate) const MAX_OCI_LAYOUT_BYTES: u64 = 64 * 1024;
14pub(crate) const MAX_OCI_INDEX_BYTES: u64 = 4 * 1024 * 1024;
15pub(crate) const MAX_OCI_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
16pub(crate) const MAX_OCI_CONFIG_BYTES: u64 = 64 * 1024 * 1024;
17pub(crate) const MAX_OCI_LAYER_BLOB_BYTES: u64 = 16 * 1024 * 1024 * 1024;
18
19/// Validate the only digest form accepted for local OCI blob paths.
20///
21/// Requiring lowercase canonical SHA-256 makes the returned value safe as one
22/// path component and rejects alternate algorithms, separators, and `..`.
23pub(crate) fn canonical_sha256_digest_hex(digest: &str) -> Result<&str> {
24    digest
25        .strip_prefix("sha256:")
26        .filter(|hex| {
27            hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
28        })
29        .ok_or_else(|| {
30            BoxError::OciImageError(format!(
31                "malformed content digest (expected canonical OCI sha256:<64 lowercase hex>): {digest:?}"
32            ))
33        })
34}
35
36/// Reject symlink/reparse-backed directories before walking an OCI layout.
37pub(crate) fn validate_plain_directory(path: &Path, what: &str) -> Result<()> {
38    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
39        BoxError::OciImageError(format!(
40            "Failed to inspect {what} directory {}: {error}",
41            path.display()
42        ))
43    })?;
44
45    #[cfg(windows)]
46    let is_link_or_reparse = {
47        use std::os::windows::fs::MetadataExt;
48        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
49        metadata.file_type().is_symlink()
50            || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
51    };
52    #[cfg(not(windows))]
53    let is_link_or_reparse = metadata.file_type().is_symlink();
54
55    if is_link_or_reparse || !metadata.is_dir() {
56        return Err(BoxError::OciImageError(format!(
57            "refusing {what} directory {} because it is not a plain directory (symlink/reparse or non-directory)",
58            path.display()
59        )));
60    }
61
62    Ok(())
63}
64
65pub(crate) fn open_regular_file_no_follow(path: &Path, what: &str) -> Result<File> {
66    #[cfg(windows)]
67    let opened = a3s_box_core::windows_file::open_regular_file(path, None).map(|(file, _)| file);
68
69    #[cfg(unix)]
70    let opened = {
71        use std::os::unix::fs::OpenOptionsExt;
72
73        std::fs::OpenOptions::new()
74            .read(true)
75            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
76            .open(path)
77    };
78
79    #[cfg(not(any(windows, unix)))]
80    let opened = std::fs::File::open(path);
81
82    let file = opened.map_err(|error| {
83        BoxError::OciImageError(format!(
84            "Failed to open {what} at {} without following links: {error}",
85            path.display()
86        ))
87    })?;
88    let metadata = file.metadata().map_err(|error| {
89        BoxError::OciImageError(format!(
90            "Failed to inspect opened {what} at {}: {error}",
91            path.display()
92        ))
93    })?;
94    if !metadata.is_file() {
95        return Err(BoxError::OciImageError(format!(
96            "refusing {what} at {} because it is not a regular file",
97            path.display()
98        )));
99    }
100    Ok(file)
101}
102
103fn checked_opened_length(file: &File, path: &Path, what: &str, limit: u64) -> Result<u64> {
104    let length = file
105        .metadata()
106        .map_err(|error| {
107            BoxError::OciImageError(format!(
108                "Failed to inspect {what} at {}: {error}",
109                path.display()
110            ))
111        })?
112        .len();
113    if length > limit {
114        return Err(BoxError::OciImageError(format!(
115            "refusing {what} at {}: {length} bytes exceeds the {limit}-byte limit",
116            path.display()
117        )));
118    }
119    Ok(length)
120}
121
122/// Read a regular file through a no-follow handle with a hard byte ceiling.
123pub(crate) fn read_regular_file_bounded(path: &Path, limit: u64, what: &str) -> Result<Vec<u8>> {
124    let file = open_regular_file_no_follow(path, what)?;
125    let length = checked_opened_length(&file, path, what, limit)?;
126    let capacity = usize::try_from(length).map_err(|_| {
127        BoxError::OciImageError(format!(
128            "refusing {what} at {}: file length does not fit in memory",
129            path.display()
130        ))
131    })?;
132    let mut bytes = Vec::new();
133    bytes.try_reserve_exact(capacity).map_err(|error| {
134        BoxError::OciImageError(format!(
135            "Failed to reserve memory for {what} at {}: {error}",
136            path.display()
137        ))
138    })?;
139    file.take(limit.saturating_add(1))
140        .read_to_end(&mut bytes)
141        .map_err(|error| {
142            BoxError::OciImageError(format!(
143                "Failed to read {what} at {}: {error}",
144                path.display()
145            ))
146        })?;
147    if bytes.len() as u64 > limit {
148        return Err(BoxError::OciImageError(format!(
149            "refusing {what} at {}: content grew beyond the {limit}-byte limit while reading",
150            path.display()
151        )));
152    }
153    Ok(bytes)
154}
155
156fn expected_descriptor_size(size: i64, what: &str) -> Result<u64> {
157    u64::try_from(size).map_err(|_| {
158        BoxError::OciImageError(format!(
159            "refusing {what}: descriptor declares a negative size ({size})"
160        ))
161    })
162}
163
164fn typed_descriptor_contract(descriptor: &Descriptor, what: &str) -> Result<(String, i64)> {
165    let digest = descriptor.digest().to_string();
166    let size = i64::try_from(descriptor.size()).map_err(|_| {
167        BoxError::OciImageError(format!(
168            "refusing {what} {digest}: descriptor size {} exceeds the supported range",
169            descriptor.size()
170        ))
171    })?;
172    Ok((digest, size))
173}
174
175/// Verify descriptor size and SHA-256 against the exact bytes to be consumed.
176pub(crate) fn validate_descriptor_bytes(
177    digest: &str,
178    size: i64,
179    bytes: &[u8],
180    what: &str,
181) -> Result<()> {
182    let expected_hex = canonical_sha256_digest_hex(digest)?;
183    let expected_size = expected_descriptor_size(size, what)?;
184    let actual_size = bytes.len() as u64;
185    if actual_size != expected_size {
186        return Err(BoxError::OciImageError(format!(
187            "refusing {what} {digest}: descriptor size {expected_size} does not match actual size {actual_size}"
188        )));
189    }
190    let actual_hex = format!("{:x}", Sha256::digest(bytes));
191    if actual_hex != expected_hex {
192        return Err(BoxError::OciImageError(format!(
193            "refusing {what} {digest}: descriptor digest does not match actual bytes (sha256:{actual_hex})"
194        )));
195    }
196    Ok(())
197}
198
199fn blob_path(root_dir: &Path, digest: &str) -> Result<PathBuf> {
200    let hex = canonical_sha256_digest_hex(digest)?;
201    Ok(root_dir.join("blobs").join("sha256").join(hex))
202}
203
204pub(crate) fn read_verified_oci_blob(
205    root_dir: &Path,
206    digest: &str,
207    size: i64,
208    limit: u64,
209    what: &str,
210) -> Result<Vec<u8>> {
211    canonical_sha256_digest_hex(digest)?;
212    let expected_size = expected_descriptor_size(size, what)?;
213    if expected_size > limit {
214        return Err(BoxError::OciImageError(format!(
215            "refusing {what} {digest}: descriptor size {expected_size} exceeds the {limit}-byte limit"
216        )));
217    }
218    let path = blob_path(root_dir, digest)?;
219    let bytes = read_regular_file_bounded(&path, limit, what).map_err(|error| {
220        BoxError::OciImageError(format!("Failed to read {what} {digest}: {error}"))
221    })?;
222    validate_descriptor_bytes(digest, size, &bytes, what)?;
223    Ok(bytes)
224}
225
226pub(crate) fn verify_oci_blob_file(
227    root_dir: &Path,
228    digest: &str,
229    size: i64,
230    limit: u64,
231    what: &str,
232) -> Result<PathBuf> {
233    let expected_hex = canonical_sha256_digest_hex(digest)?;
234    let expected_size = expected_descriptor_size(size, what)?;
235    if expected_size > limit {
236        return Err(BoxError::OciImageError(format!(
237            "refusing {what} {digest}: descriptor size {expected_size} exceeds the {limit}-byte limit"
238        )));
239    }
240
241    let path = blob_path(root_dir, digest)?;
242    let mut file = open_regular_file_no_follow(&path, what).map_err(|error| {
243        BoxError::OciImageError(format!("Failed to open {what} {digest}: {error}"))
244    })?;
245    let opened_size = checked_opened_length(&file, &path, what, limit)?;
246    if opened_size != expected_size {
247        return Err(BoxError::OciImageError(format!(
248            "refusing {what} {digest}: descriptor size {expected_size} does not match actual size {opened_size}"
249        )));
250    }
251
252    let mut hasher = Sha256::new();
253    let mut total = 0u64;
254    let mut buffer = [0u8; 64 * 1024];
255    loop {
256        let read = file.read(&mut buffer).map_err(|error| {
257            BoxError::OciImageError(format!(
258                "Failed to read {what} at {}: {error}",
259                path.display()
260            ))
261        })?;
262        if read == 0 {
263            break;
264        }
265        total = total.saturating_add(read as u64);
266        if total > expected_size {
267            return Err(BoxError::OciImageError(format!(
268                "refusing {what} {digest}: content grew beyond its descriptor size {expected_size} while reading"
269            )));
270        }
271        hasher.update(&buffer[..read]);
272    }
273    if total != expected_size {
274        return Err(BoxError::OciImageError(format!(
275            "refusing {what} {digest}: descriptor size {expected_size} does not match bytes read {total}"
276        )));
277    }
278    let actual_hex = format!("{:x}", hasher.finalize());
279    if actual_hex != expected_hex {
280        return Err(BoxError::OciImageError(format!(
281            "refusing {what} {digest}: descriptor digest does not match actual bytes (sha256:{actual_hex})"
282        )));
283    }
284    Ok(path)
285}
286
287/// Health check configuration from OCI image config.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct OciHealthCheck {
290    pub test: Vec<String>,
291    pub interval: Option<u64>,
292    pub timeout: Option<u64>,
293    pub retries: Option<u32>,
294    pub start_period: Option<u64>,
295}
296
297impl OciHealthCheck {
298    /// Whether this Docker-compatible health check contains an executable test.
299    ///
300    /// `NONE` disables an inherited image health check. Malformed `CMD` and
301    /// `CMD-SHELL` arrays without a non-empty command are also non-effective;
302    /// unknown non-empty forms are kept fail-closed for forward compatibility.
303    pub fn is_enabled(&self) -> bool {
304        let Some(marker) = self.test.first() else {
305            return false;
306        };
307        if marker.eq_ignore_ascii_case("NONE") {
308            return false;
309        }
310        if marker.eq_ignore_ascii_case("CMD") || marker.eq_ignore_ascii_case("CMD-SHELL") {
311            return self
312                .test
313                .get(1..)
314                .is_some_and(|command| command.iter().any(|part| !part.trim().is_empty()));
315        }
316        self.test.iter().any(|part| !part.trim().is_empty())
317    }
318}
319
320/// Represents an OCI image loaded from disk.
321#[derive(Debug)]
322pub struct OciImage {
323    /// Root directory of the OCI image layout
324    root_dir: PathBuf,
325
326    /// Exact root manifest descriptor selected from the OCI index.
327    manifest_descriptor: Descriptor,
328
329    /// Authenticated image manifest selected from the OCI index.
330    manifest: ImageManifest,
331
332    /// Complete descriptor set whose bytes were verified while loading.
333    content_descriptors: Vec<Descriptor>,
334
335    /// Platform declared by the verified image configuration.
336    platform: BoxPlatform,
337
338    /// Image configuration
339    config: OciImageConfig,
340
341    /// Paths to layer blobs (in order, bottom to top)
342    layer_paths: Vec<PathBuf>,
343}
344
345/// Parsed OCI image configuration with entrypoint and environment.
346#[derive(Debug, Clone)]
347pub struct OciImageConfig {
348    /// Entrypoint command
349    pub entrypoint: Option<Vec<String>>,
350
351    /// Default command arguments
352    pub cmd: Option<Vec<String>>,
353
354    /// Environment variables
355    pub env: Vec<(String, String)>,
356
357    /// Working directory
358    pub working_dir: Option<String>,
359
360    /// User to run as
361    pub user: Option<String>,
362
363    /// Exposed ports
364    pub exposed_ports: Vec<String>,
365
366    /// Labels
367    pub labels: std::collections::HashMap<String, String>,
368
369    /// Volumes declared in the image (OCI VOLUME directive)
370    pub volumes: Vec<String>,
371
372    /// Stop signal
373    pub stop_signal: Option<String>,
374
375    /// Health check configuration
376    pub health_check: Option<OciHealthCheck>,
377
378    /// ONBUILD triggers
379    pub onbuild: Vec<String>,
380}
381
382impl OciImage {
383    /// Load an OCI image from a directory.
384    ///
385    /// The directory must contain a valid OCI image layout:
386    /// - oci-layout file
387    /// - index.json
388    /// - blobs/sha256/ directory with manifest, config, and layers
389    ///
390    /// # Arguments
391    ///
392    /// * `path` - Path to the OCI image directory
393    ///
394    /// # Errors
395    ///
396    /// Returns error if:
397    /// - Directory doesn't exist
398    /// - OCI layout is invalid
399    /// - Manifest or config cannot be parsed
400    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
401        let root_dir = path.as_ref().to_path_buf();
402
403        // Validate OCI layout
404        Self::validate_oci_layout(&root_dir)?;
405
406        // Load index.json
407        let index = Self::load_index(&root_dir)?;
408
409        // Get the manifest descriptor from index. Its digest and size are both
410        // authenticated before the manifest bytes are parsed.
411        let manifest_descriptor = index
412            .manifests()
413            .first()
414            .cloned()
415            .ok_or_else(|| BoxError::OciImageError("No manifests in index.json".to_string()))?;
416
417        Self::from_validated_manifest_descriptor(root_dir, manifest_descriptor)
418    }
419
420    /// Load one exact manifest descriptor from an already materialized OCI
421    /// layout through the same config and layer verification as [`Self::from_path`].
422    pub(crate) fn from_manifest_descriptor(
423        path: impl AsRef<Path>,
424        manifest_descriptor: Descriptor,
425    ) -> Result<Self> {
426        let root_dir = path.as_ref().to_path_buf();
427        Self::validate_oci_layout(&root_dir)?;
428        Self::from_validated_manifest_descriptor(root_dir, manifest_descriptor)
429    }
430
431    fn from_validated_manifest_descriptor(
432        root_dir: PathBuf,
433        manifest_descriptor: Descriptor,
434    ) -> Result<Self> {
435        // Load manifest
436        let manifest = Self::load_manifest(&root_dir, &manifest_descriptor)?;
437
438        // Load config
439        let (config, platform) = Self::load_config(&root_dir, manifest.config())?;
440
441        // Verify every layer through a no-follow handle before exposing paths
442        // that extraction will subsequently consume.
443        let layer_paths = manifest
444            .layers()
445            .iter()
446            .map(|layer| {
447                let (digest, size) = typed_descriptor_contract(layer, "layer blob")?;
448                verify_oci_blob_file(
449                    &root_dir,
450                    &digest,
451                    size,
452                    MAX_OCI_LAYER_BLOB_BYTES,
453                    "layer blob",
454                )
455            })
456            .collect::<Result<Vec<_>>>()?;
457
458        let mut content_descriptors = Vec::with_capacity(manifest.layers().len() + 2);
459        content_descriptors.push(manifest_descriptor.clone());
460        content_descriptors.push(manifest.config().clone());
461        content_descriptors.extend(manifest.layers().iter().cloned());
462
463        Ok(Self {
464            root_dir,
465            manifest_descriptor,
466            manifest,
467            content_descriptors,
468            platform,
469            config,
470            layer_paths,
471        })
472    }
473
474    /// Get the image configuration.
475    pub fn config(&self) -> &OciImageConfig {
476        &self.config
477    }
478
479    /// Get paths to all layer blobs (in order, bottom to top).
480    pub fn layer_paths(&self) -> &[PathBuf] {
481        &self.layer_paths
482    }
483
484    /// Get the root directory of the OCI image.
485    pub fn root_dir(&self) -> &Path {
486        &self.root_dir
487    }
488
489    /// Get the manifest digest (e.g. `"sha256:abc123..."`).
490    pub fn manifest_digest(&self) -> &str {
491        self.manifest_descriptor.digest().as_ref()
492    }
493
494    /// Exact root manifest descriptor selected from the OCI index.
495    pub(crate) fn manifest_descriptor(&self) -> &Descriptor {
496        &self.manifest_descriptor
497    }
498
499    /// Authenticated manifest selected from the OCI index.
500    pub(crate) fn manifest(&self) -> &ImageManifest {
501        &self.manifest
502    }
503
504    /// Complete descriptor set whose bytes were verified while loading.
505    pub(crate) fn content_descriptors(&self) -> &[Descriptor] {
506        &self.content_descriptors
507    }
508
509    /// Exact platform declared by the verified image configuration.
510    pub(crate) fn platform(&self) -> &BoxPlatform {
511        &self.platform
512    }
513
514    /// Get the entrypoint command.
515    ///
516    /// Returns the entrypoint from config, or None if not set.
517    pub fn entrypoint(&self) -> Option<&[String]> {
518        self.config.entrypoint.as_deref()
519    }
520
521    /// Get the default command arguments.
522    pub fn cmd(&self) -> Option<&[String]> {
523        self.config.cmd.as_deref()
524    }
525
526    /// Get environment variables.
527    pub fn env(&self) -> &[(String, String)] {
528        &self.config.env
529    }
530
531    /// Get the working directory.
532    pub fn working_dir(&self) -> Option<&str> {
533        self.config.working_dir.as_deref()
534    }
535
536    /// Get a label value by key.
537    pub fn label(&self, key: &str) -> Option<&str> {
538        self.config.labels.get(key).map(|s| s.as_str())
539    }
540
541    /// Validate that the directory contains a valid OCI layout.
542    fn validate_oci_layout(root_dir: &Path) -> Result<()> {
543        validate_plain_directory(root_dir, "OCI image root")?;
544
545        // Open the top-level metadata without following links. Reading it here
546        // also prevents oversized metadata from passing an existence-only check.
547        let oci_layout_path = root_dir.join("oci-layout");
548        read_regular_file_bounded(&oci_layout_path, MAX_OCI_LAYOUT_BYTES, "oci-layout")?;
549
550        let index_path = root_dir.join("index.json");
551        read_regular_file_bounded(&index_path, MAX_OCI_INDEX_BYTES, "index.json")?;
552
553        let blobs_dir = root_dir.join("blobs");
554        validate_plain_directory(&blobs_dir, "OCI blobs")?;
555        validate_plain_directory(&blobs_dir.join("sha256"), "OCI sha256 blobs")?;
556
557        Ok(())
558    }
559
560    /// Load the image index from index.json.
561    pub(crate) fn load_index(root_dir: &Path) -> Result<ImageIndex> {
562        let index_path = root_dir.join("index.json");
563        let content = read_regular_file_bounded(&index_path, MAX_OCI_INDEX_BYTES, "index.json")?;
564
565        serde_json::from_slice(&content)
566            .map_err(|e| BoxError::OciImageError(format!("Failed to parse index.json: {}", e)))
567    }
568
569    /// Load and authenticate an image-index blob referenced by a descriptor.
570    pub(crate) fn load_index_blob(root_dir: &Path, descriptor: &Descriptor) -> Result<ImageIndex> {
571        let (digest, size) = typed_descriptor_contract(descriptor, "image-index blob")?;
572        let content = read_verified_oci_blob(
573            root_dir,
574            &digest,
575            size,
576            MAX_OCI_INDEX_BYTES,
577            "image-index blob",
578        )?;
579        serde_json::from_slice(&content).map_err(|error| {
580            BoxError::OciImageError(format!("Failed to parse image-index blob: {error}"))
581        })
582    }
583
584    /// Load the image manifest from blobs.
585    fn load_manifest(root_dir: &Path, descriptor: &Descriptor) -> Result<ImageManifest> {
586        let (digest, size) = typed_descriptor_contract(descriptor, "manifest blob")?;
587        let content = read_verified_oci_blob(
588            root_dir,
589            &digest,
590            size,
591            MAX_OCI_MANIFEST_BYTES,
592            "manifest blob",
593        )?;
594
595        serde_json::from_slice(&content)
596            .map_err(|e| BoxError::OciImageError(format!("Failed to parse manifest: {}", e)))
597    }
598
599    /// Load the image configuration from blobs.
600    fn load_config(
601        root_dir: &Path,
602        descriptor: &Descriptor,
603    ) -> Result<(OciImageConfig, BoxPlatform)> {
604        let (digest, size) = typed_descriptor_contract(descriptor, "config blob")?;
605        let content =
606            read_verified_oci_blob(root_dir, &digest, size, MAX_OCI_CONFIG_BYTES, "config blob")?;
607
608        let oci_config: ImageConfiguration = serde_json::from_slice(&content)
609            .map_err(|e| BoxError::OciImageError(format!("Failed to parse config: {}", e)))?;
610
611        let raw_config: serde_json::Value = serde_json::from_slice(&content)
612            .map_err(|e| BoxError::OciImageError(format!("Failed to parse config JSON: {}", e)))?;
613
614        // oci-spec does not model OnBuild or Healthcheck, so parse those
615        // Docker-compatible image fields directly from raw JSON.
616        let onbuild: Vec<String> = raw_config
617            .get("config")
618            .and_then(|c| c.get("OnBuild"))
619            .cloned()
620            .and_then(|v| serde_json::from_value(v).ok())
621            .unwrap_or_default();
622        let health_check = Self::parse_health_check_from_raw(&raw_config);
623
624        let platform = BoxPlatform {
625            os: oci_config.os().to_string(),
626            architecture: oci_config.architecture().to_string(),
627            variant: oci_config.variant().clone(),
628        };
629        let mut config = OciImageConfig::from_oci_config(&oci_config, onbuild);
630        config.health_check = health_check;
631        Ok((config, platform))
632    }
633
634    /// Parse Docker-compatible Healthcheck metadata from raw image config JSON.
635    fn parse_health_check_from_raw(raw_config: &serde_json::Value) -> Option<OciHealthCheck> {
636        let health = raw_config
637            .get("config")
638            .and_then(|c| c.get("Healthcheck").or_else(|| c.get("healthcheck")))?;
639
640        let test = health.get("Test").or_else(|| health.get("test"))?;
641        let test: Vec<String> = serde_json::from_value(test.clone()).ok()?;
642        if test.is_empty() {
643            return None;
644        }
645
646        if test
647            .first()
648            .is_some_and(|marker| marker.eq_ignore_ascii_case("NONE"))
649        {
650            return None;
651        }
652
653        Some(OciHealthCheck {
654            test,
655            interval: health
656                .get("Interval")
657                .or_else(|| health.get("interval"))
658                .and_then(duration_seconds_from_json),
659            timeout: health
660                .get("Timeout")
661                .or_else(|| health.get("timeout"))
662                .and_then(duration_seconds_from_json),
663            retries: health
664                .get("Retries")
665                .or_else(|| health.get("retries"))
666                .and_then(u32_from_json)
667                .filter(|value| *value > 0),
668            start_period: health
669                .get("StartPeriod")
670                .or_else(|| health.get("start_period"))
671                .and_then(duration_seconds_from_json),
672        })
673    }
674}
675
676fn duration_seconds_from_json(value: &serde_json::Value) -> Option<u64> {
677    let nanos = u64_from_json(value)?;
678    if nanos == 0 {
679        return None;
680    }
681    Some(nanos.div_ceil(1_000_000_000).max(1))
682}
683
684fn u64_from_json(value: &serde_json::Value) -> Option<u64> {
685    value
686        .as_u64()
687        .or_else(|| value.as_str().and_then(|s| s.parse::<u64>().ok()))
688}
689
690fn u32_from_json(value: &serde_json::Value) -> Option<u32> {
691    u64_from_json(value).and_then(|value| u32::try_from(value).ok())
692}
693
694impl OciImageConfig {
695    /// Create from OCI spec ImageConfiguration.
696    fn from_oci_config(oci_config: &ImageConfiguration, onbuild: Vec<String>) -> Self {
697        let config = oci_config.config();
698
699        let entrypoint = config.as_ref().and_then(|c| c.entrypoint().clone());
700        let cmd = config.as_ref().and_then(|c| c.cmd().clone());
701        let working_dir = config.as_ref().and_then(|c| c.working_dir().clone());
702        let user = config.as_ref().and_then(|c| c.user().clone());
703
704        // Parse environment variables
705        let env = config
706            .as_ref()
707            .and_then(|c| c.env().as_ref())
708            .map(|env_list| {
709                env_list
710                    .iter()
711                    .filter_map(|e| {
712                        let parts: Vec<&str> = e.splitn(2, '=').collect();
713                        if parts.len() == 2 {
714                            Some((parts[0].to_string(), parts[1].to_string()))
715                        } else {
716                            None
717                        }
718                    })
719                    .collect()
720            })
721            .unwrap_or_default();
722
723        // Parse exposed ports
724        let exposed_ports = config
725            .as_ref()
726            .and_then(|c| c.exposed_ports().as_ref())
727            .map(|ports| ports.to_vec())
728            .unwrap_or_default();
729
730        // Parse labels
731        let labels = config
732            .as_ref()
733            .and_then(|c| c.labels().clone())
734            .unwrap_or_default();
735
736        // Parse volumes (OCI VOLUME directive)
737        let volumes = config
738            .as_ref()
739            .and_then(|c| c.volumes().as_ref())
740            .map(|vols| vols.to_vec())
741            .unwrap_or_default();
742
743        // Parse stop signal
744        let stop_signal = config.as_ref().and_then(|c| c.stop_signal().clone());
745
746        // Healthcheck is filled by load_config from raw JSON because oci-spec
747        // 0.6 does not expose the Docker-compatible field.
748        let health_check = None;
749
750        Self {
751            entrypoint,
752            cmd,
753            env,
754            working_dir,
755            user,
756            exposed_ports,
757            labels,
758            volumes,
759            stop_signal,
760            health_check,
761            onbuild,
762        }
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use std::fs;
770    use tempfile::TempDir;
771
772    #[test]
773    fn test_validate_oci_layout_missing_oci_layout_file() {
774        let temp_dir = TempDir::new().unwrap();
775
776        let result = OciImage::validate_oci_layout(temp_dir.path());
777
778        assert!(result.is_err());
779        assert!(result.unwrap_err().to_string().contains("oci-layout"));
780    }
781
782    #[test]
783    fn test_validate_oci_layout_missing_index_json() {
784        let temp_dir = TempDir::new().unwrap();
785
786        // Create oci-layout file
787        fs::write(
788            temp_dir.path().join("oci-layout"),
789            r#"{"imageLayoutVersion":"1.0.0"}"#,
790        )
791        .unwrap();
792
793        let result = OciImage::validate_oci_layout(temp_dir.path());
794
795        assert!(result.is_err());
796        assert!(result.unwrap_err().to_string().contains("index.json"));
797    }
798
799    #[test]
800    fn test_validate_oci_layout_missing_blobs() {
801        let temp_dir = TempDir::new().unwrap();
802
803        // Create oci-layout file
804        fs::write(
805            temp_dir.path().join("oci-layout"),
806            r#"{"imageLayoutVersion":"1.0.0"}"#,
807        )
808        .unwrap();
809
810        // Create index.json
811        fs::write(temp_dir.path().join("index.json"), "{}").unwrap();
812
813        let result = OciImage::validate_oci_layout(temp_dir.path());
814
815        assert!(result.is_err());
816        assert!(result.unwrap_err().to_string().contains("blobs"));
817    }
818
819    #[test]
820    fn test_validate_oci_layout_valid() {
821        let temp_dir = TempDir::new().unwrap();
822
823        // Create valid OCI layout structure
824        create_minimal_oci_layout(temp_dir.path());
825
826        let result = OciImage::validate_oci_layout(temp_dir.path());
827
828        assert!(result.is_ok());
829    }
830
831    #[test]
832    fn test_blob_path() {
833        let root = PathBuf::from("/images/test");
834        let digest = format!("sha256:{}", "a".repeat(64));
835
836        let path = blob_path(&root, &digest).unwrap();
837        assert_eq!(
838            path,
839            PathBuf::from(format!("/images/test/blobs/sha256/{}", "a".repeat(64)))
840        );
841
842        assert!(blob_path(&root, "abc123").is_err());
843        assert!(blob_path(
844            &root,
845            "sha256:../../../../../../../../windows/system32/drivers/etc/hosts"
846        )
847        .is_err());
848    }
849
850    #[test]
851    fn test_from_path_valid_image() {
852        let temp_dir = TempDir::new().unwrap();
853
854        // Create a complete OCI image layout
855        create_complete_oci_image(temp_dir.path());
856
857        let image = OciImage::from_path(temp_dir.path()).unwrap();
858
859        // Verify config was parsed
860        assert_eq!(image.entrypoint(), Some(&["/bin/agent".to_string()][..]));
861        assert_eq!(
862            image.cmd(),
863            Some(&["--port".to_string(), "8080".to_string()][..])
864        );
865        assert_eq!(image.working_dir(), Some("/workspace"));
866
867        // Verify env was parsed
868        let env = image.env();
869        assert!(env
870            .iter()
871            .any(|(k, v)| k == "PATH" && v.contains("/usr/bin")));
872
873        // Verify labels
874        assert_eq!(image.label("a3s.type"), Some("agent"));
875
876        // Verify Docker-compatible Healthcheck was parsed from raw config JSON.
877        let health_check = image.config().health_check.as_ref().unwrap();
878        assert_eq!(
879            health_check.test,
880            vec!["CMD-SHELL".to_string(), "test -f /tmp/healthy".to_string()]
881        );
882        assert_eq!(health_check.interval, Some(30));
883        assert_eq!(health_check.timeout, Some(2));
884        assert_eq!(health_check.retries, Some(2));
885        assert_eq!(health_check.start_period, Some(5));
886
887        // Verify layer paths
888        assert_eq!(image.layer_paths().len(), 1);
889    }
890
891    #[test]
892    fn test_from_path_exposes_manifest_digest() {
893        let temp_dir = TempDir::new().unwrap();
894        let layout = create_complete_oci_image(temp_dir.path());
895        let image = OciImage::from_path(temp_dir.path()).unwrap();
896        assert_eq!(image.manifest_digest(), layout.manifest_digest);
897    }
898
899    #[test]
900    fn test_from_path_nonexistent() {
901        let result = OciImage::from_path("/nonexistent/path");
902
903        assert!(result.is_err());
904    }
905
906    #[test]
907    fn test_from_path_rejects_oversized_index() {
908        let temp_dir = TempDir::new().unwrap();
909        create_minimal_oci_layout(temp_dir.path());
910        fs::File::create(temp_dir.path().join("index.json"))
911            .unwrap()
912            .set_len(MAX_OCI_INDEX_BYTES + 1)
913            .unwrap();
914
915        let error = OciImage::from_path(temp_dir.path()).unwrap_err();
916        assert!(error.to_string().contains("limit"), "{error}");
917    }
918
919    #[test]
920    fn test_from_path_rejects_blob_digest_mismatch() {
921        let temp_dir = TempDir::new().unwrap();
922        let layout = create_complete_oci_image(temp_dir.path());
923        let config_path = temp_dir
924            .path()
925            .join("blobs/sha256")
926            .join(digest_hex(&layout.config_digest));
927        let mut content = fs::read(&config_path).unwrap();
928        content[0] ^= 1;
929        fs::write(config_path, content).unwrap();
930
931        let error = OciImage::from_path(temp_dir.path()).unwrap_err();
932        assert!(error.to_string().contains("digest"), "{error}");
933    }
934
935    #[test]
936    fn test_from_path_rejects_noncanonical_manifest_digest() {
937        let temp_dir = TempDir::new().unwrap();
938        create_minimal_oci_layout(temp_dir.path());
939        fs::write(
940            temp_dir.path().join("index.json"),
941            r#"{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:../../../../outside","size":0}]}"#,
942        )
943        .unwrap();
944
945        assert!(OciImage::from_path(temp_dir.path()).is_err());
946    }
947
948    #[test]
949    fn test_from_path_rejects_index_symlink() {
950        let temp_dir = TempDir::new().unwrap();
951        let layout = temp_dir.path().join("layout");
952        fs::create_dir_all(layout.join("blobs/sha256")).unwrap();
953        fs::write(
954            layout.join("oci-layout"),
955            r#"{"imageLayoutVersion":"1.0.0"}"#,
956        )
957        .unwrap();
958        let outside = temp_dir.path().join("outside-index.json");
959        fs::write(&outside, "{}").unwrap();
960        if !symlink_file_for_test(&outside, &layout.join("index.json")) {
961            return;
962        }
963
964        let error = OciImage::from_path(&layout).unwrap_err();
965        assert!(error.to_string().contains("index.json"), "{error}");
966    }
967
968    #[test]
969    fn test_from_path_rejects_layer_blob_symlink() {
970        let temp_dir = TempDir::new().unwrap();
971        let layout = create_complete_oci_image(temp_dir.path());
972        let layer_path = temp_dir
973            .path()
974            .join("blobs/sha256")
975            .join(digest_hex(&layout.layer_digest));
976        let layer_content = fs::read(&layer_path).unwrap();
977        let outside = temp_dir.path().join("outside-layer.tar.gz");
978        fs::write(&outside, layer_content).unwrap();
979        fs::remove_file(&layer_path).unwrap();
980        if !symlink_file_for_test(&outside, &layer_path) {
981            return;
982        }
983
984        let error = OciImage::from_path(temp_dir.path()).unwrap_err();
985        assert!(error.to_string().contains("layer blob"), "{error}");
986    }
987
988    #[test]
989    fn test_validate_layout_rejects_reparse_blobs_directory() {
990        let temp_dir = TempDir::new().unwrap();
991        let layout = temp_dir.path().join("layout");
992        let outside_blobs = temp_dir.path().join("outside-blobs");
993        fs::create_dir_all(&layout).unwrap();
994        fs::create_dir_all(outside_blobs.join("sha256")).unwrap();
995        fs::write(
996            layout.join("oci-layout"),
997            r#"{"imageLayoutVersion":"1.0.0"}"#,
998        )
999        .unwrap();
1000        fs::write(layout.join("index.json"), "{}").unwrap();
1001        if !symlink_dir_for_test(&outside_blobs, &layout.join("blobs")) {
1002            return;
1003        }
1004
1005        let error = OciImage::validate_oci_layout(&layout).unwrap_err();
1006        assert!(error.to_string().contains("plain directory"), "{error}");
1007    }
1008
1009    #[test]
1010    fn test_from_path_rejects_reparse_root_directory() {
1011        let temp_dir = TempDir::new().unwrap();
1012        let target = temp_dir.path().join("target-layout");
1013        fs::create_dir_all(&target).unwrap();
1014        create_complete_oci_image(&target);
1015        let linked_root = temp_dir.path().join("linked-layout");
1016        if !symlink_dir_for_test(&target, &linked_root) {
1017            return;
1018        }
1019
1020        let error = OciImage::from_path(linked_root).unwrap_err();
1021        assert!(error.to_string().contains("plain directory"), "{error}");
1022    }
1023
1024    #[cfg(unix)]
1025    fn symlink_file_for_test(target: &Path, link: &Path) -> bool {
1026        std::os::unix::fs::symlink(target, link).unwrap();
1027        true
1028    }
1029
1030    #[cfg(windows)]
1031    fn symlink_file_for_test(target: &Path, link: &Path) -> bool {
1032        windows_symlink_for_test(|| std::os::windows::fs::symlink_file(target, link))
1033    }
1034
1035    #[cfg(not(any(unix, windows)))]
1036    fn symlink_file_for_test(_target: &Path, _link: &Path) -> bool {
1037        false
1038    }
1039
1040    #[cfg(unix)]
1041    fn symlink_dir_for_test(target: &Path, link: &Path) -> bool {
1042        std::os::unix::fs::symlink(target, link).unwrap();
1043        true
1044    }
1045
1046    #[cfg(windows)]
1047    fn symlink_dir_for_test(target: &Path, link: &Path) -> bool {
1048        windows_symlink_for_test(|| std::os::windows::fs::symlink_dir(target, link))
1049    }
1050
1051    #[cfg(not(any(unix, windows)))]
1052    fn symlink_dir_for_test(_target: &Path, _link: &Path) -> bool {
1053        false
1054    }
1055
1056    #[cfg(windows)]
1057    fn windows_symlink_for_test(create: impl FnOnce() -> std::io::Result<()>) -> bool {
1058        match create() {
1059            Ok(()) => true,
1060            Err(error) if error.raw_os_error() == Some(1314) => false,
1061            Err(error) => panic!("failed to create Windows test symlink: {error}"),
1062        }
1063    }
1064
1065    // Helper function to create minimal OCI layout structure
1066    fn create_minimal_oci_layout(path: &Path) {
1067        fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
1068
1069        fs::write(path.join("index.json"), "{}").unwrap();
1070
1071        fs::create_dir_all(path.join("blobs/sha256")).unwrap();
1072    }
1073
1074    #[derive(Debug)]
1075    struct CompleteLayout {
1076        manifest_digest: String,
1077        config_digest: String,
1078        layer_digest: String,
1079    }
1080
1081    fn sha256_digest(bytes: &[u8]) -> String {
1082        format!("sha256:{:x}", Sha256::digest(bytes))
1083    }
1084
1085    fn digest_hex(digest: &str) -> &str {
1086        digest.strip_prefix("sha256:").unwrap()
1087    }
1088
1089    fn write_blob(path: &Path, bytes: &[u8]) -> String {
1090        let digest = sha256_digest(bytes);
1091        fs::write(path.join("blobs/sha256").join(digest_hex(&digest)), bytes).unwrap();
1092        digest
1093    }
1094
1095    // Helper function to create a complete, cryptographically consistent OCI image.
1096    fn create_complete_oci_image(path: &Path) -> CompleteLayout {
1097        // Create directory structure
1098        fs::create_dir_all(path.join("blobs/sha256")).unwrap();
1099
1100        // Create oci-layout
1101        fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
1102
1103        // Create config blob
1104        let config_content = r#"{
1105            "architecture": "amd64",
1106            "os": "linux",
1107            "config": {
1108                "Entrypoint": ["/bin/agent"],
1109                "Cmd": ["--port", "8080"],
1110                "Env": ["PATH=/usr/local/bin:/usr/bin:/bin"],
1111                "WorkingDir": "/workspace",
1112                "Labels": {
1113                    "a3s.type": "agent",
1114                    "a3s.version": "1.0.0"
1115                },
1116                "Healthcheck": {
1117                    "Test": ["CMD-SHELL", "test -f /tmp/healthy"],
1118                    "Interval": 30000000000,
1119                    "Timeout": 1500000000,
1120                    "Retries": 2,
1121                    "StartPeriod": 5000000000
1122                }
1123            },
1124            "rootfs": {
1125                "type": "layers",
1126                "diff_ids": ["sha256:0000000000000000000000000000000000000000000000000000000000000000"]
1127            },
1128            "history": []
1129        }"#;
1130        let config_digest = write_blob(path, config_content.as_bytes());
1131
1132        // Create layer blob (minimal tar.gz for testing).
1133        let layer_build_path = path.join("fixture-layer.tar.gz");
1134        create_test_layer(&layer_build_path);
1135        let layer_content = fs::read(&layer_build_path).unwrap();
1136        fs::remove_file(layer_build_path).unwrap();
1137        let layer_digest = write_blob(path, &layer_content);
1138
1139        // Create manifest blob
1140        let manifest_content = format!(
1141            r#"{{
1142            "schemaVersion": 2,
1143            "mediaType": "application/vnd.oci.image.manifest.v1+json",
1144            "config": {{
1145                "mediaType": "application/vnd.oci.image.config.v1+json",
1146                "digest": "{}",
1147                "size": {}
1148            }},
1149            "layers": [
1150                {{
1151                    "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1152                    "digest": "{}",
1153                    "size": {}
1154                }}
1155            ]
1156        }}"#,
1157            config_digest,
1158            config_content.len(),
1159            layer_digest,
1160            layer_content.len()
1161        );
1162        let manifest_digest = write_blob(path, manifest_content.as_bytes());
1163
1164        // Create index.json
1165        let index_content = format!(
1166            r#"{{
1167            "schemaVersion": 2,
1168            "mediaType": "application/vnd.oci.image.index.v1+json",
1169            "manifests": [
1170                {{
1171                    "mediaType": "application/vnd.oci.image.manifest.v1+json",
1172                    "digest": "{}",
1173                    "size": {}
1174                }}
1175            ]
1176        }}"#,
1177            manifest_digest,
1178            manifest_content.len()
1179        );
1180        fs::write(path.join("index.json"), index_content).unwrap();
1181
1182        CompleteLayout {
1183            manifest_digest,
1184            config_digest,
1185            layer_digest,
1186        }
1187    }
1188
1189    #[test]
1190    fn test_from_oci_config_parses_volumes() {
1191        // Directly test OciImageConfig::from_oci_config with volumes
1192        let config_json = r#"{
1193            "architecture": "amd64",
1194            "os": "linux",
1195            "config": {
1196                "Volumes": {
1197                    "/data": {},
1198                    "/var/log": {}
1199                }
1200            },
1201            "rootfs": {
1202                "type": "layers",
1203                "diff_ids": []
1204            },
1205            "history": []
1206        }"#;
1207        let oci_config: oci_spec::image::ImageConfiguration =
1208            serde_json::from_str(config_json).unwrap();
1209        let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
1210        assert_eq!(config.volumes.len(), 2);
1211        assert!(config.volumes.contains(&"/data".to_string()));
1212        assert!(config.volumes.contains(&"/var/log".to_string()));
1213    }
1214
1215    #[test]
1216    fn test_from_oci_config_no_volumes() {
1217        let config_json = r#"{
1218            "architecture": "amd64",
1219            "os": "linux",
1220            "config": {},
1221            "rootfs": {
1222                "type": "layers",
1223                "diff_ids": []
1224            },
1225            "history": []
1226        }"#;
1227        let oci_config: oci_spec::image::ImageConfiguration =
1228            serde_json::from_str(config_json).unwrap();
1229        let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
1230        assert!(config.volumes.is_empty());
1231    }
1232
1233    #[test]
1234    fn test_parse_health_check_cmd() {
1235        let raw = serde_json::json!({
1236            "config": {
1237                "Healthcheck": {
1238                    "Test": ["CMD", "curl", "-f", "http://localhost/"],
1239                    "Interval": 30000000000u64,
1240                    "Timeout": 5000000000u64,
1241                    "Retries": 3u64,
1242                    "StartPeriod": 0u64
1243                }
1244            }
1245        });
1246
1247        let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
1248        assert_eq!(hc.test, vec!["CMD", "curl", "-f", "http://localhost/"]);
1249        assert_eq!(hc.interval, Some(30));
1250        assert_eq!(hc.timeout, Some(5));
1251        assert_eq!(hc.retries, Some(3));
1252        assert_eq!(hc.start_period, None);
1253    }
1254
1255    #[test]
1256    fn test_parse_health_check_cmd_shell_and_ceil_durations() {
1257        let raw = serde_json::json!({
1258            "config": {
1259                "Healthcheck": {
1260                    "Test": ["CMD-SHELL", "wget -qO- http://localhost/health"],
1261                    "Interval": 1500000000u64,
1262                    "Timeout": "1",
1263                    "Retries": "2",
1264                    "StartPeriod": 1u64
1265                }
1266            }
1267        });
1268
1269        let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
1270        assert_eq!(
1271            hc.test,
1272            vec!["CMD-SHELL", "wget -qO- http://localhost/health"]
1273        );
1274        assert_eq!(hc.interval, Some(2));
1275        assert_eq!(hc.timeout, Some(1));
1276        assert_eq!(hc.retries, Some(2));
1277        assert_eq!(hc.start_period, Some(1));
1278    }
1279
1280    #[test]
1281    fn test_parse_health_check_none_disables() {
1282        let raw = serde_json::json!({
1283            "config": {
1284                "Healthcheck": {
1285                    "Test": ["NONE"]
1286                }
1287            }
1288        });
1289
1290        assert!(OciImage::parse_health_check_from_raw(&raw).is_none());
1291    }
1292
1293    #[test]
1294    fn health_check_enabled_semantics_match_docker_forms() {
1295        let health_check = |test: &[&str]| OciHealthCheck {
1296            test: test.iter().map(|part| (*part).to_string()).collect(),
1297            interval: None,
1298            timeout: None,
1299            retries: None,
1300            start_period: None,
1301        };
1302
1303        assert!(health_check(&["CMD", "/bin/true"]).is_enabled());
1304        assert!(health_check(&["CMD-SHELL", "test -f /ready"]).is_enabled());
1305        assert!(health_check(&["future-form", "probe"]).is_enabled());
1306        assert!(!health_check(&[]).is_enabled());
1307        assert!(!health_check(&["NONE"]).is_enabled());
1308        assert!(!health_check(&["none", "ignored"]).is_enabled());
1309        assert!(!health_check(&["CMD"]).is_enabled());
1310        assert!(!health_check(&["CMD-SHELL", "  "]).is_enabled());
1311    }
1312
1313    #[test]
1314    fn test_load_config_parses_onbuild_triggers() {
1315        // Verify that OnBuild entries in the raw OCI config JSON are parsed
1316        // and surfaced in OciImageConfig.onbuild (oci-spec does not model this field).
1317        let temp_dir = TempDir::new().unwrap();
1318        fs::create_dir_all(temp_dir.path().join("blobs/sha256")).unwrap();
1319        fs::write(
1320            temp_dir.path().join("oci-layout"),
1321            r#"{"imageLayoutVersion":"1.0.0"}"#,
1322        )
1323        .unwrap();
1324
1325        let config_content = r#"{
1326            "architecture": "amd64",
1327            "os": "linux",
1328            "config": {
1329                "OnBuild": ["RUN echo hello", "COPY . /app"]
1330            },
1331            "rootfs": {"type": "layers", "diff_ids": []},
1332            "history": []
1333        }"#;
1334        let config_digest = write_blob(temp_dir.path(), config_content.as_bytes());
1335
1336        let manifest_content = format!(
1337            r#"{{"schemaVersion":2,"config":{{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"{}","size":{}}},"layers":[]}}"#,
1338            config_digest,
1339            config_content.len()
1340        );
1341        let manifest_digest = write_blob(temp_dir.path(), manifest_content.as_bytes());
1342
1343        let index_content = format!(
1344            r#"{{"schemaVersion":2,"manifests":[{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"{}","size":{}}}]}}"#,
1345            manifest_digest,
1346            manifest_content.len()
1347        );
1348        fs::write(temp_dir.path().join("index.json"), index_content).unwrap();
1349
1350        let image = OciImage::from_path(temp_dir.path()).unwrap();
1351        assert_eq!(
1352            image.config().onbuild,
1353            vec!["RUN echo hello", "COPY . /app"]
1354        );
1355    }
1356
1357    // Helper function to create a test layer (minimal tar.gz)
1358    fn create_test_layer(path: &Path) {
1359        use flate2::write::GzEncoder;
1360        use flate2::Compression;
1361        use tar::Builder;
1362
1363        let file = fs::File::create(path).unwrap();
1364        let encoder = GzEncoder::new(file, Compression::default());
1365        let mut builder = Builder::new(encoder);
1366
1367        // Add a simple file
1368        let mut header = tar::Header::new_gnu();
1369        header.set_size(5);
1370        header.set_mode(0o644);
1371        header.set_cksum();
1372
1373        builder
1374            .append_data(&mut header, "test.txt", b"hello" as &[u8])
1375            .unwrap();
1376        builder.finish().unwrap();
1377    }
1378}