Skip to main content

tar_core/
builder.rs

1//! Builder pattern for creating tar headers.
2//!
3//! This module provides [`HeaderBuilder`] for constructing tar headers and
4//! [`PaxBuilder`] for creating PAX extended header records.
5//!
6//! # Example
7//!
8//! ```
9//! use tar_core::builder::HeaderBuilder;
10//! use tar_core::EntryType;
11//!
12//! let header = HeaderBuilder::new_ustar()
13//!     .path(b"hello.txt").unwrap()
14//!     .mode(0o644).unwrap()
15//!     .size(1024).unwrap()
16//!     .entry_type(EntryType::Regular)
17//!     .finish();
18//! ```
19
20use alloc::format;
21use alloc::string::String;
22use alloc::vec;
23use alloc::vec::Vec;
24
25use crate::{
26    EntryType, GnuExtSparseHeader, Header, HeaderError, Result, SparseEntry, UstarHeader,
27    HEADER_SIZE, PAX_ATIME, PAX_CTIME, PAX_GID, PAX_GNAME, PAX_GNU_SPARSE_MAJOR,
28    PAX_GNU_SPARSE_MINOR, PAX_GNU_SPARSE_NAME, PAX_GNU_SPARSE_REALSIZE, PAX_LINKPATH, PAX_MTIME,
29    PAX_PATH, PAX_SIZE, PAX_UID, PAX_UNAME,
30};
31
32/// Stack-allocated decimal formatter for u64.
33///
34/// Formats a u64 into a fixed-size byte buffer without allocating.
35/// Max u64 is 20 digits ("18446744073709551615").
36pub(crate) struct DecU64 {
37    buf: [u8; 20],
38    start: u8,
39}
40
41impl DecU64 {
42    pub(crate) fn new(mut value: u64) -> Self {
43        let mut buf = [0u8; 20];
44        if value == 0 {
45            buf[19] = b'0';
46            return Self { buf, start: 19 };
47        }
48        let mut pos = 20u8;
49        while value > 0 {
50            pos -= 1;
51            buf[pos as usize] = b'0' + (value % 10) as u8;
52            value /= 10;
53        }
54        Self { buf, start: pos }
55    }
56
57    pub(crate) fn as_bytes(&self) -> &[u8] {
58        &self.buf[self.start as usize..]
59    }
60}
61
62/// Write a byte slice into a fixed-size header field.
63///
64/// The const generic `N` carries the field size from the zerocopy header
65/// struct, so the compiler knows the capacity at each call site.
66///
67/// # Errors
68///
69/// Returns [`HeaderError::FieldOverflow`] if the value is too long for the field.
70fn write_bytes<const N: usize>(field: &mut [u8; N], value: &[u8]) -> Result<()> {
71    if value.len() > N {
72        return Err(HeaderError::FieldOverflow {
73            field_len: N,
74            detail: format!("{}-byte value", value.len()),
75        });
76    }
77    field.fill(0);
78    field[..value.len()].copy_from_slice(value);
79    Ok(())
80}
81
82/// Builder for creating tar headers.
83///
84/// This provides a fluent API for constructing tar headers with proper
85/// field formatting and checksum calculation.
86///
87/// # Example
88///
89/// ```
90/// use tar_core::builder::HeaderBuilder;
91/// use tar_core::EntryType;
92///
93/// let mut builder = HeaderBuilder::new_ustar();
94/// builder
95///     .path(b"example.txt").unwrap()
96///     .mode(0o644).unwrap()
97///     .uid(1000).unwrap()
98///     .gid(1000).unwrap()
99///     .size(0).unwrap()
100///     .mtime(1234567890).unwrap()
101///     .entry_type(EntryType::Regular);
102///
103/// let header = builder.finish();
104/// ```
105#[derive(Clone)]
106pub struct HeaderBuilder {
107    header: Header,
108}
109
110impl HeaderBuilder {
111    /// Create a new builder for a UStar format header.
112    #[must_use]
113    pub fn new_ustar() -> Self {
114        Self {
115            header: Header::new_ustar(),
116        }
117    }
118
119    /// Create a new builder for a GNU tar format header.
120    #[must_use]
121    pub fn new_gnu() -> Self {
122        Self {
123            header: Header::new_gnu(),
124        }
125    }
126
127    /// Mutable access to the header viewed as a UstarHeader.
128    ///
129    /// Both UStar and GNU formats share the same field layout for the
130    /// common fields (name, mode, uid, gid, size, mtime, checksum,
131    /// typeflag, linkname, uname, gname, dev_major, dev_minor).
132    fn fields_mut(&mut self) -> &mut UstarHeader {
133        self.header.as_ustar_mut()
134    }
135
136    /// Set the file path (name field, 100 bytes).
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if the path is longer than 100 bytes.
141    pub fn path(&mut self, path: impl AsRef<[u8]>) -> Result<&mut Self> {
142        write_bytes(&mut self.fields_mut().name, path.as_ref())?;
143        Ok(self)
144    }
145
146    /// Set the file mode.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`HeaderError::FieldOverflow`] if the mode exceeds the 8-byte
151    /// octal capacity (max 0o7777777 = 2,097,151).
152    pub fn mode(&mut self, mode: u32) -> Result<&mut Self> {
153        self.header.set_mode(mode)?;
154        Ok(self)
155    }
156
157    /// Set the owner user ID.
158    ///
159    /// Format-aware: GNU headers use base-256 for values exceeding the
160    /// octal range; ustar headers use octal only.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`HeaderError::FieldOverflow`] if the value cannot be
165    /// represented in the header format.
166    pub fn uid(&mut self, uid: u64) -> Result<&mut Self> {
167        self.header.set_uid(uid)?;
168        Ok(self)
169    }
170
171    /// Set the owner group ID.
172    ///
173    /// Format-aware: GNU headers use base-256 for values exceeding the
174    /// octal range; ustar headers use octal only.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`HeaderError::FieldOverflow`] if the value cannot be
179    /// represented in the header format.
180    pub fn gid(&mut self, gid: u64) -> Result<&mut Self> {
181        self.header.set_gid(gid)?;
182        Ok(self)
183    }
184
185    /// Set the file size.
186    ///
187    /// Format-aware: GNU headers use base-256 for values exceeding the
188    /// octal range; ustar headers use octal only.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`HeaderError::FieldOverflow`] if the value cannot be
193    /// represented in the header format.
194    pub fn size(&mut self, size: u64) -> Result<&mut Self> {
195        self.header.set_size(size)?;
196        Ok(self)
197    }
198
199    /// Set the modification time as a Unix timestamp.
200    ///
201    /// Format-aware: GNU headers use base-256 for values exceeding the
202    /// octal range; ustar headers use octal only.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`HeaderError::FieldOverflow`] if the value cannot be
207    /// represented in the header format.
208    pub fn mtime(&mut self, mtime: u64) -> Result<&mut Self> {
209        self.header.set_mtime(mtime)?;
210        Ok(self)
211    }
212
213    /// Set the entry type.
214    pub fn entry_type(&mut self, entry_type: EntryType) -> &mut Self {
215        self.fields_mut().typeflag[0] = entry_type.to_byte();
216        self
217    }
218
219    /// Set the link name for symbolic/hard links (100 bytes).
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the link name is longer than 100 bytes.
224    pub fn link_name(&mut self, link: impl AsRef<[u8]>) -> Result<&mut Self> {
225        write_bytes(&mut self.fields_mut().linkname, link.as_ref())?;
226        Ok(self)
227    }
228
229    /// Set the owner user name (32 bytes, UStar/GNU only).
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if the username is longer than 32 bytes.
234    pub fn username(&mut self, name: impl AsRef<[u8]>) -> Result<&mut Self> {
235        write_bytes(&mut self.fields_mut().uname, name.as_ref())?;
236        Ok(self)
237    }
238
239    /// Set the owner group name (32 bytes, UStar/GNU only).
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if the group name is longer than 32 bytes.
244    pub fn groupname(&mut self, name: impl AsRef<[u8]>) -> Result<&mut Self> {
245        write_bytes(&mut self.fields_mut().gname, name.as_ref())?;
246        Ok(self)
247    }
248
249    /// Set device major and minor numbers.
250    ///
251    /// Used for character and block device entries.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`HeaderError::FieldOverflow`] if the values don't fit in
256    /// the 8-byte octal fields (max 0o7777777 = 2,097,151).
257    pub fn device(&mut self, major: u32, minor: u32) -> Result<&mut Self> {
258        self.header.set_device(major, minor)?;
259        Ok(self)
260    }
261
262    /// Set the UStar prefix field for long paths (155 bytes).
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the prefix is longer than 155 bytes.
267    pub fn prefix(&mut self, prefix: impl AsRef<[u8]>) -> Result<&mut Self> {
268        write_bytes(&mut self.fields_mut().prefix, prefix.as_ref())?;
269        Ok(self)
270    }
271
272    /// Get a reference to the current header for inspection.
273    ///
274    /// Note: The checksum field will not be valid until [`finish`](Self::finish)
275    /// is called.
276    #[must_use]
277    pub fn as_header(&self) -> &Header {
278        &self.header
279    }
280
281    /// Get a mutable reference to the raw header.
282    ///
283    /// This is intended for direct field manipulation that the builder
284    /// doesn't expose (e.g. GNU sparse header fields).
285    pub fn as_header_mut(&mut self) -> &mut Header {
286        &mut self.header
287    }
288
289    /// Compute the checksum and return the final header.
290    ///
291    /// This fills in the checksum field and returns the complete 512-byte
292    /// header.
293    #[must_use]
294    pub fn finish(&mut self) -> Header {
295        // Fill checksum field with spaces for calculation
296        self.header.as_ustar_mut().cksum.fill(b' ');
297
298        // Compute unsigned sum of all bytes
299        let checksum: u64 = self.header.as_bytes().iter().map(|&b| u64::from(b)).sum();
300
301        // Max checksum = 512 * 255 = 130560, which always fits in 8-byte octal
302        // (max representable: 07777777 = 2097151).
303        crate::encode_octal(&mut self.header.as_ustar_mut().cksum, checksum)
304            .expect("checksum always fits in 8-byte octal field");
305
306        self.header
307    }
308}
309
310impl Default for HeaderBuilder {
311    fn default() -> Self {
312        Self::new_ustar()
313    }
314}
315
316impl core::fmt::Debug for HeaderBuilder {
317    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
318        f.debug_struct("HeaderBuilder")
319            .field("header", self.as_header())
320            .finish()
321    }
322}
323
324/// Builder for PAX extended header records.
325///
326/// PAX extended headers contain key-value pairs that extend the basic
327/// tar header format, allowing for longer paths, larger file sizes,
328/// and additional metadata.
329///
330/// # Format
331///
332/// Each record has the format: `<length> <key>=<value>\n`
333/// where `<length>` is the total record length including the length field itself.
334///
335/// # Example
336///
337/// ```
338/// use tar_core::builder::PaxBuilder;
339///
340/// let mut builder = PaxBuilder::new();
341/// builder
342///     .path(b"/very/long/path/that/exceeds/100/characters/limit.txt")
343///     .size(1_000_000_000_000);
344/// let data = builder.finish();
345/// ```
346#[derive(Clone, Default)]
347pub struct PaxBuilder {
348    data: Vec<u8>,
349}
350
351impl PaxBuilder {
352    /// Create a new empty PAX builder.
353    #[must_use]
354    pub fn new() -> Self {
355        Self { data: Vec::new() }
356    }
357
358    /// Add a key-value record.
359    ///
360    /// The record is formatted as `<length> <key>=<value>\n`.
361    ///
362    /// The length prefix includes itself, which requires computing how many
363    /// decimal digits the total length will occupy. This uses the same
364    /// algorithm as tar-rs's `append_pax_extensions`.
365    pub fn add(&mut self, key: &str, value: impl AsRef<[u8]>) -> &mut Self {
366        let value = value.as_ref();
367        // Format: "<len> <key>=<value>\n"
368        // rest_len covers: " " + key + "=" + value + "\n"
369        let rest_len = 3 + key.len() + value.len();
370
371        // The length prefix includes itself, so we need to figure out how
372        // many digits the total length will be. Start assuming 1 digit and
373        // widen until it fits.
374        let mut len_len = 1;
375        let mut max_len = 10;
376        while rest_len + len_len >= max_len {
377            len_len += 1;
378            max_len *= 10;
379        }
380        let total_len = rest_len + len_len;
381
382        let len_dec = DecU64::new(total_len as u64);
383        self.data.extend_from_slice(len_dec.as_bytes());
384        self.data.push(b' ');
385        self.data.extend_from_slice(key.as_bytes());
386        self.data.push(b'=');
387        self.data.extend_from_slice(value);
388        self.data.push(b'\n');
389
390        self
391    }
392
393    /// Add a path record.
394    pub fn path(&mut self, path: impl AsRef<[u8]>) -> &mut Self {
395        self.add(PAX_PATH, path)
396    }
397
398    /// Add a linkpath record.
399    pub fn linkpath(&mut self, path: impl AsRef<[u8]>) -> &mut Self {
400        self.add(PAX_LINKPATH, path)
401    }
402
403    /// Add a record with a u64 value formatted as decimal.
404    pub fn add_u64(&mut self, key: &str, value: u64) -> &mut Self {
405        let buf = DecU64::new(value);
406        self.add(key, buf.as_bytes())
407    }
408
409    /// Add a size record.
410    pub fn size(&mut self, size: u64) -> &mut Self {
411        self.add_u64(PAX_SIZE, size)
412    }
413
414    /// Add a uid record.
415    pub fn uid(&mut self, uid: u64) -> &mut Self {
416        self.add_u64(PAX_UID, uid)
417    }
418
419    /// Add a gid record.
420    pub fn gid(&mut self, gid: u64) -> &mut Self {
421        self.add_u64(PAX_GID, gid)
422    }
423
424    /// Add a uname (username) record.
425    pub fn uname(&mut self, name: impl AsRef<[u8]>) -> &mut Self {
426        self.add(PAX_UNAME, name)
427    }
428
429    /// Add a gname (group name) record.
430    pub fn gname(&mut self, name: impl AsRef<[u8]>) -> &mut Self {
431        self.add(PAX_GNAME, name)
432    }
433
434    /// Add an mtime record.
435    pub fn mtime(&mut self, mtime: u64) -> &mut Self {
436        self.add_u64(PAX_MTIME, mtime)
437    }
438
439    /// Add an atime record.
440    pub fn atime(&mut self, atime: u64) -> &mut Self {
441        self.add_u64(PAX_ATIME, atime)
442    }
443
444    /// Add a ctime record.
445    pub fn ctime(&mut self, ctime: u64) -> &mut Self {
446        self.add_u64(PAX_CTIME, ctime)
447    }
448
449    /// Get the current data (for inspection).
450    #[must_use]
451    pub fn as_bytes(&self) -> &[u8] {
452        &self.data
453    }
454
455    /// Return the finished PAX extended header data.
456    #[must_use]
457    pub fn finish(self) -> Vec<u8> {
458        self.data
459    }
460}
461
462impl core::fmt::Debug for PaxBuilder {
463    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
464        f.debug_struct("PaxBuilder")
465            .field("data", &String::from_utf8_lossy(&self.data))
466            .finish()
467    }
468}
469
470// ============================================================================
471// Entry Builder
472// ============================================================================
473
474/// How to handle long paths and other extensions.
475///
476/// When paths exceed 100 bytes or link targets exceed 100 bytes, tar archives
477/// use extension mechanisms to store the full values. This enum selects which
478/// mechanism to use.
479#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
480pub enum ExtensionMode {
481    /// Use GNU extensions (LongLink/LongName pseudo-entries).
482    ///
483    /// This emits a pseudo-entry with typeflag 'L' (for long names) or 'K'
484    /// (for long link targets), followed by the actual entry with a truncated
485    /// name. This is widely compatible with GNU tar.
486    #[default]
487    Gnu,
488    /// Use PAX extensions (extended headers).
489    ///
490    /// This emits a PAX extended header (typeflag 'x') containing the full
491    /// path/linkpath, followed by the actual entry. This is the POSIX.1-2001
492    /// standard approach.
493    Pax,
494}
495
496/// Maximum length for the name field in a tar header.
497pub const NAME_MAX_LEN: usize = 100;
498
499/// Maximum length for the linkname field in a tar header.
500pub const LINKNAME_MAX_LEN: usize = 100;
501
502/// The canonical name used for GNU long link/name pseudo-entries.
503const GNU_LONGLINK_NAME: &[u8] = b"././@LongLink";
504
505/// Builder for complete tar entries including extension headers.
506///
507/// This handles the complexity of emitting multiple headers when paths
508/// or link targets exceed the 100-byte limit. It supports both GNU
509/// (LongLink/LongName) and PAX extension mechanisms.
510///
511/// # Sans-IO Design
512///
513/// This builder does not perform any I/O. It produces `Vec<Header>` blocks
514/// or contiguous `Vec<u8>` that can be written to any output.
515///
516/// # Extension Handling
517///
518/// - **Short paths** (≤100 bytes): Single header, no extensions needed
519/// - **Long paths** (>100 bytes): Extension header + data blocks + main header
520/// - **Long link targets**: Same as long paths, using appropriate extension
521///
522/// # Example
523///
524/// ```
525/// use tar_core::builder::{EntryBuilder, ExtensionMode};
526/// use tar_core::EntryType;
527///
528/// // Create a simple entry (short path)
529/// let mut builder = EntryBuilder::new_gnu();
530/// builder
531///     .path(b"hello.txt")
532///     .mode(0o644).unwrap()
533///     .size(1024).unwrap()
534///     .entry_type(EntryType::Regular);
535/// let blocks = builder.finish();
536/// assert_eq!(blocks.len(), 1); // Just one header block
537///
538/// // Create an entry with a long path
539/// let long_path = "a/".repeat(60) + "file.txt";
540/// let mut builder = EntryBuilder::new_gnu();
541/// builder
542///     .path(long_path.as_bytes())
543///     .mode(0o644).unwrap()
544///     .size(0).unwrap()
545///     .entry_type(EntryType::Regular);
546/// let blocks = builder.finish();
547/// assert!(blocks.len() > 1); // Extension header(s) + main header
548/// ```
549#[derive(Clone)]
550pub struct EntryBuilder {
551    /// The primary header builder.
552    header: HeaderBuilder,
553    /// Long path (if > 100 bytes).
554    long_path: Option<Vec<u8>>,
555    /// Long link target (if > 100 bytes).
556    long_link: Option<Vec<u8>>,
557    /// PAX extensions builder (used when mode is Pax).
558    pax: Option<PaxBuilder>,
559    /// Extension mode preference.
560    mode: ExtensionMode,
561    /// Sparse file map, if this is a sparse entry.
562    sparse: Option<SparseInfo>,
563}
564
565/// Sparse file metadata for the builder.
566#[derive(Clone)]
567struct SparseInfo {
568    /// The sparse data map: regions of real data within the logical file.
569    map: Vec<SparseEntry>,
570    /// Logical file size (the file appears this large to readers).
571    real_size: u64,
572}
573
574impl EntryBuilder {
575    /// Create a new builder using GNU tar format for the underlying header.
576    ///
577    /// This sets the extension mode to GNU (LongLink/LongName).
578    #[must_use]
579    pub fn new_gnu() -> Self {
580        Self {
581            header: HeaderBuilder::new_gnu(),
582            long_path: None,
583            long_link: None,
584            pax: None,
585            mode: ExtensionMode::Gnu,
586            sparse: None,
587        }
588    }
589
590    /// Create a new builder using UStar format for the underlying header.
591    ///
592    /// This sets the extension mode to PAX (extended headers).
593    #[must_use]
594    pub fn new_ustar() -> Self {
595        Self {
596            header: HeaderBuilder::new_ustar(),
597            long_path: None,
598            long_link: None,
599            pax: None,
600            mode: ExtensionMode::Pax,
601            sparse: None,
602        }
603    }
604
605    /// Create a new builder with explicit format and extension mode.
606    #[must_use]
607    pub fn with_mode(header: HeaderBuilder, mode: ExtensionMode) -> Self {
608        Self {
609            header,
610            long_path: None,
611            long_link: None,
612            pax: None,
613            mode,
614            sparse: None,
615        }
616    }
617
618    /// Get the current extension mode.
619    #[must_use]
620    pub fn extension_mode(&self) -> ExtensionMode {
621        self.mode
622    }
623
624    /// Set the extension mode.
625    pub fn set_extension_mode(&mut self, mode: ExtensionMode) -> &mut Self {
626        self.mode = mode;
627        self
628    }
629
630    /// Set the file path.
631    ///
632    /// If the path exceeds 100 bytes, it will be stored using the configured
633    /// extension mechanism (GNU or PAX). The main header's name field will
634    /// contain a truncated version (first 100 bytes, matching GNU tar).
635    pub fn path(&mut self, path: impl AsRef<[u8]>) -> &mut Self {
636        let path = path.as_ref();
637        if path.len() > NAME_MAX_LEN {
638            self.long_path = Some(path.to_vec());
639            // Store the first 100 bytes in the main header for compatibility
640            // (matches GNU tar behavior)
641            let truncated = &path[..NAME_MAX_LEN];
642            self.header
643                .path(truncated)
644                .expect("truncated path fits in name field");
645        } else {
646            self.long_path = None;
647            self.header
648                .path(path)
649                .expect("path within NAME_MAX_LEN fits in name field");
650        }
651        self
652    }
653
654    /// Set the link target for symbolic/hard links.
655    ///
656    /// If the link target exceeds 100 bytes, it will be stored using the
657    /// configured extension mechanism.
658    pub fn link_name(&mut self, link: impl AsRef<[u8]>) -> &mut Self {
659        let link = link.as_ref();
660        if link.len() > LINKNAME_MAX_LEN {
661            self.long_link = Some(link.to_vec());
662            let truncated = &link[..LINKNAME_MAX_LEN];
663            self.header
664                .link_name(truncated)
665                .expect("truncated link fits in linkname field");
666        } else {
667            self.long_link = None;
668            self.header
669                .link_name(link)
670                .expect("link within LINKNAME_MAX_LEN fits in linkname field");
671        }
672        self
673    }
674
675    /// Set the file mode (permissions).
676    ///
677    /// # Errors
678    ///
679    /// Returns an error if the mode exceeds the maximum value for the 7-digit
680    /// octal field (0o7777777 / 2097151). Standard Unix permission modes
681    /// (up to 0o7777) always fit.
682    pub fn mode(&mut self, mode: u32) -> Result<&mut Self> {
683        self.header.mode(mode)?;
684        Ok(self)
685    }
686
687    /// Set the owner user ID.
688    ///
689    /// If the value overflows the header field and this builder uses PAX
690    /// extensions, the value is stored as a PAX record instead (with 0
691    /// written to the header field for compatibility).
692    ///
693    /// # Errors
694    ///
695    /// Returns an error only if the value overflows and PAX fallback is
696    /// not available (GNU mode with value >= 2^63).
697    pub fn uid(&mut self, uid: u64) -> Result<&mut Self> {
698        match self.header.uid(uid) {
699            Ok(_) => Ok(self),
700            Err(_) if self.mode == ExtensionMode::Pax => {
701                self.header.uid(0).expect("zero fits");
702                self.pax_mut().uid(uid);
703                Ok(self)
704            }
705            Err(e) => Err(e),
706        }
707    }
708
709    /// Set the owner group ID.
710    ///
711    /// If the value overflows the header field and this builder uses PAX
712    /// extensions, the value is stored as a PAX record instead (with 0
713    /// written to the header field for compatibility).
714    ///
715    /// # Errors
716    ///
717    /// Returns an error only if the value overflows and PAX fallback is
718    /// not available (GNU mode with value >= 2^63).
719    pub fn gid(&mut self, gid: u64) -> Result<&mut Self> {
720        match self.header.gid(gid) {
721            Ok(_) => Ok(self),
722            Err(_) if self.mode == ExtensionMode::Pax => {
723                self.header.gid(0).expect("zero fits");
724                self.pax_mut().gid(gid);
725                Ok(self)
726            }
727            Err(e) => Err(e),
728        }
729    }
730
731    /// Set the file size.
732    ///
733    /// If the value overflows the header field and this builder uses PAX
734    /// extensions, the value is stored as a PAX record instead (with 0
735    /// written to the header field for compatibility).
736    ///
737    /// # Errors
738    ///
739    /// Returns an error only if the value overflows and PAX fallback is
740    /// not available (GNU mode with value >= 2^63).
741    pub fn size(&mut self, size: u64) -> Result<&mut Self> {
742        match self.header.size(size) {
743            Ok(_) => Ok(self),
744            Err(_) if self.mode == ExtensionMode::Pax => {
745                self.header.size(0).expect("zero fits");
746                self.pax_mut().size(size);
747                Ok(self)
748            }
749            Err(e) => Err(e),
750        }
751    }
752
753    /// Set the modification time as a Unix timestamp.
754    ///
755    /// If the value overflows the header field and this builder uses PAX
756    /// extensions, the value is stored as a PAX record instead (with 0
757    /// written to the header field for compatibility).
758    ///
759    /// # Errors
760    ///
761    /// Returns an error only if the value overflows and PAX fallback is
762    /// not available (GNU mode with value >= 2^63).
763    pub fn mtime(&mut self, mtime: u64) -> Result<&mut Self> {
764        match self.header.mtime(mtime) {
765            Ok(_) => Ok(self),
766            Err(_) if self.mode == ExtensionMode::Pax => {
767                self.header.mtime(0).expect("zero fits");
768                self.pax_mut().mtime(mtime);
769                Ok(self)
770            }
771            Err(e) => Err(e),
772        }
773    }
774
775    /// Set the entry type.
776    pub fn entry_type(&mut self, entry_type: EntryType) -> &mut Self {
777        self.header.entry_type(entry_type);
778        self
779    }
780
781    /// Set the owner user name.
782    ///
783    /// If the name exceeds the 32-byte header field and this builder uses
784    /// PAX extensions, the full name is stored as a PAX `uname` record
785    /// (with the header field zeroed for compatibility).
786    ///
787    /// # Errors
788    ///
789    /// Returns an error only if the name overflows and PAX fallback is
790    /// not available (GNU mode).
791    pub fn username(&mut self, name: impl AsRef<[u8]>) -> Result<&mut Self> {
792        let name = name.as_ref();
793        match self.header.username(name) {
794            Ok(_) => Ok(self),
795            Err(_) if self.mode == ExtensionMode::Pax => {
796                // Zero the header field and store full name in PAX
797                self.header.username([]).expect("empty fits");
798                self.pax_mut().uname(name);
799                Ok(self)
800            }
801            Err(e) => Err(e),
802        }
803    }
804
805    /// Set the owner group name.
806    ///
807    /// If the name exceeds the 32-byte header field and this builder uses
808    /// PAX extensions, the full name is stored as a PAX `gname` record
809    /// (with the header field zeroed for compatibility).
810    ///
811    /// # Errors
812    ///
813    /// Returns an error only if the name overflows and PAX fallback is
814    /// not available (GNU mode).
815    pub fn groupname(&mut self, name: impl AsRef<[u8]>) -> Result<&mut Self> {
816        let name = name.as_ref();
817        match self.header.groupname(name) {
818            Ok(_) => Ok(self),
819            Err(_) if self.mode == ExtensionMode::Pax => {
820                self.header.groupname([]).expect("empty fits");
821                self.pax_mut().gname(name);
822                Ok(self)
823            }
824            Err(e) => Err(e),
825        }
826    }
827
828    /// Set device major and minor numbers.
829    ///
830    /// Used for character and block device entries.
831    ///
832    /// # Errors
833    ///
834    /// Returns an error if the values don't fit in the header fields.
835    pub fn device(&mut self, major: u32, minor: u32) -> Result<&mut Self> {
836        self.header.device(major, minor)?;
837        Ok(self)
838    }
839
840    /// Mark this entry as a sparse file.
841    ///
842    /// The `sparse_map` describes which regions of the logical file contain
843    /// real data — gaps are implicitly zero-filled. The `real_size` is the
844    /// logical file size as seen by readers.
845    ///
846    /// The caller must also call [`size()`](Self::size) with the on-disk
847    /// content size (the sum of all sparse entry lengths).
848    ///
849    /// On [`finish()`](Self::finish), the builder emits format-appropriate
850    /// sparse metadata:
851    /// - **GNU mode**: Sets entry type to `GnuSparse`, writes inline
852    ///   descriptors and extension blocks, sets `realsize` in the GNU header.
853    /// - **PAX mode**: Adds `GNU.sparse.*` PAX extensions (v1.0 format)
854    ///   and emits the sparse map as a data prefix block.
855    pub fn sparse(&mut self, sparse_map: &[SparseEntry], real_size: u64) -> &mut Self {
856        self.sparse = Some(SparseInfo {
857            map: sparse_map.to_vec(),
858            real_size,
859        });
860        self
861    }
862
863    /// Add a custom PAX extension record.
864    ///
865    /// This is useful for adding metadata that doesn't fit in standard
866    /// header fields. The PAX extension will be emitted regardless of
867    /// the extension mode setting.
868    pub fn add_pax(&mut self, key: &str, value: impl AsRef<[u8]>) -> &mut Self {
869        self.pax_mut().add(key, value);
870        self
871    }
872
873    /// Get or create the PAX builder for this entry.
874    fn pax_mut(&mut self) -> &mut PaxBuilder {
875        self.pax.get_or_insert_with(PaxBuilder::new)
876    }
877
878    /// Get a reference to the underlying header builder.
879    #[must_use]
880    pub fn header(&self) -> &HeaderBuilder {
881        &self.header
882    }
883
884    /// Get a mutable reference to the underlying header builder.
885    pub fn header_mut(&mut self) -> &mut HeaderBuilder {
886        &mut self.header
887    }
888
889    /// Check if this entry requires extension headers.
890    #[must_use]
891    pub fn needs_extension(&self) -> bool {
892        self.long_path.is_some() || self.long_link.is_some() || self.pax.is_some()
893    }
894
895    /// Build the complete header sequence as a vector of 512-byte blocks.
896    ///
897    /// Returns all blocks needed for this entry's headers:
898    /// - For short paths: just the main header (1 block)
899    /// - For GNU long paths: LongName header + data blocks + main header
900    /// - For PAX: extended header + data blocks + main header
901    /// - For GNU sparse: above + extension blocks after the main header
902    /// - For PAX sparse: PAX header includes `GNU.sparse.*` keys, plus
903    ///   a sparse map data prefix block after the main header
904    #[must_use]
905    pub fn finish(&mut self) -> Vec<Header> {
906        let sparse = self.sparse.take();
907        let mut blocks = Vec::new();
908
909        // Pre-compute the PAX sparse map data (if applicable) so we can
910        // both adjust the header size and emit it after the main header
911        // without redundant work.
912        let pax_sparse_map_data: Option<Vec<u8>> = match (&sparse, self.mode) {
913            (Some(si), ExtensionMode::Pax) => Some(Self::build_sparse_map_data(si)),
914            _ => None,
915        };
916
917        match self.mode {
918            ExtensionMode::Gnu => {
919                // For GNU sparse, set entry type and write inline descriptors.
920                if let Some(ref si) = sparse {
921                    self.header.entry_type(EntryType::GnuSparse);
922                    if let Some(gnu) = self.header.as_header_mut().try_as_gnu_mut() {
923                        gnu.set_real_size(si.real_size);
924                        for (i, entry) in si.map.iter().take(4).enumerate() {
925                            gnu.sparse[i].set(entry);
926                        }
927                        gnu.set_is_extended(si.map.len() > 4);
928                    }
929                }
930
931                // Emit GNU LongLink for long link targets first
932                if let Some(ref long_link) = self.long_link {
933                    self.emit_gnu_long_entry(&mut blocks, EntryType::GnuLongLink, long_link);
934                }
935
936                // Emit GNU LongName for long paths
937                if let Some(ref long_path) = self.long_path {
938                    self.emit_gnu_long_entry(&mut blocks, EntryType::GnuLongName, long_path);
939                }
940            }
941            ExtensionMode::Pax => {
942                // For PAX sparse v1.0, add sparse PAX extensions and adjust
943                // the header size to include the sparse map data prefix.
944                if let Some(ref si) = sparse {
945                    let map_padded = pax_sparse_map_data
946                        .as_ref()
947                        .unwrap()
948                        .len()
949                        .next_multiple_of(HEADER_SIZE);
950
951                    // Add the map prefix size to the header's size field.
952                    // The caller already set size() to on_disk content size;
953                    // we add the padded map prefix on top.
954                    let current_size = self.header.as_header().entry_size().unwrap_or(0);
955                    self.header
956                        .size(current_size + map_padded as u64)
957                        .expect("adjusted size fits");
958
959                    let real_size_str = DecU64::new(si.real_size);
960                    self.pax_mut().add(PAX_GNU_SPARSE_MAJOR, b"1");
961                    self.pax_mut().add(PAX_GNU_SPARSE_MINOR, b"0");
962                    self.pax_mut()
963                        .add(PAX_GNU_SPARSE_REALSIZE, real_size_str.as_bytes());
964
965                    // The real path goes into GNU.sparse.name; the header
966                    // gets a synthetic path.
967                    let real_path = self
968                        .long_path
969                        .take()
970                        .unwrap_or_else(|| self.header.as_header().path_bytes().to_vec());
971                    self.pax_mut().add(PAX_GNU_SPARSE_NAME, &real_path);
972
973                    // Set a synthetic path in the header (following Go's convention).
974                    let synthetic = b"GNUSparseFile.0/placeholder";
975                    self.header
976                        .path(synthetic)
977                        .expect("synthetic sparse path fits");
978                }
979
980                // Build PAX data with long path/link if needed
981                let pax_data = self.build_pax_data();
982                if !pax_data.is_empty() {
983                    self.emit_pax_entry(&mut blocks, &pax_data);
984                }
985            }
986        }
987
988        // Emit the main header (recomputes checksum)
989        let main_header = self.header.finish();
990        blocks.push(main_header);
991
992        // Emit format-specific sparse metadata after the main header.
993        if let Some(ref si) = sparse {
994            match self.mode {
995                ExtensionMode::Gnu => {
996                    // GNU sparse extension blocks for entries beyond the
997                    // first 4 inline descriptors.
998                    self.emit_gnu_sparse_ext_blocks(&mut blocks, si);
999                }
1000                ExtensionMode::Pax => {
1001                    // PAX v1.0 sparse map data prefix (padded to 512 bytes).
1002                    let map_data = pax_sparse_map_data.as_ref().unwrap();
1003                    let map_padded = map_data.len().next_multiple_of(HEADER_SIZE);
1004                    let mut buf = vec![0u8; map_padded];
1005                    buf[..map_data.len()].copy_from_slice(map_data);
1006                    for chunk in buf.chunks_exact(HEADER_SIZE) {
1007                        blocks.push(*Header::from_bytes(
1008                            chunk.try_into().expect("chunks_exact guarantees size"),
1009                        ));
1010                    }
1011                }
1012            }
1013        }
1014
1015        blocks
1016    }
1017
1018    /// Build the complete header sequence as contiguous bytes.
1019    ///
1020    /// This is a convenience method that flattens the block vector.
1021    #[must_use]
1022    pub fn finish_bytes(&mut self) -> Vec<u8> {
1023        let blocks = self.finish();
1024        let mut out = Vec::with_capacity(blocks.len() * HEADER_SIZE);
1025        for block in &blocks {
1026            out.extend_from_slice(block.as_bytes());
1027        }
1028        out
1029    }
1030
1031    /// Emit a GNU LongLink/LongName pseudo-entry.
1032    fn emit_gnu_long_entry(&self, blocks: &mut Vec<Header>, entry_type: EntryType, data: &[u8]) {
1033        // The data is null-terminated in GNU format
1034        let data_with_null_len = data.len() + 1;
1035
1036        // Build the header for the pseudo-entry.
1037        // All these values are constants or small enough to always fit.
1038        let mut ext_header = HeaderBuilder::new_gnu();
1039        ext_header
1040            .path(GNU_LONGLINK_NAME)
1041            .expect("GNU longlink name fits");
1042        ext_header.mode(0).expect("zero fits");
1043        ext_header.uid(0).expect("zero fits");
1044        ext_header.gid(0).expect("zero fits");
1045        ext_header
1046            .size(data_with_null_len as u64)
1047            .expect("extension data size fits");
1048        ext_header.mtime(0).expect("zero fits");
1049        ext_header.entry_type(entry_type);
1050
1051        blocks.push(ext_header.finish());
1052
1053        // Emit data blocks (null-terminated, padded to 512 bytes)
1054        let num_data_blocks = data_with_null_len.div_ceil(HEADER_SIZE);
1055        let mut data_buf = vec![0u8; num_data_blocks * HEADER_SIZE];
1056        data_buf[..data.len()].copy_from_slice(data);
1057        // Null terminator is already in place (vec initialized to 0)
1058
1059        for chunk in data_buf.chunks_exact(HEADER_SIZE) {
1060            blocks.push(*Header::from_bytes(
1061                chunk.try_into().expect("chunks_exact guarantees size"),
1062            ));
1063        }
1064    }
1065
1066    /// Build PAX extension data for long paths/links and custom extensions.
1067    fn build_pax_data(&self) -> Vec<u8> {
1068        let mut pax = self.pax.clone().unwrap_or_default();
1069
1070        if let Some(ref long_path) = self.long_path {
1071            pax.path(long_path);
1072        }
1073
1074        if let Some(ref long_link) = self.long_link {
1075            pax.linkpath(long_link);
1076        }
1077
1078        pax.finish()
1079    }
1080
1081    /// Emit a PAX extended header entry.
1082    fn emit_pax_entry(&self, blocks: &mut Vec<Header>, pax_data: &[u8]) {
1083        // Build a name for the PAX header (following tar conventions)
1084        // Format: "PaxHeaders.0/<truncated_name>"
1085        let pax_name = self.build_pax_header_name();
1086
1087        // Build the PAX header.
1088        // All these values are constants or small enough to always fit.
1089        let mut pax_header = HeaderBuilder::new_ustar();
1090        pax_header.path(&pax_name).expect("PAX header name fits");
1091        pax_header.mode(0o644).expect("mode 0644 fits");
1092        pax_header.uid(0).expect("zero fits");
1093        pax_header.gid(0).expect("zero fits");
1094        pax_header
1095            .size(pax_data.len() as u64)
1096            .expect("PAX data size fits");
1097        pax_header.mtime(0).expect("zero fits");
1098        pax_header.entry_type(EntryType::XHeader);
1099
1100        blocks.push(pax_header.finish());
1101
1102        // Emit data blocks (padded to 512 bytes)
1103        let num_data_blocks = pax_data.len().div_ceil(HEADER_SIZE);
1104        let mut data_buf = vec![0u8; num_data_blocks * HEADER_SIZE];
1105        data_buf[..pax_data.len()].copy_from_slice(pax_data);
1106
1107        for chunk in data_buf.chunks_exact(HEADER_SIZE) {
1108            blocks.push(*Header::from_bytes(
1109                chunk.try_into().expect("chunks_exact guarantees size"),
1110            ));
1111        }
1112    }
1113
1114    /// Build the name for a PAX extended header.
1115    fn build_pax_header_name(&self) -> Vec<u8> {
1116        // Get the base name from the header's current path
1117        let path = self.header.as_header().path_bytes();
1118        let base_name = path.rsplit(|&b| b == b'/').next().unwrap_or(path);
1119
1120        // Build: "PaxHeaders.0/<basename>" (truncated to fit)
1121        let mut name = b"PaxHeaders.0/".to_vec();
1122        let remaining = NAME_MAX_LEN.saturating_sub(name.len());
1123        let truncated_base = &base_name[..remaining.min(base_name.len())];
1124        name.extend_from_slice(truncated_base);
1125
1126        name
1127    }
1128
1129    /// Build the PAX v1.0 sparse map data prefix.
1130    ///
1131    /// Format: `<count>\n<offset>\n<length>\n...` (not yet padded).
1132    fn build_sparse_map_data(si: &SparseInfo) -> Vec<u8> {
1133        let mut data = format!("{}\n", si.map.len());
1134        for entry in &si.map {
1135            data.push_str(&format!("{}\n{}\n", entry.offset, entry.length));
1136        }
1137        data.into_bytes()
1138    }
1139
1140    /// Emit GNU sparse extension blocks for entries beyond the first 4.
1141    fn emit_gnu_sparse_ext_blocks(&self, blocks: &mut Vec<Header>, si: &SparseInfo) {
1142        if si.map.len() <= 4 {
1143            return;
1144        }
1145
1146        let remaining = &si.map[4..];
1147        let chunks: Vec<&[SparseEntry]> = remaining.chunks(21).collect();
1148
1149        for (i, chunk) in chunks.iter().enumerate() {
1150            let is_last = i == chunks.len() - 1;
1151            let mut ext = GnuExtSparseHeader::default();
1152            for (j, entry) in chunk.iter().enumerate() {
1153                ext.sparse[j].set(entry);
1154            }
1155            ext.set_is_extended(!is_last);
1156
1157            // Convert the extension block to a Header-sized block.
1158            let ext_bytes = zerocopy::IntoBytes::as_bytes(&ext);
1159            blocks.push(*Header::from_bytes(
1160                ext_bytes
1161                    .try_into()
1162                    .expect("GnuExtSparseHeader is 512 bytes"),
1163            ));
1164        }
1165    }
1166}
1167
1168impl Default for EntryBuilder {
1169    fn default() -> Self {
1170        Self::new_gnu()
1171    }
1172}
1173
1174impl core::fmt::Debug for EntryBuilder {
1175    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1176        f.debug_struct("EntryBuilder")
1177            .field("mode", &self.mode)
1178            .field("needs_extension", &self.needs_extension())
1179            .field("long_path_len", &self.long_path.as_ref().map(|p| p.len()))
1180            .field("long_link_len", &self.long_link.as_ref().map(|l| l.len()))
1181            .field("header", &self.header)
1182            .finish()
1183    }
1184}
1185
1186/// Calculate the number of 512-byte blocks needed to store `size` bytes.
1187///
1188/// This is useful for calculating content block counts.
1189#[must_use]
1190pub const fn blocks_for_size(size: u64) -> u64 {
1191    size.div_ceil(HEADER_SIZE as u64)
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197    use crate::PaxExtensions;
1198
1199    #[test]
1200    fn test_write_bytes() {
1201        let mut field = [0u8; 10];
1202
1203        // Normal case
1204        write_bytes(&mut field, b"hello").unwrap();
1205        assert_eq!(&field[..5], b"hello");
1206        assert_eq!(field[5..], [0, 0, 0, 0, 0]);
1207
1208        // Exact fit
1209        write_bytes(&mut field, b"0123456789").unwrap();
1210        assert_eq!(&field, b"0123456789");
1211
1212        // Too long
1213        assert!(write_bytes(&mut field, b"12345678901").is_err());
1214    }
1215
1216    #[test]
1217    fn test_encode_octal() {
1218        // 8-byte field (like mode)
1219        let mut field = [0u8; 8];
1220
1221        crate::encode_octal(&mut field, 0o644).unwrap();
1222        assert_eq!(&field, b"0000644\0");
1223
1224        crate::encode_octal(&mut field, 0o755).unwrap();
1225        assert_eq!(&field, b"0000755\0");
1226
1227        crate::encode_octal(&mut field, 0).unwrap();
1228        assert_eq!(&field, b"0000000\0");
1229
1230        // 12-byte field (like size)
1231        let mut field12 = [0u8; 12];
1232        crate::encode_octal(&mut field12, 0o77777777777).unwrap();
1233        assert_eq!(&field12, b"77777777777\0");
1234
1235        // Test max value that fits
1236        crate::encode_octal(&mut field, 0o7777777).unwrap();
1237        assert_eq!(&field, b"7777777\0");
1238    }
1239
1240    #[test]
1241    fn test_encode_octal_overflow() {
1242        let mut field = [0u8; 8];
1243        // Value too large for 7 octal digits
1244        assert!(crate::encode_octal(&mut field, 0o100000000).is_err());
1245    }
1246
1247    #[test]
1248    fn test_header_builder_basic() {
1249        let builder = HeaderBuilder::new_ustar();
1250        let header = builder.as_header();
1251        assert!(header.is_ustar());
1252        assert!(!header.is_gnu());
1253    }
1254
1255    #[test]
1256    fn test_header_builder_gnu() {
1257        let builder = HeaderBuilder::new_gnu();
1258        let header = builder.as_header();
1259        assert!(header.is_gnu());
1260        assert!(!header.is_ustar());
1261    }
1262
1263    #[test]
1264    fn test_header_builder_file() {
1265        let mut builder = HeaderBuilder::new_ustar();
1266        builder
1267            .path(b"test.txt")
1268            .unwrap()
1269            .mode(0o644)
1270            .unwrap()
1271            .uid(1000)
1272            .unwrap()
1273            .gid(1000)
1274            .unwrap()
1275            .size(1024)
1276            .unwrap()
1277            .mtime(1234567890)
1278            .unwrap()
1279            .entry_type(EntryType::Regular)
1280            .username(b"user")
1281            .unwrap()
1282            .groupname(b"group")
1283            .unwrap();
1284
1285        let header = builder.finish();
1286
1287        assert_eq!(header.path_bytes(), b"test.txt");
1288        assert_eq!(header.mode().unwrap(), 0o644);
1289        assert_eq!(header.uid().unwrap(), 1000);
1290        assert_eq!(header.gid().unwrap(), 1000);
1291        assert_eq!(header.entry_size().unwrap(), 1024);
1292        assert_eq!(header.mtime().unwrap(), 1234567890);
1293        assert_eq!(header.entry_type(), EntryType::Regular);
1294        assert_eq!(header.username().unwrap(), b"user");
1295        assert_eq!(header.groupname().unwrap(), b"group");
1296
1297        // Verify checksum
1298        assert!(header.verify_checksum().is_ok());
1299    }
1300
1301    #[test]
1302    fn test_header_builder_symlink() {
1303        let mut builder = HeaderBuilder::new_ustar();
1304        builder
1305            .path(b"link")
1306            .unwrap()
1307            .mode(0o777)
1308            .unwrap()
1309            .entry_type(EntryType::Symlink)
1310            .link_name(b"target")
1311            .unwrap()
1312            .size(0)
1313            .unwrap()
1314            .mtime(0)
1315            .unwrap()
1316            .uid(0)
1317            .unwrap()
1318            .gid(0)
1319            .unwrap();
1320
1321        let header = builder.finish();
1322
1323        assert_eq!(header.path_bytes(), b"link");
1324        assert_eq!(header.entry_type(), EntryType::Symlink);
1325        assert_eq!(header.link_name_bytes(), b"target");
1326        assert!(header.verify_checksum().is_ok());
1327    }
1328
1329    #[test]
1330    fn test_header_builder_directory() {
1331        let mut builder = HeaderBuilder::new_ustar();
1332        builder
1333            .path(b"mydir/")
1334            .unwrap()
1335            .mode(0o755)
1336            .unwrap()
1337            .entry_type(EntryType::Directory)
1338            .size(0)
1339            .unwrap()
1340            .mtime(0)
1341            .unwrap()
1342            .uid(0)
1343            .unwrap()
1344            .gid(0)
1345            .unwrap();
1346
1347        let header = builder.finish();
1348
1349        assert_eq!(header.entry_type(), EntryType::Directory);
1350        assert!(header.verify_checksum().is_ok());
1351    }
1352
1353    #[test]
1354    fn test_header_builder_device() {
1355        let mut builder = HeaderBuilder::new_ustar();
1356        builder
1357            .path(b"null")
1358            .unwrap()
1359            .mode(0o666)
1360            .unwrap()
1361            .entry_type(EntryType::Char)
1362            .device(1, 3)
1363            .unwrap()
1364            .size(0)
1365            .unwrap()
1366            .mtime(0)
1367            .unwrap()
1368            .uid(0)
1369            .unwrap()
1370            .gid(0)
1371            .unwrap();
1372
1373        let header = builder.finish();
1374
1375        assert_eq!(header.entry_type(), EntryType::Char);
1376        assert_eq!(header.device_major().unwrap(), Some(1));
1377        assert_eq!(header.device_minor().unwrap(), Some(3));
1378        assert!(header.verify_checksum().is_ok());
1379    }
1380
1381    #[test]
1382    fn test_pax_builder_basic() {
1383        let mut builder = PaxBuilder::new();
1384        builder.add("key", b"value").add("another", b"test");
1385        let data = builder.finish();
1386
1387        // Parse it back
1388        let mut iter = PaxExtensions::new(&data);
1389
1390        let ext1 = iter.next().unwrap().unwrap();
1391        assert_eq!(ext1.key().unwrap(), "key");
1392        assert_eq!(ext1.value().unwrap(), "value");
1393
1394        let ext2 = iter.next().unwrap().unwrap();
1395        assert_eq!(ext2.key().unwrap(), "another");
1396        assert_eq!(ext2.value().unwrap(), "test");
1397
1398        assert!(iter.next().is_none());
1399    }
1400
1401    #[test]
1402    fn test_pax_builder_path() {
1403        let long_path = b"/very/long/path/that/exceeds/one/hundred/characters/which/is/the/limit/for/the/standard/tar/name/field.txt";
1404
1405        let mut builder = PaxBuilder::new();
1406        builder.path(long_path);
1407        let data = builder.finish();
1408
1409        let ext = PaxExtensions::new(&data).next().unwrap().unwrap();
1410        assert_eq!(ext.key().unwrap(), "path");
1411        assert_eq!(ext.value_bytes(), long_path);
1412    }
1413
1414    #[test]
1415    fn test_pax_builder_size() {
1416        let mut builder = PaxBuilder::new();
1417        builder.size(1_000_000_000_000);
1418        let data = builder.finish();
1419
1420        let exts = PaxExtensions::new(&data);
1421        assert_eq!(exts.get_u64("size"), Some(1_000_000_000_000));
1422    }
1423
1424    #[test]
1425    fn test_pax_builder_multiple() {
1426        let mut builder = PaxBuilder::new();
1427        builder
1428            .path(b"/some/path")
1429            .uid(65534)
1430            .gid(65534)
1431            .uname(b"nobody")
1432            .gname(b"nogroup")
1433            .mtime(1700000000);
1434        let data = builder.finish();
1435
1436        let exts = PaxExtensions::new(&data);
1437        assert_eq!(exts.get("path"), Some("/some/path"));
1438        assert_eq!(exts.get_u64("uid"), Some(65534));
1439        assert_eq!(exts.get_u64("gid"), Some(65534));
1440        assert_eq!(exts.get("uname"), Some("nobody"));
1441        assert_eq!(exts.get("gname"), Some("nogroup"));
1442        assert_eq!(exts.get_u64("mtime"), Some(1700000000));
1443    }
1444
1445    #[test]
1446    fn test_pax_record_length_calculation() {
1447        // Test edge cases for length calculation
1448        // Record "9 k=v\n" has length 6, but we write "9" which is 1 digit
1449        // Actually need "6 k=v\n" which is length 6 - that works!
1450
1451        let mut builder = PaxBuilder::new();
1452        builder.add("k", b"v");
1453        let data = builder.finish();
1454        assert_eq!(&data, b"6 k=v\n");
1455
1456        // Longer key/value
1457        let mut builder = PaxBuilder::new();
1458        builder.add("path", b"/a/b/c/d/e/f");
1459        let data = builder.finish();
1460        // "XX path=/a/b/c/d/e/f\n" where XX is the length
1461        // Base: " path=/a/b/c/d/e/f\n" = 1 + 4 + 1 + 12 + 1 = 19
1462        // With "19": total 21, but we wrote 19... need 21
1463        // With "21": total 21, works!
1464        assert!(data.starts_with(b"21 path="));
1465    }
1466
1467    #[test]
1468    fn test_roundtrip() {
1469        // Build a header, serialize it, parse it back, verify fields match
1470        let mut builder = HeaderBuilder::new_ustar();
1471        builder
1472            .path(b"roundtrip_test.txt")
1473            .unwrap()
1474            .mode(0o755)
1475            .unwrap()
1476            .uid(1001)
1477            .unwrap()
1478            .gid(1002)
1479            .unwrap()
1480            .size(4096)
1481            .unwrap()
1482            .mtime(1609459200)
1483            .unwrap()
1484            .entry_type(EntryType::Regular)
1485            .username(b"testuser")
1486            .unwrap()
1487            .groupname(b"testgroup")
1488            .unwrap();
1489
1490        // Parse it back
1491        let parsed = builder.finish();
1492
1493        // Verify all fields match
1494        assert_eq!(parsed.path_bytes(), b"roundtrip_test.txt");
1495        assert_eq!(parsed.mode().unwrap(), 0o755);
1496        assert_eq!(parsed.uid().unwrap(), 1001);
1497        assert_eq!(parsed.gid().unwrap(), 1002);
1498        assert_eq!(parsed.entry_size().unwrap(), 4096);
1499        assert_eq!(parsed.mtime().unwrap(), 1609459200);
1500        assert_eq!(parsed.entry_type(), EntryType::Regular);
1501        assert_eq!(parsed.username().unwrap(), b"testuser");
1502        assert_eq!(parsed.groupname().unwrap(), b"testgroup");
1503
1504        // Checksum must be valid
1505        parsed.verify_checksum().unwrap();
1506    }
1507
1508    #[test]
1509    fn test_roundtrip_gnu() {
1510        let mut builder = HeaderBuilder::new_gnu();
1511        builder
1512            .path(b"gnu_test.dat")
1513            .unwrap()
1514            .mode(0o600)
1515            .unwrap()
1516            .size(0)
1517            .unwrap()
1518            .mtime(0)
1519            .unwrap()
1520            .uid(0)
1521            .unwrap()
1522            .gid(0)
1523            .unwrap()
1524            .entry_type(EntryType::Regular);
1525
1526        let parsed = builder.finish();
1527
1528        assert!(parsed.is_gnu());
1529        assert_eq!(parsed.path_bytes(), b"gnu_test.dat");
1530        parsed.verify_checksum().unwrap();
1531    }
1532
1533    #[test]
1534    fn test_header_builder_default() {
1535        let builder = HeaderBuilder::default();
1536        assert!(builder.as_header().is_ustar());
1537    }
1538
1539    #[test]
1540    fn test_header_builder_debug() {
1541        let builder = HeaderBuilder::new_ustar();
1542        let debug_str = format!("{builder:?}");
1543        assert!(debug_str.contains("HeaderBuilder"));
1544    }
1545
1546    #[test]
1547    fn test_pax_builder_debug() {
1548        let builder = PaxBuilder::new();
1549        let debug_str = format!("{builder:?}");
1550        assert!(debug_str.contains("PaxBuilder"));
1551    }
1552
1553    #[test]
1554    fn test_path_too_long() {
1555        let mut builder = HeaderBuilder::new_ustar();
1556        let long_path = [b'a'; 101];
1557        assert!(builder.path(long_path).is_err());
1558    }
1559
1560    #[test]
1561    fn test_link_name_too_long() {
1562        let mut builder = HeaderBuilder::new_ustar();
1563        let long_link = [b'b'; 101];
1564        assert!(builder.link_name(long_link).is_err());
1565    }
1566
1567    #[test]
1568    fn test_username_too_long() {
1569        let mut builder = HeaderBuilder::new_ustar();
1570        let long_name = [b'u'; 33];
1571        assert!(builder.username(long_name).is_err());
1572    }
1573
1574    #[test]
1575    fn test_pax_builder_linkpath() {
1576        let mut builder = PaxBuilder::new();
1577        builder.linkpath(b"/target/of/symlink");
1578        let data = builder.finish();
1579
1580        let exts = PaxExtensions::new(&data);
1581        assert_eq!(exts.get("linkpath"), Some("/target/of/symlink"));
1582    }
1583
1584    #[test]
1585    fn test_pax_builder_times() {
1586        let mut builder = PaxBuilder::new();
1587        builder.mtime(1000).atime(2000).ctime(3000);
1588        let data = builder.finish();
1589
1590        let exts = PaxExtensions::new(&data);
1591        assert_eq!(exts.get_u64("mtime"), Some(1000));
1592        assert_eq!(exts.get_u64("atime"), Some(2000));
1593        assert_eq!(exts.get_u64("ctime"), Some(3000));
1594    }
1595
1596    // =========================================================================
1597    // EntryBuilder Tests
1598    // =========================================================================
1599
1600    #[test]
1601    fn test_entry_builder_short_path_no_extension() {
1602        let mut builder = EntryBuilder::new_gnu();
1603        builder
1604            .path(b"hello.txt")
1605            .mode(0o644)
1606            .unwrap()
1607            .size(1024)
1608            .unwrap()
1609            .mtime(1234567890)
1610            .unwrap()
1611            .uid(1000)
1612            .unwrap()
1613            .gid(1000)
1614            .unwrap()
1615            .entry_type(EntryType::Regular);
1616
1617        assert!(!builder.needs_extension());
1618
1619        let blocks = builder.finish();
1620        assert_eq!(blocks.len(), 1, "short path should produce single header");
1621
1622        // Verify the header is valid
1623        let header = &blocks[0];
1624        assert_eq!(header.path_bytes(), b"hello.txt");
1625        assert_eq!(header.mode().unwrap(), 0o644);
1626        assert_eq!(header.entry_size().unwrap(), 1024);
1627        assert!(header.verify_checksum().is_ok());
1628    }
1629
1630    #[test]
1631    fn test_entry_builder_path_exactly_100_bytes() {
1632        // Path exactly 100 bytes should NOT require extension
1633        let path = "a".repeat(100);
1634        let mut builder = EntryBuilder::new_gnu();
1635        builder
1636            .path(path.as_bytes())
1637            .mode(0o644)
1638            .unwrap()
1639            .size(0)
1640            .unwrap()
1641            .entry_type(EntryType::Regular);
1642
1643        assert!(!builder.needs_extension());
1644
1645        let blocks = builder.finish();
1646        assert_eq!(blocks.len(), 1);
1647
1648        let header = &blocks[0];
1649        assert_eq!(header.path_bytes().len(), 100);
1650    }
1651
1652    #[test]
1653    fn test_entry_builder_gnu_long_path() {
1654        // Path > 100 bytes requires GNU LongName extension
1655        let long_path = "a/".repeat(60) + "file.txt"; // 128 bytes
1656        assert!(long_path.len() > 100);
1657
1658        let mut builder = EntryBuilder::new_gnu();
1659        builder
1660            .path(long_path.as_bytes())
1661            .mode(0o644)
1662            .unwrap()
1663            .size(0)
1664            .unwrap()
1665            .mtime(0)
1666            .unwrap()
1667            .uid(0)
1668            .unwrap()
1669            .gid(0)
1670            .unwrap()
1671            .entry_type(EntryType::Regular);
1672
1673        assert!(builder.needs_extension());
1674
1675        let blocks = builder.finish();
1676        // Should have: 1 LongName header + 1 data block + 1 main header = 3 blocks
1677        assert!(blocks.len() >= 3, "got {} blocks", blocks.len());
1678
1679        // First block should be the GNU LongName header
1680        let ext_header = &blocks[0];
1681        assert_eq!(ext_header.entry_type(), EntryType::GnuLongName);
1682        assert_eq!(ext_header.path_bytes(), b"././@LongLink");
1683        assert!(ext_header.verify_checksum().is_ok());
1684
1685        // The size should be path length + 1 (null terminator)
1686        assert_eq!(ext_header.entry_size().unwrap(), long_path.len() as u64 + 1);
1687
1688        // Second block should contain the path data (null-terminated)
1689        let data_block = blocks[1].as_bytes();
1690        assert_eq!(&data_block[..long_path.len()], long_path.as_bytes());
1691        assert_eq!(data_block[long_path.len()], 0); // null terminator
1692
1693        // Last block should be the main header
1694        let main_header = blocks.last().unwrap();
1695        assert_eq!(main_header.entry_type(), EntryType::Regular);
1696        assert!(main_header.verify_checksum().is_ok());
1697    }
1698
1699    #[test]
1700    fn test_entry_builder_gnu_long_link() {
1701        // Link target > 100 bytes requires GNU LongLink extension
1702        let long_target = "/very/long/symlink/target/".repeat(5); // ~130 bytes
1703        assert!(long_target.len() > 100);
1704
1705        let mut builder = EntryBuilder::new_gnu();
1706        builder
1707            .path(b"mylink")
1708            .link_name(long_target.as_bytes())
1709            .mode(0o777)
1710            .unwrap()
1711            .size(0)
1712            .unwrap()
1713            .mtime(0)
1714            .unwrap()
1715            .uid(0)
1716            .unwrap()
1717            .gid(0)
1718            .unwrap()
1719            .entry_type(EntryType::Symlink);
1720
1721        assert!(builder.needs_extension());
1722
1723        let blocks = builder.finish();
1724        assert!(blocks.len() >= 3);
1725
1726        // First block should be the GNU LongLink header
1727        let ext_header = &blocks[0];
1728        assert_eq!(ext_header.entry_type(), EntryType::GnuLongLink);
1729        assert_eq!(ext_header.path_bytes(), b"././@LongLink");
1730
1731        // Last block should be the symlink header
1732        let main_header = blocks.last().unwrap();
1733        assert_eq!(main_header.entry_type(), EntryType::Symlink);
1734    }
1735
1736    #[test]
1737    fn test_entry_builder_gnu_long_path_and_link() {
1738        // Both path and link target > 100 bytes
1739        let long_path = "dir/".repeat(30) + "file"; // ~124 bytes
1740        let long_target = "target/".repeat(20); // 140 bytes
1741
1742        assert!(long_path.len() > 100);
1743        assert!(long_target.len() > 100);
1744
1745        let mut builder = EntryBuilder::new_gnu();
1746        builder
1747            .path(long_path.as_bytes())
1748            .link_name(long_target.as_bytes())
1749            .mode(0o777)
1750            .unwrap()
1751            .size(0)
1752            .unwrap()
1753            .mtime(0)
1754            .unwrap()
1755            .uid(0)
1756            .unwrap()
1757            .gid(0)
1758            .unwrap()
1759            .entry_type(EntryType::Symlink);
1760
1761        let blocks = builder.finish();
1762        // Should have: LongLink header + data + LongName header + data + main header
1763        // At minimum: 2 (for LongLink) + 2 (for LongName) + 1 (main) = 5 blocks
1764        assert!(blocks.len() >= 5, "got {} blocks", blocks.len());
1765
1766        // First should be LongLink (for link target)
1767        let first = &blocks[0];
1768        assert_eq!(first.entry_type(), EntryType::GnuLongLink);
1769
1770        // After LongLink data, should be LongName
1771        // Find the LongName header
1772        let longname_idx = blocks
1773            .iter()
1774            .position(|b| b.entry_type() == EntryType::GnuLongName);
1775        assert!(longname_idx.is_some(), "should have LongName header");
1776
1777        // Last should be main header
1778        let main = blocks.last().unwrap();
1779        assert_eq!(main.entry_type(), EntryType::Symlink);
1780    }
1781
1782    #[test]
1783    fn test_entry_builder_pax_long_path() {
1784        let long_path = "pax/".repeat(30) + "file.txt"; // ~124 bytes
1785        assert!(long_path.len() > 100);
1786
1787        let mut builder = EntryBuilder::new_ustar(); // Uses PAX mode
1788        builder
1789            .path(long_path.as_bytes())
1790            .mode(0o644)
1791            .unwrap()
1792            .size(0)
1793            .unwrap()
1794            .mtime(0)
1795            .unwrap()
1796            .uid(0)
1797            .unwrap()
1798            .gid(0)
1799            .unwrap()
1800            .entry_type(EntryType::Regular);
1801
1802        assert_eq!(builder.extension_mode(), ExtensionMode::Pax);
1803        assert!(builder.needs_extension());
1804
1805        let blocks = builder.finish();
1806        // Should have: PAX header + data block + main header
1807        assert!(blocks.len() >= 3);
1808
1809        // First block should be the PAX extended header
1810        let pax_header = &blocks[0];
1811        assert_eq!(pax_header.entry_type(), EntryType::XHeader);
1812        assert!(pax_header.verify_checksum().is_ok());
1813
1814        // Second block should contain PAX records
1815        let pax_data = blocks[1].as_bytes();
1816        // The PAX data should contain "path=<long_path>"
1817        let pax_str = String::from_utf8_lossy(pax_data);
1818        assert!(pax_str.contains("path="));
1819        assert!(pax_str.contains(&long_path));
1820
1821        // Last block should be the main header
1822        let main_header = blocks.last().unwrap();
1823        assert_eq!(main_header.entry_type(), EntryType::Regular);
1824        assert!(main_header.is_ustar());
1825    }
1826
1827    #[test]
1828    fn test_entry_builder_pax_long_link() {
1829        let long_target = "/long/symlink/target/".repeat(6);
1830        assert!(long_target.len() > 100);
1831
1832        let mut builder = EntryBuilder::new_ustar();
1833        builder
1834            .path(b"link")
1835            .link_name(long_target.as_bytes())
1836            .mode(0o777)
1837            .unwrap()
1838            .size(0)
1839            .unwrap()
1840            .entry_type(EntryType::Symlink);
1841
1842        let blocks = builder.finish();
1843
1844        // First block should be PAX header
1845        let pax_header = &blocks[0];
1846        assert_eq!(pax_header.entry_type(), EntryType::XHeader);
1847
1848        // PAX data should contain linkpath
1849        let pax_data = blocks[1].as_bytes();
1850        let pax_str = String::from_utf8_lossy(pax_data);
1851        assert!(pax_str.contains("linkpath="));
1852    }
1853
1854    #[test]
1855    fn test_entry_builder_custom_pax_extension() {
1856        let mut builder = EntryBuilder::new_ustar();
1857        builder
1858            .path(b"file.txt")
1859            .mode(0o644)
1860            .unwrap()
1861            .size(0)
1862            .unwrap()
1863            .add_pax("SCHILY.xattr.user.test", b"value")
1864            .entry_type(EntryType::Regular);
1865
1866        assert!(builder.needs_extension()); // Due to custom PAX
1867
1868        let blocks = builder.finish();
1869        assert!(blocks.len() >= 3);
1870
1871        let pax_header = &blocks[0];
1872        assert_eq!(pax_header.entry_type(), EntryType::XHeader);
1873
1874        let pax_data = blocks[1].as_bytes();
1875        let pax_str = String::from_utf8_lossy(pax_data);
1876        assert!(pax_str.contains("SCHILY.xattr.user.test=value"));
1877    }
1878
1879    #[test]
1880    fn test_entry_builder_extension_mode_switching() {
1881        let long_path = "x/".repeat(60);
1882
1883        // Default GNU mode
1884        let mut builder = EntryBuilder::new_gnu();
1885        assert_eq!(builder.extension_mode(), ExtensionMode::Gnu);
1886
1887        // Switch to PAX mode
1888        builder.set_extension_mode(ExtensionMode::Pax);
1889        assert_eq!(builder.extension_mode(), ExtensionMode::Pax);
1890
1891        builder
1892            .path(long_path.as_bytes())
1893            .mode(0o644)
1894            .unwrap()
1895            .size(0)
1896            .unwrap()
1897            .entry_type(EntryType::Regular);
1898
1899        let blocks = builder.finish();
1900        // Should use PAX, not GNU
1901        let first = &blocks[0];
1902        assert_eq!(first.entry_type(), EntryType::XHeader);
1903    }
1904
1905    #[test]
1906    fn test_entry_builder_finish_bytes() {
1907        let mut builder = EntryBuilder::new_gnu();
1908        builder
1909            .path(b"test.txt")
1910            .mode(0o644)
1911            .unwrap()
1912            .size(0)
1913            .unwrap()
1914            .entry_type(EntryType::Regular);
1915
1916        let bytes = builder.finish_bytes();
1917        assert_eq!(bytes.len(), 512);
1918        assert_eq!(&bytes[..512], builder.header().as_header().as_bytes());
1919    }
1920
1921    #[test]
1922    fn test_entry_builder_directory() {
1923        let mut builder = EntryBuilder::new_gnu();
1924        builder
1925            .path(b"mydir/")
1926            .mode(0o755)
1927            .unwrap()
1928            .size(0)
1929            .unwrap()
1930            .mtime(1234567890)
1931            .unwrap()
1932            .uid(1000)
1933            .unwrap()
1934            .gid(1000)
1935            .unwrap()
1936            .entry_type(EntryType::Directory);
1937
1938        let blocks = builder.finish();
1939        assert_eq!(blocks.len(), 1);
1940
1941        let header = &blocks[0];
1942        assert_eq!(header.entry_type(), EntryType::Directory);
1943        assert!(header.verify_checksum().is_ok());
1944    }
1945
1946    #[test]
1947    fn test_entry_builder_device() {
1948        let mut builder = EntryBuilder::new_gnu();
1949        builder
1950            .path(b"null")
1951            .mode(0o666)
1952            .unwrap()
1953            .size(0)
1954            .unwrap()
1955            .device(1, 3)
1956            .unwrap()
1957            .entry_type(EntryType::Char);
1958
1959        let blocks = builder.finish();
1960        let header = &blocks[0];
1961        assert_eq!(header.entry_type(), EntryType::Char);
1962        assert_eq!(header.device_major().unwrap(), Some(1));
1963        assert_eq!(header.device_minor().unwrap(), Some(3));
1964    }
1965
1966    #[test]
1967    fn test_entry_builder_with_mode() {
1968        let header = HeaderBuilder::new_gnu();
1969        let builder = EntryBuilder::with_mode(header, ExtensionMode::Pax);
1970        assert_eq!(builder.extension_mode(), ExtensionMode::Pax);
1971        assert!(builder.header().as_header().is_gnu());
1972    }
1973
1974    #[test]
1975    fn test_entry_builder_default() {
1976        let builder = EntryBuilder::default();
1977        assert_eq!(builder.extension_mode(), ExtensionMode::Gnu);
1978    }
1979
1980    #[test]
1981    fn test_entry_builder_debug() {
1982        let mut builder = EntryBuilder::new_gnu();
1983        builder.path(b"test.txt");
1984        let debug_str = format!("{builder:?}");
1985        assert!(debug_str.contains("EntryBuilder"));
1986        assert!(debug_str.contains("Gnu"));
1987    }
1988
1989    #[test]
1990    fn test_blocks_for_size() {
1991        assert_eq!(blocks_for_size(0), 0);
1992        assert_eq!(blocks_for_size(1), 1);
1993        assert_eq!(blocks_for_size(511), 1);
1994        assert_eq!(blocks_for_size(512), 1);
1995        assert_eq!(blocks_for_size(513), 2);
1996        assert_eq!(blocks_for_size(1024), 2);
1997        assert_eq!(blocks_for_size(1025), 3);
1998    }
1999
2000    #[test]
2001    fn test_entry_builder_very_long_path() {
2002        // Path that requires multiple data blocks
2003        let very_long_path = "x/".repeat(300); // 600 bytes
2004        assert!(very_long_path.len() > 512);
2005
2006        let mut builder = EntryBuilder::new_gnu();
2007        builder
2008            .path(very_long_path.as_bytes())
2009            .mode(0o644)
2010            .unwrap()
2011            .size(0)
2012            .unwrap()
2013            .entry_type(EntryType::Regular);
2014
2015        let blocks = builder.finish();
2016        // LongName header + 2 data blocks (600+1 = 601 bytes, needs 2 blocks) + main header = 4
2017        assert!(blocks.len() >= 4, "got {} blocks", blocks.len());
2018
2019        let ext_header = &blocks[0];
2020        assert_eq!(ext_header.entry_type(), EntryType::GnuLongName);
2021        // Size should be 601 (path + null terminator)
2022        assert_eq!(ext_header.entry_size().unwrap(), 601);
2023    }
2024
2025    #[test]
2026    fn test_entry_builder_username_groupname() {
2027        let mut builder = EntryBuilder::new_gnu();
2028        builder
2029            .path(b"file.txt")
2030            .mode(0o644)
2031            .unwrap()
2032            .size(0)
2033            .unwrap()
2034            .username(b"testuser")
2035            .unwrap()
2036            .groupname(b"testgroup")
2037            .unwrap()
2038            .entry_type(EntryType::Regular);
2039
2040        let blocks = builder.finish();
2041        let header = &blocks[0];
2042        assert_eq!(header.username().unwrap(), b"testuser");
2043        assert_eq!(header.groupname().unwrap(), b"testgroup");
2044    }
2045
2046    #[test]
2047    fn test_entry_builder_header_access() {
2048        let mut builder = EntryBuilder::new_gnu();
2049        builder.path(b"test.txt");
2050
2051        // Read access
2052        assert!(builder.header().as_header().is_gnu());
2053
2054        // Mutable access
2055        builder.header_mut().mode(0o755).unwrap();
2056        assert_eq!(builder.header().as_header().mode().unwrap(), 0o755);
2057    }
2058
2059    #[test]
2060    fn test_entry_builder_pax_numeric_fallback() {
2061        use crate::PaxExtensions;
2062
2063        let large_uid: u64 = 5_000_000; // exceeds ustar 8-byte octal max (2097151)
2064        let large_size: u64 = 10_000_000_000; // exceeds ustar 12-byte octal max
2065
2066        // PAX mode: overflow falls back to PAX records.
2067        let mut builder = EntryBuilder::new_ustar();
2068        builder
2069            .path(b"big.dat")
2070            .uid(large_uid)
2071            .unwrap()
2072            .gid(large_uid)
2073            .unwrap()
2074            .size(large_size)
2075            .unwrap()
2076            .mtime(large_size)
2077            .unwrap()
2078            .entry_type(EntryType::Regular);
2079
2080        let blocks = builder.finish();
2081        // Should have PAX extension header + data + main header.
2082        assert!(blocks.len() >= 3, "expected PAX extension blocks");
2083
2084        // First block should be the PAX XHeader.
2085        let pax_header = &blocks[0];
2086        assert!(pax_header.entry_type().is_pax_local_extensions());
2087
2088        // Parse the PAX data to verify the numeric values are present.
2089        let pax_data_blocks = blocks.len() - 2; // minus PAX header and main header
2090        let pax_data: Vec<u8> = blocks[1..1 + pax_data_blocks]
2091            .iter()
2092            .flat_map(|b| b.as_bytes().iter().copied())
2093            .collect();
2094        let exts = PaxExtensions::new(&pax_data);
2095        assert_eq!(exts.get_u64("uid"), Some(large_uid));
2096        assert_eq!(exts.get_u64("gid"), Some(large_uid));
2097        assert_eq!(exts.get_u64("size"), Some(large_size));
2098        assert_eq!(exts.get_u64("mtime"), Some(large_size));
2099
2100        // Main header should have 0 in the overflowed fields.
2101        let main_header = blocks.last().unwrap();
2102        assert_eq!(main_header.uid().unwrap(), 0);
2103        assert_eq!(main_header.entry_size().unwrap(), 0);
2104    }
2105
2106    #[test]
2107    fn test_entry_builder_gnu_large_uid() {
2108        // GNU mode: large UIDs use base-256, no PAX needed.
2109        let large_uid: u64 = 5_000_000;
2110        let mut builder = EntryBuilder::new_gnu();
2111        builder
2112            .path(b"big.dat")
2113            .uid(large_uid)
2114            .unwrap()
2115            .gid(large_uid)
2116            .unwrap()
2117            .size(0)
2118            .unwrap()
2119            .mtime(0)
2120            .unwrap()
2121            .entry_type(EntryType::Regular);
2122
2123        let blocks = builder.finish();
2124        // GNU with no long path: just 1 block, no extensions needed.
2125        assert_eq!(blocks.len(), 1);
2126        let header = &blocks[0];
2127        assert_eq!(header.uid().unwrap(), large_uid);
2128        assert_eq!(header.gid().unwrap(), large_uid);
2129    }
2130
2131    // =========================================================================
2132    // Sparse EntryBuilder Tests
2133    // =========================================================================
2134
2135    use crate::parse::{Limits, ParseEvent, Parser};
2136    use crate::SparseEntry;
2137    use zerocopy::FromBytes;
2138
2139    #[test]
2140    fn test_entry_builder_gnu_sparse_basic() {
2141        let sparse_map = [
2142            SparseEntry {
2143                offset: 0,
2144                length: 100,
2145            },
2146            SparseEntry {
2147                offset: 1000,
2148                length: 200,
2149            },
2150        ];
2151        let on_disk: u64 = 300;
2152        let real_size: u64 = 1200;
2153
2154        let mut builder = EntryBuilder::new_gnu();
2155        builder
2156            .path(b"sparse.bin")
2157            .mode(0o644)
2158            .unwrap()
2159            .size(on_disk)
2160            .unwrap()
2161            .mtime(0)
2162            .unwrap()
2163            .uid(0)
2164            .unwrap()
2165            .gid(0)
2166            .unwrap()
2167            .sparse(&sparse_map, real_size);
2168
2169        let blocks = builder.finish();
2170        assert_eq!(blocks.len(), 1, "2 inline entries => no extension blocks");
2171
2172        let header = &blocks[0];
2173        assert_eq!(header.entry_type(), EntryType::GnuSparse);
2174        header.verify_checksum().unwrap();
2175
2176        let gnu = header.try_as_gnu().unwrap();
2177        assert_eq!(gnu.real_size().unwrap(), real_size);
2178        assert!(!gnu.is_extended());
2179
2180        let s0 = gnu.sparse[0].to_sparse_entry().unwrap();
2181        assert_eq!(s0, sparse_map[0]);
2182        let s1 = gnu.sparse[1].to_sparse_entry().unwrap();
2183        assert_eq!(s1, sparse_map[1]);
2184        assert!(gnu.sparse[2].is_empty());
2185    }
2186
2187    #[test]
2188    fn test_entry_builder_gnu_sparse_four_inline() {
2189        let sparse_map = [
2190            SparseEntry {
2191                offset: 0,
2192                length: 50,
2193            },
2194            SparseEntry {
2195                offset: 100,
2196                length: 50,
2197            },
2198            SparseEntry {
2199                offset: 200,
2200                length: 50,
2201            },
2202            SparseEntry {
2203                offset: 300,
2204                length: 50,
2205            },
2206        ];
2207        let on_disk: u64 = 200;
2208        let real_size: u64 = 350;
2209
2210        let mut builder = EntryBuilder::new_gnu();
2211        builder
2212            .path(b"sparse4.bin")
2213            .mode(0o644)
2214            .unwrap()
2215            .size(on_disk)
2216            .unwrap()
2217            .mtime(0)
2218            .unwrap()
2219            .uid(0)
2220            .unwrap()
2221            .gid(0)
2222            .unwrap()
2223            .sparse(&sparse_map, real_size);
2224
2225        let blocks = builder.finish();
2226        assert_eq!(blocks.len(), 1, "exactly 4 entries fit inline");
2227
2228        let header = &blocks[0];
2229        assert_eq!(header.entry_type(), EntryType::GnuSparse);
2230        header.verify_checksum().unwrap();
2231
2232        let gnu = header.try_as_gnu().unwrap();
2233        assert_eq!(gnu.real_size().unwrap(), real_size);
2234        assert!(!gnu.is_extended());
2235
2236        for (i, expected) in sparse_map.iter().enumerate() {
2237            assert_eq!(gnu.sparse[i].to_sparse_entry().unwrap(), *expected);
2238        }
2239    }
2240
2241    #[test]
2242    fn test_entry_builder_gnu_sparse_with_extensions() {
2243        // 6 entries: 4 inline + 2 in one extension block
2244        let sparse_map: Vec<SparseEntry> = (0..6)
2245            .map(|i| SparseEntry {
2246                offset: i * 1000,
2247                length: 100,
2248            })
2249            .collect();
2250        let on_disk: u64 = 600;
2251        let real_size: u64 = 5100;
2252
2253        let mut builder = EntryBuilder::new_gnu();
2254        builder
2255            .path(b"sparse_ext.bin")
2256            .mode(0o644)
2257            .unwrap()
2258            .size(on_disk)
2259            .unwrap()
2260            .mtime(0)
2261            .unwrap()
2262            .uid(0)
2263            .unwrap()
2264            .gid(0)
2265            .unwrap()
2266            .sparse(&sparse_map, real_size);
2267
2268        let blocks = builder.finish();
2269        assert_eq!(blocks.len(), 2, "main header + 1 extension block");
2270
2271        // Main header
2272        let header = &blocks[0];
2273        assert_eq!(header.entry_type(), EntryType::GnuSparse);
2274        header.verify_checksum().unwrap();
2275        let gnu = header.try_as_gnu().unwrap();
2276        assert!(gnu.is_extended(), "more entries follow");
2277        assert_eq!(gnu.real_size().unwrap(), real_size);
2278
2279        for (i, expected) in sparse_map.iter().enumerate().take(4) {
2280            assert_eq!(gnu.sparse[i].to_sparse_entry().unwrap(), *expected);
2281        }
2282
2283        // Extension block
2284        let ext = GnuExtSparseHeader::ref_from_bytes(blocks[1].as_bytes()).unwrap();
2285        assert!(!ext.is_extended(), "last extension block");
2286        for i in 0..2 {
2287            assert_eq!(ext.sparse[i].to_sparse_entry().unwrap(), sparse_map[4 + i]);
2288        }
2289        assert!(ext.sparse[2].is_empty());
2290    }
2291
2292    #[test]
2293    fn test_entry_builder_gnu_sparse_many_extensions() {
2294        // 28 entries: 4 inline + 21 in ext1 + 3 in ext2
2295        let sparse_map: Vec<SparseEntry> = (0..28)
2296            .map(|i| SparseEntry {
2297                offset: i * 500,
2298                length: 50,
2299            })
2300            .collect();
2301        let on_disk: u64 = 28 * 50;
2302        let real_size: u64 = 27 * 500 + 50;
2303
2304        let mut builder = EntryBuilder::new_gnu();
2305        builder
2306            .path(b"sparse_many.bin")
2307            .mode(0o644)
2308            .unwrap()
2309            .size(on_disk)
2310            .unwrap()
2311            .mtime(0)
2312            .unwrap()
2313            .uid(0)
2314            .unwrap()
2315            .gid(0)
2316            .unwrap()
2317            .sparse(&sparse_map, real_size);
2318
2319        let blocks = builder.finish();
2320        assert_eq!(blocks.len(), 3, "main + 2 extension blocks");
2321
2322        let gnu = blocks[0].try_as_gnu().unwrap();
2323        assert!(gnu.is_extended());
2324
2325        let ext1 = GnuExtSparseHeader::ref_from_bytes(blocks[1].as_bytes()).unwrap();
2326        assert!(ext1.is_extended(), "ext1 chains to ext2");
2327        for i in 0..21 {
2328            assert_eq!(ext1.sparse[i].to_sparse_entry().unwrap(), sparse_map[4 + i]);
2329        }
2330
2331        let ext2 = GnuExtSparseHeader::ref_from_bytes(blocks[2].as_bytes()).unwrap();
2332        assert!(!ext2.is_extended(), "ext2 is last");
2333        for i in 0..3 {
2334            assert_eq!(
2335                ext2.sparse[i].to_sparse_entry().unwrap(),
2336                sparse_map[25 + i]
2337            );
2338        }
2339        assert!(ext2.sparse[3].is_empty());
2340    }
2341
2342    #[test]
2343    fn test_entry_builder_pax_sparse_basic() {
2344        let sparse_map = [
2345            SparseEntry {
2346                offset: 0,
2347                length: 100,
2348            },
2349            SparseEntry {
2350                offset: 2000,
2351                length: 300,
2352            },
2353        ];
2354        let on_disk: u64 = 400;
2355        let real_size: u64 = 2300;
2356
2357        let mut builder = EntryBuilder::new_ustar();
2358        builder
2359            .path(b"pax_sparse.dat")
2360            .mode(0o644)
2361            .unwrap()
2362            .size(on_disk)
2363            .unwrap()
2364            .mtime(0)
2365            .unwrap()
2366            .uid(0)
2367            .unwrap()
2368            .gid(0)
2369            .unwrap()
2370            .sparse(&sparse_map, real_size);
2371
2372        let blocks = builder.finish();
2373
2374        // First block is a PAX XHeader
2375        assert_eq!(blocks[0].entry_type(), EntryType::XHeader);
2376        blocks[0].verify_checksum().unwrap();
2377
2378        // Parse PAX data to verify sparse keys
2379        let pax_size = blocks[0].entry_size().unwrap() as usize;
2380        let pax_data_blocks = pax_size.div_ceil(HEADER_SIZE);
2381        let pax_data: Vec<u8> = blocks[1..1 + pax_data_blocks]
2382            .iter()
2383            .flat_map(|b| b.as_bytes())
2384            .copied()
2385            .collect();
2386        let pax_str = std::str::from_utf8(&pax_data[..pax_size]).unwrap();
2387
2388        assert!(pax_str.contains("GNU.sparse.major=1\n"));
2389        assert!(pax_str.contains("GNU.sparse.minor=0\n"));
2390        assert!(pax_str.contains(&format!("GNU.sparse.realsize={real_size}\n")));
2391        assert!(pax_str.contains("GNU.sparse.name=pax_sparse.dat\n"));
2392
2393        // Main header should have a synthetic path
2394        let main_idx = 1 + pax_data_blocks;
2395        let main = &blocks[main_idx];
2396        assert!(
2397            main.path_bytes().starts_with(b"GNUSparseFile"),
2398            "synthetic path should start with GNUSparseFile, got {:?}",
2399            String::from_utf8_lossy(main.path_bytes())
2400        );
2401        main.verify_checksum().unwrap();
2402
2403        // After main header, there's a sparse map data prefix block
2404        assert!(blocks.len() > main_idx + 1, "should have map data prefix");
2405        let map_block = blocks[main_idx + 1].as_bytes();
2406        let map_str = std::str::from_utf8(map_block).unwrap();
2407        // Format: "<count>\n<offset>\n<length>\n..."
2408        assert!(
2409            map_str.starts_with("2\n"),
2410            "map prefix starts with entry count"
2411        );
2412        assert!(map_str.contains("0\n100\n"));
2413        assert!(map_str.contains("2000\n300\n"));
2414    }
2415
2416    #[test]
2417    fn test_entry_builder_gnu_sparse_with_long_path() {
2418        let long_path = "d/".repeat(60) + "sparse.bin"; // >100 bytes
2419        assert!(long_path.len() > 100);
2420
2421        let sparse_map: Vec<SparseEntry> = (0..6)
2422            .map(|i| SparseEntry {
2423                offset: i * 1000,
2424                length: 100,
2425            })
2426            .collect();
2427        let on_disk: u64 = 600;
2428        let real_size: u64 = 5100;
2429
2430        let mut builder = EntryBuilder::new_gnu();
2431        builder
2432            .path(long_path.as_bytes())
2433            .mode(0o644)
2434            .unwrap()
2435            .size(on_disk)
2436            .unwrap()
2437            .mtime(0)
2438            .unwrap()
2439            .uid(0)
2440            .unwrap()
2441            .gid(0)
2442            .unwrap()
2443            .sparse(&sparse_map, real_size);
2444
2445        let blocks = builder.finish();
2446
2447        // LongName blocks come first
2448        assert_eq!(blocks[0].entry_type(), EntryType::GnuLongName);
2449        blocks[0].verify_checksum().unwrap();
2450
2451        // Find the main header (GnuSparse type)
2452        let main_idx = blocks
2453            .iter()
2454            .position(|b| b.entry_type() == EntryType::GnuSparse)
2455            .expect("should have GnuSparse header");
2456
2457        // LongName header + data should precede it
2458        assert!(main_idx >= 2, "LongName header + data before main");
2459
2460        // Extension blocks should follow the main header
2461        let gnu = blocks[main_idx].try_as_gnu().unwrap();
2462        assert!(gnu.is_extended());
2463
2464        // Remaining blocks after main header should be extension blocks
2465        let ext_blocks = blocks.len() - main_idx - 1;
2466        assert!(ext_blocks >= 1, "should have extension block(s) after main");
2467    }
2468
2469    // =========================================================================
2470    // Sparse roundtrip tests (build → parse → verify)
2471    // =========================================================================
2472
2473    /// Helper: build a complete archive from builder output + on-disk data.
2474    fn build_archive(builder: &mut EntryBuilder, on_disk_size: u64) -> Vec<u8> {
2475        let mut archive = Vec::new();
2476        let header_bytes = builder.finish_bytes();
2477        archive.extend_from_slice(&header_bytes);
2478        // Content data (zeros for testing), padded to 512
2479        archive.extend(vec![0u8; on_disk_size.next_multiple_of(512) as usize]);
2480        // End-of-archive marker (two zero blocks)
2481        archive.extend(vec![0u8; 1024]);
2482        archive
2483    }
2484
2485    /// Helper: parse an archive and extract the sparse event.
2486    fn parse_sparse_event(archive: &[u8]) -> (Vec<SparseEntry>, u64, Vec<u8>) {
2487        let mut parser = Parser::new(Limits::default());
2488        match parser.parse(archive).unwrap() {
2489            ParseEvent::SparseEntry {
2490                sparse_map,
2491                real_size,
2492                entry,
2493                ..
2494            } => (sparse_map, real_size, entry.path.to_vec()),
2495            other => panic!("Expected SparseEntry, got {other:?}"),
2496        }
2497    }
2498
2499    #[test]
2500    fn test_sparse_roundtrip_gnu_basic() {
2501        let sparse_map = vec![
2502            SparseEntry {
2503                offset: 0,
2504                length: 100,
2505            },
2506            SparseEntry {
2507                offset: 5000,
2508                length: 200,
2509            },
2510        ];
2511        let on_disk: u64 = 300;
2512        let real_size: u64 = 5200;
2513
2514        let mut builder = EntryBuilder::new_gnu();
2515        builder
2516            .path(b"rt_gnu.bin")
2517            .mode(0o644)
2518            .unwrap()
2519            .size(on_disk)
2520            .unwrap()
2521            .mtime(0)
2522            .unwrap()
2523            .uid(0)
2524            .unwrap()
2525            .gid(0)
2526            .unwrap()
2527            .sparse(&sparse_map, real_size);
2528
2529        let archive = build_archive(&mut builder, on_disk);
2530        let (parsed_map, parsed_rs, parsed_path) = parse_sparse_event(&archive);
2531
2532        assert_eq!(parsed_path, b"rt_gnu.bin");
2533        assert_eq!(parsed_rs, real_size);
2534        assert_eq!(parsed_map, sparse_map);
2535    }
2536
2537    #[test]
2538    fn test_sparse_roundtrip_gnu_extended() {
2539        let sparse_map: Vec<SparseEntry> = (0..6)
2540            .map(|i| SparseEntry {
2541                offset: i * 2000,
2542                length: 100,
2543            })
2544            .collect();
2545        let on_disk: u64 = 600;
2546        let real_size: u64 = 10100;
2547
2548        let mut builder = EntryBuilder::new_gnu();
2549        builder
2550            .path(b"rt_gnu_ext.bin")
2551            .mode(0o644)
2552            .unwrap()
2553            .size(on_disk)
2554            .unwrap()
2555            .mtime(0)
2556            .unwrap()
2557            .uid(0)
2558            .unwrap()
2559            .gid(0)
2560            .unwrap()
2561            .sparse(&sparse_map, real_size);
2562
2563        let archive = build_archive(&mut builder, on_disk);
2564        let (parsed_map, parsed_rs, _) = parse_sparse_event(&archive);
2565
2566        assert_eq!(parsed_rs, real_size);
2567        assert_eq!(parsed_map, sparse_map);
2568    }
2569
2570    #[test]
2571    fn test_sparse_roundtrip_pax_basic() {
2572        let sparse_map = vec![
2573            SparseEntry {
2574                offset: 0,
2575                length: 100,
2576            },
2577            SparseEntry {
2578                offset: 3000,
2579                length: 400,
2580            },
2581        ];
2582        let on_disk: u64 = 500;
2583        let real_size: u64 = 3400;
2584
2585        let mut builder = EntryBuilder::new_ustar();
2586        builder
2587            .path(b"rt_pax.dat")
2588            .mode(0o644)
2589            .unwrap()
2590            .size(on_disk)
2591            .unwrap()
2592            .mtime(0)
2593            .unwrap()
2594            .uid(0)
2595            .unwrap()
2596            .gid(0)
2597            .unwrap()
2598            .sparse(&sparse_map, real_size);
2599
2600        let archive = build_archive(&mut builder, on_disk);
2601        let (parsed_map, parsed_rs, parsed_path) = parse_sparse_event(&archive);
2602
2603        assert_eq!(parsed_path, b"rt_pax.dat");
2604        assert_eq!(parsed_rs, real_size);
2605        assert_eq!(parsed_map, sparse_map);
2606    }
2607
2608    #[test]
2609    fn test_sparse_roundtrip_pax_many_entries() {
2610        let sparse_map: Vec<SparseEntry> = (0..10)
2611            .map(|i| SparseEntry {
2612                offset: i * 1000,
2613                length: 50,
2614            })
2615            .collect();
2616        let on_disk: u64 = 500;
2617        let real_size: u64 = 9050;
2618
2619        let mut builder = EntryBuilder::new_ustar();
2620        builder
2621            .path(b"rt_pax_many.dat")
2622            .mode(0o644)
2623            .unwrap()
2624            .size(on_disk)
2625            .unwrap()
2626            .mtime(0)
2627            .unwrap()
2628            .uid(0)
2629            .unwrap()
2630            .gid(0)
2631            .unwrap()
2632            .sparse(&sparse_map, real_size);
2633
2634        let archive = build_archive(&mut builder, on_disk);
2635        let (parsed_map, parsed_rs, parsed_path) = parse_sparse_event(&archive);
2636
2637        assert_eq!(parsed_path, b"rt_pax_many.dat");
2638        assert_eq!(parsed_rs, real_size);
2639        assert_eq!(parsed_map, sparse_map);
2640    }
2641
2642    mod proptest_tests {
2643        use super::*;
2644        use proptest::prelude::*;
2645
2646        /// Strategy for generating a sparse map with non-overlapping entries.
2647        fn sparse_map_strategy(max_entries: usize) -> impl Strategy<Value = Vec<SparseEntry>> {
2648            proptest::collection::vec((0u64..0x10_000, 1u64..0x1000), 0..=max_entries).prop_map(
2649                |raw| {
2650                    let mut entries = Vec::new();
2651                    let mut cursor = 0u64;
2652                    for (gap, length) in raw {
2653                        let offset = cursor.saturating_add(gap);
2654                        entries.push(SparseEntry { offset, length });
2655                        cursor = offset.saturating_add(length);
2656                    }
2657                    entries
2658                },
2659            )
2660        }
2661
2662        proptest! {
2663            #[test]
2664            fn test_decu64_roundtrip(value: u64) {
2665                let d = DecU64::new(value);
2666                let s = core::str::from_utf8(d.as_bytes()).unwrap();
2667                let parsed: u64 = s.parse().unwrap();
2668                prop_assert_eq!(parsed, value);
2669            }
2670
2671            #[test]
2672            fn test_sparse_builder_roundtrip_gnu(
2673                map in sparse_map_strategy(30),
2674            ) {
2675                let on_disk: u64 = map.iter().map(|e| e.length).sum();
2676                let real_size = map.last().map(|e| e.offset + e.length).unwrap_or(0);
2677
2678                let mut builder = EntryBuilder::new_gnu();
2679                builder
2680                    .path(b"proptest_gnu.bin")
2681                    .mode(0o644).unwrap()
2682                    .size(on_disk).unwrap()
2683                    .mtime(0).unwrap()
2684                    .uid(0).unwrap()
2685                    .gid(0).unwrap()
2686                    .sparse(&map, real_size);
2687
2688                let archive = build_archive(&mut builder, on_disk);
2689                let mut parser = Parser::new(Limits::default());
2690                let event = parser.parse(&archive).unwrap();
2691
2692                match event {
2693                    ParseEvent::SparseEntry {
2694                        sparse_map,
2695                        real_size: rs,
2696                        ..
2697                    } => {
2698                        prop_assert_eq!(rs, real_size);
2699                        prop_assert_eq!(sparse_map.len(), map.len());
2700                        for (i, expected) in map.iter().enumerate() {
2701                            prop_assert_eq!(sparse_map[i], *expected);
2702                        }
2703                    }
2704                    other => {
2705                        return Err(proptest::test_runner::TestCaseError::fail(
2706                            format!("Expected SparseEntry, got {other:?}")));
2707                    }
2708                }
2709            }
2710
2711            #[test]
2712            fn test_sparse_builder_roundtrip_pax(
2713                map in sparse_map_strategy(20),
2714            ) {
2715                let on_disk: u64 = map.iter().map(|e| e.length).sum();
2716                let real_size = map.last().map(|e| e.offset + e.length).unwrap_or(0);
2717
2718                let mut builder = EntryBuilder::new_ustar();
2719                builder
2720                    .path(b"proptest_pax.dat")
2721                    .mode(0o644).unwrap()
2722                    .size(on_disk).unwrap()
2723                    .mtime(0).unwrap()
2724                    .uid(0).unwrap()
2725                    .gid(0).unwrap()
2726                    .sparse(&map, real_size);
2727
2728                let archive = build_archive(&mut builder, on_disk);
2729                let mut parser = Parser::new(Limits::default());
2730                let event = parser.parse(&archive).unwrap();
2731
2732                match event {
2733                    ParseEvent::SparseEntry {
2734                        sparse_map,
2735                        real_size: rs,
2736                        entry,
2737                        ..
2738                    } => {
2739                        prop_assert_eq!(&entry.path[..], b"proptest_pax.dat");
2740                        prop_assert_eq!(rs, real_size);
2741                        prop_assert_eq!(sparse_map.len(), map.len());
2742                        for (i, expected) in map.iter().enumerate() {
2743                            prop_assert_eq!(sparse_map[i], *expected);
2744                        }
2745                    }
2746                    other => {
2747                        return Err(proptest::test_runner::TestCaseError::fail(
2748                            format!("Expected SparseEntry, got {other:?}")));
2749                    }
2750                }
2751            }
2752        }
2753    }
2754}
2755
2756#[cfg(kani)]
2757mod kani_proofs {
2758    use super::*;
2759
2760    #[kani::proof]
2761    #[kani::unwind(21)]
2762    fn check_decu64_panic_freedom() {
2763        let value: u64 = kani::any();
2764        let d = DecU64::new(value);
2765        let bytes = d.as_bytes();
2766        kani::assert(!bytes.is_empty(), "output is never empty");
2767        kani::assert(bytes.len() <= 20, "output fits in buffer");
2768    }
2769
2770    // DecU64 roundtrip is verified via proptest; the manual parse loop
2771    // over a fully-symbolic u64 exceeds CBMC's 10s budget.
2772}