Skip to main content

camel_processor/
tar_splitter.rs

1//! TAR and TAR.GZ stream splitters: metadata constants, bounded
2//! configuration, and the TAR-specific error surface.
3//!
4//! Splitting itself (bounded sequential TAR reading over materialized bytes)
5//! is layered on this contract. Entry names are validated through the shared
6//! archive path validator, so TAR and ZIP reject the same unsafe shapes with
7//! format-prefixed error text. TAR.GZ runs the same reader over the output
8//! of the bounded single-member GZIP decoder shared with
9//! `GzipDataFormat`; concatenated multi-member GZIP input is rejected
10//! instead of silently split.
11
12use std::collections::{HashMap, HashSet};
13use std::io::Read;
14use std::path::Path;
15use std::pin::Pin;
16use std::sync::Arc;
17
18use bytes::Bytes;
19use futures::Stream;
20use serde::Deserialize;
21use tokio::sync::mpsc;
22
23use camel_api::{Body, CamelError, Exchange, Message, StreamingSplitExpression, Value};
24
25use crate::archive_splitter::{
26    DEFAULT_MAX_PATH_LENGTH, DuplicatePolicy, next_free_indexed_name, validate_entry_path,
27};
28use crate::data_format::gzip::decode_first_member;
29
30pub const CAMEL_TAR_ENTRY_NAME: &str = "CamelTarEntryName";
31pub const CAMEL_TAR_ENTRY_PATH: &str = "CamelTarEntryPath";
32pub const CAMEL_TAR_ENTRY_INDEX: &str = "CamelTarEntryIndex";
33pub const CAMEL_TAR_ENTRY_SIZE: &str = "CamelTarEntrySize";
34pub const CAMEL_TAR_ENTRY_IS_DIRECTORY: &str = "CamelTarEntryIsDirectory";
35
36const DEFAULT_MAX_ENTRIES: usize = 10_000;
37const DEFAULT_MAX_TOTAL_DECODED_SIZE: u64 = 1_073_741_824;
38const DEFAULT_MAX_PER_ENTRY_SIZE: u64 = 512 * 1024 * 1024;
39const DEFAULT_MAX_COMPRESSED_SIZE: u64 = 1_073_741_824;
40const DEFAULT_CHANNEL_CAPACITY: usize = 2;
41
42/// Bounded configuration for TAR and TAR.GZ stream splitting.
43///
44/// Unknown keys are rejected (`deny_unknown_fields`) and every cap has an
45/// explicit default mirroring the ZIP splitter, so deserialized
46/// configurations are always fully bounded.
47#[derive(Clone, Debug, Deserialize)]
48#[serde(deny_unknown_fields, default)]
49pub struct TarSplitConfig {
50    /// Maximum number of emitted entries before the split fails.
51    pub max_entries: usize,
52    /// Maximum aggregate decoded bytes across all regular-entry payloads.
53    /// TAR framing (headers and padding) is not counted against this cap;
54    /// on TAR.GZ the decode step is bounded separately by this cap plus a
55    /// framing allowance derived from `max_entries`.
56    pub max_total_decoded_size: u64,
57    /// Maximum decoded size of a single entry.
58    pub max_per_entry_size: u64,
59    /// Maximum compressed input size accepted before decoding (TAR.GZ).
60    pub max_compressed_size: u64,
61    /// Maximum validated entry path length.
62    pub max_path_length: usize,
63    /// Duplicate entry-name policy from the shared archive vocabulary:
64    /// `Reject` fails the split on the first duplicate, `AllowWithIndex`
65    /// emits deterministic collision-free indexed names. TAR applies this
66    /// policy directly; ZIP's reader-level duplicate collapse is pinned
67    /// historical behavior, not a parity target.
68    pub duplicate_names_policy: DuplicatePolicy,
69    /// Accept archives that contain zero regular-file entries.
70    pub allow_empty_archive: bool,
71}
72
73impl Default for TarSplitConfig {
74    fn default() -> Self {
75        Self {
76            max_entries: DEFAULT_MAX_ENTRIES,
77            max_total_decoded_size: DEFAULT_MAX_TOTAL_DECODED_SIZE,
78            max_per_entry_size: DEFAULT_MAX_PER_ENTRY_SIZE,
79            max_compressed_size: DEFAULT_MAX_COMPRESSED_SIZE,
80            max_path_length: DEFAULT_MAX_PATH_LENGTH,
81            duplicate_names_policy: DuplicatePolicy::default(),
82            allow_empty_archive: false,
83        }
84    }
85}
86
87/// Validate a TAR entry name against the shared archive path rules
88/// (length, NUL, absolute paths, traversal, backslash, drive prefix) with
89/// TAR-prefixed error text.
90fn validate_tar_entry_path(name: &str, max_length: usize) -> Result<String, CamelError> {
91    validate_entry_path(name, max_length, "TAR")
92}
93
94/// Malformed archive (bad header, truncated stream, checksum mismatch).
95fn err_malformed_archive(detail: &str) -> CamelError {
96    CamelError::TypeConversionFailed(format!("Invalid TAR archive: {detail}"))
97}
98
99/// Duplicate entry name under [`DuplicatePolicy::Reject`].
100fn err_duplicate_entry_name(name: &str) -> CamelError {
101    CamelError::TypeConversionFailed(format!("Duplicate TAR entry name: {name}"))
102}
103
104/// Entry-count cap violation.
105fn err_max_entries(limit: usize) -> CamelError {
106    CamelError::TypeConversionFailed(format!("TAR exceeds max entries: {limit}"))
107}
108
109/// Per-entry decoded-size cap violation.
110fn err_entry_too_large(name: &str, size: u64, limit: u64) -> CamelError {
111    CamelError::TypeConversionFailed(format!(
112        "TAR entry '{name}' size {size} exceeds max {limit}"
113    ))
114}
115
116/// Aggregate decoded-size cap violation.
117fn err_total_decoded_exceeded(limit: u64) -> CamelError {
118    CamelError::TypeConversionFailed(format!("TAR total decoded size exceeds max {limit}"))
119}
120
121/// Decoded-archive budget violation (TAR.GZ): the decompressed TAR stream
122/// (payloads plus framing) exceeded the derived decode bound, so it cannot
123/// be a payload-cap-respecting archive.
124fn err_decoded_archive_budget(limit: u64) -> CamelError {
125    CamelError::TypeConversionFailed(format!(
126        "TAR.GZ decoded archive stream exceeds bounded decode budget {limit}"
127    ))
128}
129
130/// Absolute fail-closed ceiling for the entry-count-derived part of the
131/// TAR.GZ framing allowance. Without it, a huge `max_entries` would
132/// saturate the allowance (and the budget) to `u64::MAX`, reopening an
133/// unbounded decode. 64 MiB is generous for realistic PAX/GNU archives:
134/// a default-cap archive (10,000 entries, each carrying a maximum-length
135/// 4,096-byte name through a GNU longname or PAX extension block) needs
136/// ~58.6 MiB of framing and still fits under the ceiling.
137const MAX_TAR_FRAMING_ALLOWANCE: u64 = 64 * 1024 * 1024;
138
139/// Constant base of the TAR.GZ framing allowance: the two end-of-archive
140/// blocks, a PAX global ('g') header block, and slack. Per-entry
141/// extension-block framing is covered by the per-entry term below.
142const BASE_TAR_FRAMING_ALLOWANCE: u64 = 64 * 1024;
143
144/// TAR block size in bytes.
145const TAR_BLOCK_SIZE: u64 = 512;
146
147/// Worst-case legit framing bytes per regular entry, derived from the
148/// validated path cap (`max_path_length`):
149///
150/// - entry header block: 512
151/// - GNU longname ('L') or PAX ('x') extension header block: 512 (tar
152///   emits at most one of the two per entry)
153/// - extension data block: the recorded path (bounded by the path cap,
154///   plus record overhead — the `path=` key, length digits, and NUL),
155///   padded to the 512-byte block boundary
156/// - payload padding: up to 511 bytes
157///
158/// With the default 4,096-byte path cap this is
159/// 512 + 512 + 4,608 + 511 = 6,143 bytes (~6 KiB) per entry.
160fn tar_per_entry_framing(path_cap: usize) -> u64 {
161    let extension_data = (path_cap as u64 + 32).div_ceil(TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
162    TAR_BLOCK_SIZE + TAR_BLOCK_SIZE + extension_data + (TAR_BLOCK_SIZE - 1)
163}
164
165/// Decode bound for a TAR.GZ stream whose entry payloads must stay within
166/// `max_total_decoded_size`: the payload cap plus a dual-bounded TAR
167/// framing allowance — `min(max_entries x per-entry framing, ceiling) +
168/// base`. The per-entry term is sized from the validated path cap so a
169/// maximum-length-name archive is never falsely rejected; the ceiling
170/// keeps the allowance fail closed under absurd entry caps; the base
171/// covers end-of-archive and global-header blocks that are not
172/// proportional to the entry count. Payload accounting stays
173/// authoritative in the parse; this bound only keeps the inflate itself
174/// bounded.
175///
176/// Saturating arithmetic is contract, not accident: the budget derives
177/// from the operator's payload cap, so a `max_total_decoded_size` of
178/// `u64::MAX` is the operator explicitly opting out of decode bounding.
179/// The derived framing term is always finite under its ceiling, so only
180/// the operator-declared cap can saturate the sum; `checked_add` would
181/// not change that contract, only rename the opt-out.
182fn tar_gz_decode_budget(config: &TarSplitConfig) -> u64 {
183    let per_entry =
184        (config.max_entries as u64).saturating_mul(tar_per_entry_framing(config.max_path_length));
185    let framing = per_entry.min(MAX_TAR_FRAMING_ALLOWANCE) + BASE_TAR_FRAMING_ALLOWANCE;
186    config.max_total_decoded_size.saturating_add(framing)
187}
188
189/// Compressed-input cap violation (TAR.GZ), checked before decoding.
190fn err_compressed_input_exceeded(size: u64, limit: u64) -> CamelError {
191    CamelError::TypeConversionFailed(format!("TAR compressed size {size} exceeds max {limit}"))
192}
193
194/// A concatenated multi-member GZIP stream, which TAR.GZ splitting does not
195/// support in v1.
196fn err_multi_member_gzip() -> CamelError {
197    CamelError::TypeConversionFailed(
198        "TAR.GZ input contains multiple GZIP members; only a single-member \
199         GZIP stream is supported"
200            .to_string(),
201    )
202}
203
204/// Zero regular-file entries while `allow_empty_archive` is false.
205fn err_empty_archive() -> CamelError {
206    CamelError::TypeConversionFailed(
207        "TAR archive contains no regular entries; enable allow_empty_archive \
208         to accept it"
209            .to_string(),
210    )
211}
212
213/// One emitted regular-file TAR entry, carried from the blocking reader
214/// thread to the fragment-exchange builder.
215struct TarEntryData {
216    index: usize,
217    path: String,
218    size: u64,
219    data: Vec<u8>,
220}
221
222/// Split a TAR archive's materialized bytes into a stream of Exchanges, one
223/// per regular-file entry in header order.
224///
225/// Takes owned `Bytes` (for `'static` lifetime), a parent `Exchange` whose
226/// headers and properties are cloned into each entry's exchange, and a
227/// `TarSplitConfig` controlling limits and policy. Entries are read
228/// sequentially from the in-memory archive: names are validated before any
229/// body is read, directories/symlinks/hard links/devices are skipped (link
230/// targets are never read), and each regular body is materialized through a
231/// bounded `take(max_per_entry_size + 1)` read so an oversized entry is
232/// rejected before its bytes are retained.
233pub fn split_tar_bytes(
234    parent: Exchange,
235    bytes: Bytes,
236    config: TarSplitConfig,
237) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>> {
238    Box::pin(async_stream::stream! {
239        // Compressed-input cap: applies to the materialized input bytes
240        // before any entry is decoded.
241        if bytes.len() as u64 > config.max_compressed_size {
242            yield Err(err_compressed_input_exceeded(
243                bytes.len() as u64,
244                config.max_compressed_size,
245            ));
246            return;
247        }
248
249        let entries = tar_entry_stream(parent, bytes, config);
250        for await result in entries {
251            yield result;
252        }
253    })
254}
255
256/// Split a single-member TAR.GZ stream into a stream of Exchanges, one per
257/// regular-file entry in header order.
258///
259/// The compressed-input cap is checked before decompression is accepted, the
260/// input is then decoded through the bounded single-member GZIP decoder
261/// shared with `GzipDataFormat`. The decode budget is the payload cap
262/// (`max_total_decoded_size`) plus a bounded TAR framing allowance, so the
263/// inflate is bounded without counting framing against the payload cap; the
264/// resulting TAR bytes run through the same bounded entry reader as plain
265/// TAR splitting, which enforces the payload accounting authoritatively. A
266/// concatenated multi-member GZIP stream is rejected instead of silently
267/// splitting only the first member.
268pub fn split_tar_gz_bytes(
269    parent: Exchange,
270    bytes: Bytes,
271    config: TarSplitConfig,
272) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>> {
273    Box::pin(async_stream::stream! {
274        // Compressed-input cap: enforced on the compressed bytes before any
275        // decompression is accepted.
276        if bytes.len() as u64 > config.max_compressed_size {
277            yield Err(err_compressed_input_exceeded(
278                bytes.len() as u64,
279                config.max_compressed_size,
280            ));
281            return;
282        }
283
284        // Bounded single-member GZIP decode, run on the blocking pool so the
285        // inflate never executes on the async runtime. The decompressed TAR
286        // stream is budgeted from the payload cap plus the TAR framing
287        // allowance; entry-level caps and payload accounting are still
288        // enforced during the parse. If the fragment stream is dropped
289        // before the decode finishes, the detached blocking task still
290        // terminates on its own: the input is fully materialized and the
291        // decode is capped at `take_limit` bytes.
292        let decode_budget = tar_gz_decode_budget(&config);
293        let take_limit = decode_budget.saturating_add(1);
294        let decode_input = bytes.clone();
295        let first = match tokio::task::spawn_blocking(move || {
296            decode_first_member(&decode_input, take_limit)
297        })
298        .await
299        {
300            Ok(Ok(first)) => first,
301            Ok(Err(e)) => {
302                yield Err(err_malformed_archive(&format!(
303                    "failed to decode GZIP stream: {e}"
304                )));
305                return;
306            }
307            Err(e) => {
308                yield Err(err_malformed_archive(&format!(
309                    "GZIP decode task failed: {e}"
310                )));
311                return;
312            }
313        };
314
315        // `take_limit` is one past the budget, so a full buffer means the
316        // decompressed stream exceeded the budget (a framing-heavy
317        // decompression bomb that would never satisfy the payload cap).
318        if first.data.len() as u64 >= take_limit {
319            yield Err(err_decoded_archive_budget(decode_budget));
320            return;
321        }
322
323        // v1 TAR.GZ is single-member: trailing input past the first member is
324        // rejected rather than silently split.
325        if first.has_trailing_input {
326            yield Err(err_multi_member_gzip());
327            return;
328        }
329
330        let entries = tar_entry_stream(parent, Bytes::from(first.data), config);
331        for await result in entries {
332            yield result;
333        }
334    })
335}
336
337/// Sequential bounded TAR parse over materialized (already decompressed)
338/// bytes. Runs the reader on a blocking thread and drains the bounded
339/// channel into the fragment stream; callers own any input-level cap checks.
340fn tar_entry_stream(
341    parent: Exchange,
342    bytes: Bytes,
343    config: TarSplitConfig,
344) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>> {
345    Box::pin(async_stream::stream! {
346        let (tx, mut rx) = mpsc::channel::<Result<TarEntryData, CamelError>>(
347            DEFAULT_CHANNEL_CAPACITY,
348        );
349
350        let max_entries = config.max_entries;
351        let max_per_entry = config.max_per_entry_size;
352        let max_total = config.max_total_decoded_size;
353        let max_path_len = config.max_path_length;
354        let allow_empty = config.allow_empty_archive;
355        let dup_policy = config.duplicate_names_policy;
356
357        // The `tar` reader state is not `Send`, so the sequential parse runs
358        // on a blocking thread, keeping blocking reads off the async runtime.
359        // The bounded channel backpressures the reader; dropping the stream
360        // drops the receiver, so `blocking_send` fails and the task exits.
361        tokio::task::spawn_blocking(move || {
362            let mut archive = tar::Archive::new(std::io::Cursor::new(bytes));
363            let entries = match archive.entries() {
364                Ok(entries) => entries,
365                Err(e) => {
366                    let _ = tx.blocking_send(Err(err_malformed_archive(&e.to_string())));
367                    return;
368                }
369            };
370
371            let mut total_decoded: u64 = 0;
372            let mut emitted: usize = 0;
373            let mut emitted_names: HashSet<String> = HashSet::new();
374            let mut name_occurrences: HashMap<String, usize> = HashMap::new();
375
376            for entry in entries {
377                let mut entry = match entry {
378                    Ok(e) => e,
379                    Err(e) => {
380                        let _ = tx.blocking_send(Err(err_malformed_archive(&e.to_string())));
381                        return;
382                    }
383                };
384
385                // Validate the entry name before any body use; the name is
386                // never handed to the filesystem.
387                let raw_name = match entry.path() {
388                    Ok(p) => match p.to_str() {
389                        Some(name) => name.to_string(),
390                        None => {
391                            let _ = tx.blocking_send(Err(err_malformed_archive(
392                                "entry name is not valid UTF-8",
393                            )));
394                            return;
395                        }
396                    },
397                    Err(e) => {
398                        let _ = tx.blocking_send(Err(err_malformed_archive(&e.to_string())));
399                        return;
400                    }
401                };
402                let mut validated = match validate_tar_entry_path(&raw_name, max_path_len) {
403                    Ok(path) => path,
404                    Err(e) => {
405                        let _ = tx.blocking_send(Err(e));
406                        return;
407                    }
408                };
409
410                // Skip directories, symlinks, hard links, and device nodes;
411                // link targets are never read and nothing touches the
412                // filesystem.
413                if !matches!(entry.header().entry_type(), tar::EntryType::Regular) {
414                    continue;
415                }
416
417                // Bounded read: one extra byte detects overflow before the
418                // body is retained.
419                let mut data = Vec::new();
420                let mut limited = Read::take(&mut entry, max_per_entry.saturating_add(1));
421                if let Err(e) = limited.read_to_end(&mut data) {
422                    let _ = tx.blocking_send(Err(err_malformed_archive(&format!(
423                        "failed to read TAR entry '{raw_name}': {e}"
424                    ))));
425                    return;
426                }
427
428                if data.len() as u64 > max_per_entry {
429                    // Report the header-declared size: the bounded read only
430                    // pulled through max + 1 bytes before rejecting.
431                    let declared = entry.header().size().unwrap_or(data.len() as u64);
432                    let _ = tx.blocking_send(Err(err_entry_too_large(
433                        &raw_name,
434                        declared,
435                        max_per_entry,
436                    )));
437                    return;
438                }
439
440                let new_total = total_decoded.saturating_add(data.len() as u64);
441                if new_total > max_total {
442                    let _ = tx.blocking_send(Err(err_total_decoded_exceeded(max_total)));
443                    return;
444                }
445                total_decoded = new_total;
446
447                let index = emitted;
448                if index >= max_entries {
449                    let _ = tx.blocking_send(Err(err_max_entries(max_entries)));
450                    return;
451                }
452                emitted += 1;
453
454                match dup_policy {
455                    DuplicatePolicy::Reject => {
456                        if !emitted_names.insert(validated.clone()) {
457                            let _ = tx.blocking_send(Err(err_duplicate_entry_name(&validated)));
458                            return;
459                        }
460                    }
461                    DuplicatePolicy::AllowWithIndex => {
462                        // The occurrence counter alone cannot guarantee
463                        // uniqueness: a literal entry can occupy an indexed
464                        // name first (a.txt, a.1.txt, a.txt would derive
465                        // a.1.txt twice), so the emitted-name set is the
466                        // authority and the index bumps until the candidate
467                        // is free.
468                        let occurrences =
469                            name_occurrences.entry(validated.clone()).or_insert(0);
470                        let start = if *occurrences > 0 {
471                            *occurrences
472                        } else if emitted_names.contains(&validated) {
473                            1
474                        } else {
475                            0
476                        };
477                        if start > 0 {
478                            let (candidate, used) =
479                                next_free_indexed_name(&validated, start, &emitted_names);
480                            // The suffix grows the name, so the path-length
481                            // cap stays authoritative for indexed names too.
482                            validated = match validate_tar_entry_path(&candidate, max_path_len)
483                            {
484                                Ok(path) => path,
485                                Err(e) => {
486                                    let _ = tx.blocking_send(Err(e));
487                                    return;
488                                }
489                            };
490                            *occurrences = (*occurrences).max(used + 1);
491                        }
492                        emitted_names.insert(validated.clone());
493                    }
494                }
495
496                if tx
497                    .blocking_send(Ok(TarEntryData {
498                        index,
499                        path: validated,
500                        size: data.len() as u64,
501                        data,
502                    }))
503                    .is_err()
504                {
505                    return;
506                }
507            }
508
509            if emitted == 0 && !allow_empty {
510                let _ = tx.blocking_send(Err(err_empty_archive()));
511            }
512        });
513
514        while let Some(result) = rx.recv().await {
515            match result {
516                Ok(entry) => {
517                    let TarEntryData {
518                        index,
519                        path,
520                        size,
521                        data,
522                    } = entry;
523                    let msg = Message {
524                        headers: parent.input.headers.clone(),
525                        body: Body::Bytes(Bytes::from(data)),
526                    };
527                    let mut ex = Exchange::new(msg);
528                    // Strip parent-level content headers that are stale for
529                    // individual TAR entries.
530                    ex.input.headers.remove("Content-Length");
531                    ex.input.headers.remove("Content-Type");
532                    ex.properties = parent.properties.clone();
533                    ex.pattern = parent.pattern;
534                    ex.otel_context = parent.otel_context.clone();
535
536                    let entry_name = Path::new(&path)
537                        .file_name()
538                        .map(|n| n.to_string_lossy().to_string())
539                        .unwrap_or_default();
540
541                    ex.input.headers.insert(
542                        CAMEL_TAR_ENTRY_NAME.to_string(),
543                        Value::String(entry_name),
544                    );
545                    ex.input
546                        .headers
547                        .insert(CAMEL_TAR_ENTRY_PATH.to_string(), Value::String(path));
548                    ex.input.headers.insert(
549                        CAMEL_TAR_ENTRY_INDEX.to_string(),
550                        Value::from(index as u64),
551                    );
552                    ex.input
553                        .headers
554                        .insert(CAMEL_TAR_ENTRY_SIZE.to_string(), Value::from(size));
555                    ex.input.headers.insert(
556                        CAMEL_TAR_ENTRY_IS_DIRECTORY.to_string(),
557                        Value::Bool(false),
558                    );
559
560                    yield Ok(ex);
561                }
562                Err(e) => {
563                    yield Err(e);
564                }
565            }
566        }
567    })
568}
569
570/// Build a [`StreamingSplitExpression`] that splits TAR archive bodies into
571/// per-entry Exchanges.
572pub fn tar_splitter(config: TarSplitConfig) -> StreamingSplitExpression {
573    Arc::new(move |exchange: Exchange| {
574        let config = config.clone();
575        match exchange.input.body.clone() {
576            Body::Bytes(b) => split_tar_bytes(exchange, b, config),
577            Body::Text(s) => split_tar_bytes(exchange, Bytes::from(s.as_bytes().to_vec()), config),
578            _ => Box::pin(async_stream::stream! {
579                yield Err(CamelError::TypeConversionFailed(
580                    "TarSplitter requires Body::Bytes or Body::Text".to_string(),
581                ));
582            }),
583        }
584    })
585}
586
587/// Build a [`StreamingSplitExpression`] that splits single-member TAR.GZ
588/// bodies into per-entry Exchanges after bounded GZIP decoding.
589pub fn tar_gz_splitter(config: TarSplitConfig) -> StreamingSplitExpression {
590    Arc::new(move |exchange: Exchange| {
591        let config = config.clone();
592        match exchange.input.body.clone() {
593            Body::Bytes(b) => split_tar_gz_bytes(exchange, b, config),
594            Body::Text(s) => {
595                split_tar_gz_bytes(exchange, Bytes::from(s.as_bytes().to_vec()), config)
596            }
597            _ => Box::pin(async_stream::stream! {
598                yield Err(CamelError::TypeConversionFailed(
599                    "TarGzSplitter requires Body::Bytes or Body::Text".to_string(),
600                ));
601            }),
602        }
603    })
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::archive_splitter::test_util::make_zip_raw;
610    use crate::zip_splitter::{CAMEL_ZIP_ENTRY_PATH, ZipSplitConfig, zip_splitter};
611    use futures::StreamExt;
612
613    /// Build one 512-byte TAR header block with a valid checksum.
614    ///
615    /// Hand-rolled so tests can create entry shapes the `tar` writer refuses
616    /// to produce (absolute names, traversal names, device nodes).
617    fn tar_header(name: &str, size: u64, typeflag: u8) -> [u8; 512] {
618        let mut h = [0u8; 512];
619        h[..name.len()].copy_from_slice(name.as_bytes());
620        h[124..136].copy_from_slice(format!("{size:011o}\0").as_bytes());
621        // Checksum field must be spaces while the checksum is computed.
622        h[148..156].copy_from_slice(b"        ");
623        h[156] = typeflag;
624        h[257..263].copy_from_slice(b"ustar\0");
625        h[263..265].copy_from_slice(b"00");
626        let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
627        h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
628        h
629    }
630
631    /// Assemble headers, padded data blocks, and the two-block end marker
632    /// into a complete TAR archive.
633    fn tar_archive(entries: &[(&str, u8, &[u8])]) -> Vec<u8> {
634        let mut out = Vec::new();
635        for &(name, typeflag, data) in entries {
636            out.extend_from_slice(&tar_header(name, data.len() as u64, typeflag));
637            if !data.is_empty() {
638                let mut block = data.to_vec();
639                let rem = block.len() % 512;
640                if rem != 0 {
641                    block.extend_from_slice(&vec![0u8; 512 - rem]);
642                }
643                out.extend_from_slice(&block);
644            }
645        }
646        out.extend_from_slice(&[0u8; 1024]);
647        out
648    }
649
650    async fn collect(
651        config: TarSplitConfig,
652        tar_data: Vec<u8>,
653    ) -> Vec<Result<Exchange, CamelError>> {
654        let expr = tar_splitter(config);
655        let exchange = Exchange::new(Message {
656            headers: Default::default(),
657            body: Body::Bytes(Bytes::from(tar_data)),
658        });
659        expr(exchange).collect().await
660    }
661
662    #[test]
663    fn tar_split_config_rejects_unknown_fields() {
664        // Known keys deserialize; missing keys fall back to the bounded defaults.
665        let cfg: TarSplitConfig =
666            serde_json::from_str(r#"{"max_entries": 5, "allow_empty_archive": true}"#)
667                .expect("valid config must deserialize");
668        assert_eq!(cfg.max_entries, 5);
669        assert!(cfg.allow_empty_archive);
670        assert_eq!(cfg.max_per_entry_size, DEFAULT_MAX_PER_ENTRY_SIZE);
671        assert_eq!(cfg.max_path_length, DEFAULT_MAX_PATH_LENGTH);
672
673        // Unknown keys fail closed instead of being ignored.
674        let err = serde_json::from_str::<TarSplitConfig>(r#"{"unknown_key": 1}"#)
675            .expect_err("unknown config key must fail");
676        assert!(
677            err.to_string().contains("unknown field `unknown_key`"),
678            "{err}"
679        );
680    }
681
682    #[tokio::test]
683    async fn tar_split_emits_regular_files_in_header_order() {
684        // Header order: file, dir, symlink, hard link, char device, file.
685        // Only the two regular files may surface as fragments.
686        let tar_data = tar_archive(&[
687            ("first.txt", b'0', b"alpha".as_slice()),
688            ("docs", b'5', b""),
689            ("link.txt", b'2', b""),
690            ("hard.txt", b'1', b""),
691            ("dev-zero", b'3', b""),
692            ("second.txt", b'0', b"beta".as_slice()),
693        ]);
694        let results = collect(TarSplitConfig::default(), tar_data).await;
695        assert_eq!(results.len(), 2, "non-regular entries must be omitted");
696
697        let first = results[0].as_ref().expect("first fragment ok");
698        let second = results[1].as_ref().expect("second fragment ok");
699
700        assert_eq!(
701            first.input.headers.get(CAMEL_TAR_ENTRY_NAME),
702            Some(&Value::String("first.txt".to_string()))
703        );
704        assert_eq!(
705            second.input.headers.get(CAMEL_TAR_ENTRY_NAME),
706            Some(&Value::String("second.txt".to_string()))
707        );
708        assert_eq!(
709            first.input.headers.get(CAMEL_TAR_ENTRY_INDEX),
710            Some(&Value::from(0u64))
711        );
712        assert_eq!(
713            second.input.headers.get(CAMEL_TAR_ENTRY_INDEX),
714            Some(&Value::from(1u64))
715        );
716        match &first.input.body {
717            Body::Bytes(b) => assert_eq!(b.as_ref(), b"alpha"),
718            other => panic!("expected Body::Bytes, got {other:?}"),
719        }
720        match &second.input.body {
721            Body::Bytes(b) => assert_eq!(b.as_ref(), b"beta"),
722            other => panic!("expected Body::Bytes, got {other:?}"),
723        }
724        assert_eq!(
725            first.input.headers.get(CAMEL_TAR_ENTRY_PATH),
726            Some(&Value::String("first.txt".to_string()))
727        );
728        assert_eq!(
729            first.input.headers.get(CAMEL_TAR_ENTRY_SIZE),
730            Some(&Value::from(5u64))
731        );
732        assert_eq!(
733            first.input.headers.get(CAMEL_TAR_ENTRY_IS_DIRECTORY),
734            Some(&Value::Bool(false))
735        );
736    }
737
738    #[tokio::test]
739    async fn tar_split_rejects_traversal_and_absolute_names() {
740        let cases = [
741            (
742                "../escape",
743                "TAR entry path contains '..' traversal: ../escape",
744            ),
745            ("/absolute", "TAR entry path is absolute: /absolute"),
746        ];
747        for (name, expected) in cases {
748            let tar_data = tar_archive(&[(name, b'0', b"oops".as_slice())]);
749            let results = collect(TarSplitConfig::default(), tar_data).await;
750            assert_eq!(
751                results.len(),
752                1,
753                "expected exactly the validation error for {name}"
754            );
755            let err = results[0]
756                .as_ref()
757                .expect_err(&format!("'{name}' must be rejected"))
758                .to_string();
759            assert!(
760                err.contains(expected),
761                "error text mismatch for {name}: {err}"
762            );
763        }
764    }
765
766    #[tokio::test]
767    async fn tar_split_enforces_all_bounds() {
768        // Entry-count cap: three regular entries against max_entries: 2.
769        let tar_data = tar_archive(&[
770            ("a.txt", b'0', b"1".as_slice()),
771            ("b.txt", b'0', b"2".as_slice()),
772            ("c.txt", b'0', b"3".as_slice()),
773        ]);
774        let config = TarSplitConfig {
775            max_entries: 2,
776            ..Default::default()
777        };
778        let results = collect(config, tar_data).await;
779        assert!(
780            results.iter().any(|r| r
781                .as_ref()
782                .is_err_and(|e| e.to_string().contains("TAR exceeds max entries: 2"))),
783            "expected entry-count cap error, got {results:?}"
784        );
785
786        // Per-entry cap: one 200-byte entry against max_per_entry_size: 100.
787        let tar_data = tar_archive(&[("big.bin", b'0', &[b'x'; 200])]);
788        let config = TarSplitConfig {
789            max_per_entry_size: 100,
790            ..Default::default()
791        };
792        let results = collect(config, tar_data).await;
793        assert_eq!(results.len(), 1);
794        assert!(
795            results[0]
796                .as_ref()
797                .expect_err("per-entry cap")
798                .to_string()
799                .contains("TAR entry 'big.bin' size 200 exceeds max 100"),
800            "expected per-entry cap error"
801        );
802
803        // Total-decoded cap: two 10-byte entries against max 15.
804        let tar_data = tar_archive(&[
805            ("a.txt", b'0', b"0123456789".as_slice()),
806            ("b.txt", b'0', b"9876543210".as_slice()),
807        ]);
808        let config = TarSplitConfig {
809            max_total_decoded_size: 15,
810            ..Default::default()
811        };
812        let results = collect(config, tar_data).await;
813        assert!(
814            results.iter().any(|r| r.as_ref().is_err_and(|e| e
815                .to_string()
816                .contains("TAR total decoded size exceeds max 15"))),
817            "expected total-decoded cap error, got {results:?}"
818        );
819
820        // Compressed-input cap: the materialized input itself against max 512.
821        let tar_data = tar_archive(&[("a.txt", b'0', b"x".as_slice())]);
822        let config = TarSplitConfig {
823            max_compressed_size: 512,
824            ..Default::default()
825        };
826        let results = collect(config, tar_data).await;
827        assert_eq!(results.len(), 1);
828        assert!(
829            results[0]
830                .as_ref()
831                .expect_err("compressed-input cap")
832                .to_string()
833                .contains("exceeds max 512"),
834            "expected compressed-input cap error"
835        );
836
837        // Path-length cap: a 26-character name against max_path_length: 10.
838        let long_name = "a-very-long-entry-name.bin";
839        let tar_data = tar_archive(&[(long_name, b'0', b"x".as_slice())]);
840        let config = TarSplitConfig {
841            max_path_length: 10,
842            ..Default::default()
843        };
844        let results = collect(config, tar_data).await;
845        assert_eq!(results.len(), 1);
846        assert!(
847            results[0]
848                .as_ref()
849                .expect_err("path-length cap")
850                .to_string()
851                .contains(&format!(
852                    "TAR entry path exceeds max length: {} > 10",
853                    long_name.len()
854                )),
855            "expected path-length cap error"
856        );
857    }
858
859    #[tokio::test]
860    async fn tar_split_empty_and_directory_only_archives_emit_zero() {
861        let config = TarSplitConfig {
862            allow_empty_archive: true,
863            ..Default::default()
864        };
865        let results = collect(config.clone(), tar_archive(&[])).await;
866        assert!(
867            results.is_empty(),
868            "empty archive must emit zero fragments: {results:?}"
869        );
870
871        let results = collect(
872            config,
873            tar_archive(&[("only-dir", b'5', b""), ("nested", b'5', b"")]),
874        )
875        .await;
876        assert!(
877            results.is_empty(),
878            "directory-only archive must emit zero fragments: {results:?}"
879        );
880    }
881
882    /// Compress raw bytes into a single-member GZIP stream for TAR.GZ setups.
883    fn gzip_bytes(raw: &[u8]) -> Vec<u8> {
884        use std::io::Write as _;
885        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
886        encoder.write_all(raw).expect("gzip write");
887        encoder.finish().expect("gzip finish")
888    }
889
890    async fn collect_gz(
891        config: TarSplitConfig,
892        gz_data: Vec<u8>,
893    ) -> Vec<Result<Exchange, CamelError>> {
894        let expr = tar_gz_splitter(config);
895        let exchange = Exchange::new(Message {
896            headers: Default::default(),
897            body: Body::Bytes(Bytes::from(gz_data)),
898        });
899        expr(exchange).collect().await
900    }
901
902    fn header_string(exchange: &Exchange, key: &str) -> String {
903        match exchange.input.headers.get(key) {
904            Some(Value::String(s)) => s.clone(),
905            other => panic!("header {key} must be a string, got {other:?}"),
906        }
907    }
908
909    fn body_bytes(exchange: &Exchange) -> Vec<u8> {
910        match &exchange.input.body {
911            Body::Bytes(b) => b.to_vec(),
912            other => panic!("expected Body::Bytes, got {other:?}"),
913        }
914    }
915
916    #[tokio::test]
917    async fn tar_gz_split_matches_tar_metadata() {
918        let tar_data = tar_archive(&[
919            ("first.txt", b'0', b"alpha".as_slice()),
920            ("docs", b'5', b""),
921            ("second.txt", b'0', b"beta".as_slice()),
922        ]);
923
924        let tar_results = collect(TarSplitConfig::default(), tar_data.clone()).await;
925        let gz_results = collect_gz(TarSplitConfig::default(), gzip_bytes(&tar_data)).await;
926
927        assert_eq!(gz_results.len(), tar_results.len(), "same entries emitted");
928        for (t, g) in tar_results.iter().zip(gz_results.iter()) {
929            let t = t.as_ref().expect("TAR fragment ok");
930            let g = g.as_ref().expect("TAR.GZ fragment ok");
931            for header in [
932                CAMEL_TAR_ENTRY_NAME,
933                CAMEL_TAR_ENTRY_PATH,
934                CAMEL_TAR_ENTRY_INDEX,
935                CAMEL_TAR_ENTRY_SIZE,
936                CAMEL_TAR_ENTRY_IS_DIRECTORY,
937            ] {
938                assert_eq!(
939                    t.input.headers.get(header),
940                    g.input.headers.get(header),
941                    "header {header} must match TAR output"
942                );
943            }
944            assert_eq!(t.input.body, g.input.body, "body must match TAR output");
945        }
946    }
947
948    /// TAR applies the shared archive duplicate-name policy directly
949    /// (per the narrowed spec this is TAR's own contract, not ZIP
950    /// parity): `Reject` fails on the first duplicate; `AllowWithIndex`
951    /// emits every entry with a deterministic indexed name for later
952    /// duplicates.
953    #[tokio::test]
954    async fn tar_split_applies_shared_duplicate_policy() {
955        let tar_data = tar_archive(&[
956            ("dup.txt", b'0', b"one".as_slice()),
957            ("other.txt", b'0', b"mid".as_slice()),
958            ("dup.txt", b'0', b"two".as_slice()),
959        ]);
960
961        // Reject policy: the split fails on the first duplicate name.
962        let reject_config = TarSplitConfig {
963            duplicate_names_policy: DuplicatePolicy::Reject,
964            ..Default::default()
965        };
966        let results = collect(reject_config, tar_data.clone()).await;
967        assert!(
968            results.iter().any(|r| r
969                .as_ref()
970                .is_err_and(|e| e.to_string().contains("Duplicate TAR entry name: dup.txt"))),
971            "reject policy must fail on the duplicate: {results:?}"
972        );
973
974        // AllowWithIndex: every entry is emitted and the later duplicate gets
975        // a deterministic index inserted before its extension.
976        let index_config = TarSplitConfig {
977            duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
978            ..Default::default()
979        };
980        let first_run = collect(index_config.clone(), tar_data.clone()).await;
981        let second_run = collect(index_config, tar_data).await;
982
983        assert_eq!(first_run.len(), 3, "every entry must be emitted");
984        let snapshot: Vec<(String, String, Vec<u8>)> = first_run
985            .iter()
986            .map(|r| {
987                let ex = r.as_ref().expect("allow-with-index must not fail");
988                (
989                    header_string(ex, CAMEL_TAR_ENTRY_PATH),
990                    header_string(ex, CAMEL_TAR_ENTRY_NAME),
991                    body_bytes(ex),
992                )
993            })
994            .collect();
995        assert_eq!(
996            snapshot,
997            [
998                (
999                    "dup.txt".to_string(),
1000                    "dup.txt".to_string(),
1001                    b"one".to_vec()
1002                ),
1003                (
1004                    "other.txt".to_string(),
1005                    "other.txt".to_string(),
1006                    b"mid".to_vec()
1007                ),
1008                (
1009                    "dup.1.txt".to_string(),
1010                    "dup.1.txt".to_string(),
1011                    b"two".to_vec()
1012                ),
1013            ],
1014            "indexed names must be deterministic"
1015        );
1016
1017        let second_paths: Vec<String> = second_run
1018            .iter()
1019            .map(|r| {
1020                let ex = r.as_ref().expect("second run must not fail");
1021                header_string(ex, CAMEL_TAR_ENTRY_PATH)
1022            })
1023            .collect();
1024        let first_paths: Vec<String> = snapshot.into_iter().map(|(p, _, _)| p).collect();
1025        assert_eq!(
1026            first_paths, second_paths,
1027            "naming must be deterministic across runs"
1028        );
1029    }
1030
1031    /// `DuplicatePolicy` ownership, per the narrowed spec: TAR applies the
1032    /// shared archive duplicate-name vocabulary (`Reject` fails the split;
1033    /// `AllowWithIndex` emits deterministic collision-free indexed names via
1034    /// the shared [`crate::archive_splitter::indexed_duplicate_name`] /
1035    /// [`crate::archive_splitter::next_free_indexed_name`] helpers). ZIP's
1036    /// historical behavior is pinned, not claimed as parity: the `zip`
1037    /// reader indexes the central directory by name, so duplicate entries
1038    /// collapse (last data wins) before the splitter observes them. That
1039    /// boundary is pinned here so a reader change that starts surfacing
1040    /// duplicates forces the ZIP mangling/reject branches (shared with TAR)
1041    /// to be revisited instead of silently changing behavior. ZIP runtime
1042    /// behavior is unchanged by this change.
1043    #[tokio::test]
1044    async fn tar_duplicate_policy_is_shared_zip_collapse_is_pinned() {
1045        let tar_data = tar_archive(&[
1046            ("dup.txt", b'0', b"one".as_slice()),
1047            ("dup.txt", b'0', b"two".as_slice()),
1048        ]);
1049        let zip_data = make_zip_raw(&[
1050            ("dup.txt", b"one".as_slice()),
1051            ("dup.txt", b"two".as_slice()),
1052        ]);
1053
1054        // AllowWithIndex on TAR: every entry is emitted and the later
1055        // duplicate carries the shared deterministic indexed suffix.
1056        let index_config = TarSplitConfig {
1057            duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
1058            ..Default::default()
1059        };
1060        let tar_results = collect(index_config, tar_data.clone()).await;
1061        let tar_paths: Vec<String> = tar_results
1062            .iter()
1063            .map(|r| header_string(r.as_ref().expect("TAR fragment ok"), CAMEL_TAR_ENTRY_PATH))
1064            .collect();
1065        assert_eq!(
1066            tar_paths,
1067            ["dup.txt".to_string(), "dup.1.txt".to_string()],
1068            "TAR indexed names must come from the shared mangling helper"
1069        );
1070
1071        // AllowWithIndex on ZIP over the same logical entries: the reader
1072        // collapses the duplicates, so exactly one unmangled fragment with
1073        // the surviving (last) body is observable.
1074        let zip_expr = zip_splitter(ZipSplitConfig {
1075            duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
1076            ..Default::default()
1077        });
1078        let zip_exchange = Exchange::new(Message {
1079            headers: Default::default(),
1080            body: Body::Bytes(Bytes::from(zip_data.clone())),
1081        });
1082        let zip_results: Vec<Result<Exchange, CamelError>> = zip_expr(zip_exchange).collect().await;
1083        assert_eq!(
1084            zip_results.len(),
1085            1,
1086            "the zip reader must collapse duplicate names for now: {zip_results:?}"
1087        );
1088        let zip_path = header_string(
1089            zip_results[0].as_ref().expect("ZIP fragment ok"),
1090            CAMEL_ZIP_ENTRY_PATH,
1091        );
1092        assert_eq!(
1093            zip_path, "dup.txt",
1094            "unique observable names stay unmangled"
1095        );
1096
1097        // Reject on TAR: the split fails on the first duplicate name.
1098        let reject_config = TarSplitConfig {
1099            duplicate_names_policy: DuplicatePolicy::Reject,
1100            ..Default::default()
1101        };
1102        let tar_err = collect(reject_config, tar_data).await;
1103        let tar_msg = tar_err
1104            .iter()
1105            .find_map(|r| r.as_ref().err().map(|e| e.to_string()))
1106            .expect("TAR reject must fail");
1107        assert!(
1108            tar_msg.contains("Duplicate TAR entry name: dup.txt"),
1109            "{tar_msg}"
1110        );
1111
1112        // Reject on ZIP over the same logical entries: with duplicates
1113        // collapsed away the names are unique, so the split succeeds — the
1114        // reject branch stays reserved for a reader that surfaces them.
1115        let zip_expr = zip_splitter(ZipSplitConfig {
1116            duplicate_names_policy: DuplicatePolicy::Reject,
1117            ..Default::default()
1118        });
1119        let zip_exchange = Exchange::new(Message {
1120            headers: Default::default(),
1121            body: Body::Bytes(Bytes::from(zip_data)),
1122        });
1123        let zip_results: Vec<Result<Exchange, CamelError>> = zip_expr(zip_exchange).collect().await;
1124        assert!(
1125            zip_results.iter().all(|r| r.is_ok()),
1126            "collapsed names are unique, so reject must not fire: {zip_results:?}"
1127        );
1128    }
1129
1130    #[tokio::test]
1131    async fn tar_gz_compressed_input_limit_is_checked() {
1132        let gz = gzip_bytes(&tar_archive(&[("a.txt", b'0', b"payload".as_slice())]));
1133        let config = TarSplitConfig {
1134            max_compressed_size: gz.len() as u64 - 1,
1135            ..Default::default()
1136        };
1137        let results = collect_gz(config, gz.clone()).await;
1138        assert_eq!(results.len(), 1);
1139        let err = results[0]
1140            .as_ref()
1141            .expect_err("compressed input over the cap must fail before decompression")
1142            .to_string();
1143        assert!(
1144            err.contains(&format!(
1145                "TAR compressed size {} exceeds max {}",
1146                gz.len(),
1147                gz.len() - 1
1148            )),
1149            "expected the compressed-input cap error: {err}"
1150        );
1151    }
1152
1153    #[tokio::test]
1154    async fn tar_gz_multi_member_is_rejected() {
1155        let member_one = gzip_bytes(&tar_archive(&[("a.txt", b'0', b"one".as_slice())]));
1156        let member_two = gzip_bytes(&tar_archive(&[("b.txt", b'0', b"two".as_slice())]));
1157        let mut concatenated = member_one;
1158        concatenated.extend_from_slice(&member_two);
1159
1160        let results = collect_gz(TarSplitConfig::default(), concatenated).await;
1161        assert_eq!(results.len(), 1);
1162        let err = results[0]
1163            .as_ref()
1164            .expect_err("multi-member input must be rejected, not split silently")
1165            .to_string();
1166        assert!(
1167            err.contains("multiple GZIP members"),
1168            "expected the unsupported-multi-member error: {err}"
1169        );
1170    }
1171
1172    /// `max_total_decoded_size` is a payload-byte cap. TAR framing
1173    /// (512-byte headers, padding, end-of-archive blocks) must not count
1174    /// against it on TAR.GZ: the decode step is bounded by the payload cap
1175    /// plus a framing allowance derived from `max_entries`, and the parse
1176    /// enforces the payload accounting authoritatively.
1177    #[tokio::test]
1178    async fn tar_gz_total_decoded_cap_counts_payload_not_framing() {
1179        // Three tiny entries: payload total (30 bytes) well under the
1180        // 64-byte cap, decoded stream (~4 KiB with framing) well over it.
1181        // A framing-inclusive decode gate would reject this archive.
1182        let payload: &[u8] = b"0123456789";
1183        let gz = gzip_bytes(&tar_archive(&[
1184            ("a.txt", b'0', payload),
1185            ("b.txt", b'0', payload),
1186            ("c.txt", b'0', payload),
1187        ]));
1188        let results = collect_gz(
1189            TarSplitConfig {
1190                max_total_decoded_size: 64,
1191                ..Default::default()
1192            },
1193            gz,
1194        )
1195        .await;
1196        assert_eq!(
1197            results.len(),
1198            3,
1199            "framing must not count against the payload cap"
1200        );
1201        for r in &results {
1202            assert!(r.is_ok(), "fragment must succeed: {r:?}");
1203        }
1204
1205        // A payload that itself exceeds the cap still fails, at the parse,
1206        // with the payload-cap error.
1207        let big = vec![b'x'; 100];
1208        let gz_big = gzip_bytes(&tar_archive(&[("big.txt", b'0', big.as_slice())]));
1209        let over = collect_gz(
1210            TarSplitConfig {
1211                max_total_decoded_size: 64,
1212                ..Default::default()
1213            },
1214            gz_big,
1215        )
1216        .await;
1217        assert_eq!(over.len(), 1);
1218        let err = over[0]
1219            .as_ref()
1220            .expect_err("payload over the cap must fail at the parse")
1221            .to_string();
1222        assert!(
1223            err.contains("TAR total decoded size exceeds max 64"),
1224            "expected the payload-cap error: {err}"
1225        );
1226    }
1227
1228    /// The entry-count term of the framing allowance must be ceilinged,
1229    /// not saturating: an absurd `max_entries` must not inflate the decode
1230    /// budget toward `u64::MAX` (fail-open decode bomb).
1231    #[test]
1232    fn tar_gz_decode_budget_is_fail_closed_under_absurd_entry_caps() {
1233        // Per-entry derivation at the default path cap: 512 (header)
1234        // + 512 (extension header) + 4,608 (padded extension data for a
1235        // 4,096-byte name) + 511 (payload padding) = 6,143.
1236        assert_eq!(tar_per_entry_framing(4096), 6143);
1237        // Default config: 10,000 entries x 6,143 (~58.6 MiB) stays under
1238        // the 64 MiB ceiling, so the ceiling does not bite by default.
1239        assert_eq!(
1240            tar_gz_decode_budget(&TarSplitConfig::default()),
1241            1024 * 1024 * 1024 + 61_430_000 + 64 * 1024
1242        );
1243
1244        let config = TarSplitConfig {
1245            max_entries: usize::MAX,
1246            max_total_decoded_size: 1024,
1247            ..Default::default()
1248        };
1249        let budget = tar_gz_decode_budget(&config);
1250        assert_eq!(
1251            budget,
1252            1024 + MAX_TAR_FRAMING_ALLOWANCE + BASE_TAR_FRAMING_ALLOWANCE,
1253            "the entry-count term must hit the absolute ceiling, not saturate"
1254        );
1255        assert!(budget < u64::MAX, "the budget must stay fail-closed finite");
1256    }
1257
1258    /// GNU longname ('L') and PAX ('x') extension blocks are physical
1259    /// stream framing without being separate entries: the constant base
1260    /// of the framing allowance must keep single-entry long-name archives
1261    /// inside the decode budget instead of falsely rejecting them.
1262    #[tokio::test]
1263    async fn tar_gz_long_name_extension_blocks_stay_within_budget() {
1264        let long_path = format!("{}file.txt", "very/long/directory/prefix/".repeat(12));
1265        assert!(long_path.len() > 100, "the path must exceed the name field");
1266
1267        // GNU: `append_data` emits a 'L' longname extension block before
1268        // the real header when the path does not fit the name field.
1269        let mut builder = tar::Builder::new(Vec::new());
1270        let mut header = tar::Header::new_gnu();
1271        header.set_size(10);
1272        header.set_mode(0o644);
1273        header.set_cksum();
1274        builder
1275            .append_data(&mut header, long_path.as_str(), b"0123456789".as_slice())
1276            .expect("append GNU long-name entry");
1277        let gnu_archive = builder.into_inner().expect("finish GNU archive");
1278
1279        // PAX: a hand-crafted 'x' extended header whose record overrides
1280        // the short header name of the following entry.
1281        let pax_record = {
1282            let body = format!(" path={long_path}\n");
1283            // The record's declared length includes its own digits.
1284            let mut total = body.len() + 1;
1285            while total.to_string().len() + body.len() != total {
1286                total += 1;
1287            }
1288            format!("{total}{body}").into_bytes()
1289        };
1290        let pax_archive = tar_archive(&[
1291            ("./PaxHeaders.0/f", b'x', pax_record.as_slice()),
1292            ("file.txt", b'0', b"0123456789".as_slice()),
1293        ]);
1294
1295        for (label, archive) in [("gnu", gnu_archive), ("pax", pax_archive)] {
1296            let gz = gzip_bytes(&archive);
1297            let results = collect_gz(
1298                TarSplitConfig {
1299                    // Tight payload cap: 10 payload bytes plus margin, so
1300                    // only the allowance keeps the decode accepted.
1301                    max_total_decoded_size: 32,
1302                    ..Default::default()
1303                },
1304                gz,
1305            )
1306            .await;
1307            assert_eq!(results.len(), 1, "{label}: one fragment expected");
1308            let ex = results[0]
1309                .as_ref()
1310                .unwrap_or_else(|e| panic!("{label}: long-name archive must split: {e}"));
1311            let path = header_string(ex, CAMEL_TAR_ENTRY_PATH);
1312            assert!(
1313                path.ends_with("file.txt") && path.len() > 100,
1314                "{label}: the long path must survive: {path}"
1315            );
1316        }
1317    }
1318
1319    /// The occurrence counter alone cannot guarantee unique emitted names:
1320    /// `a.txt`, `a.1.txt`, `a.txt` would derive `a.1.txt` twice. The
1321    /// emitted-name set is the collision authority and the index bumps
1322    /// until the candidate is free.
1323    #[tokio::test]
1324    async fn tar_split_indexed_names_never_collide_with_emitted() {
1325        let data = tar_archive(&[
1326            ("a.txt", b'0', b"first".as_slice()),
1327            ("a.1.txt", b'0', b"literal".as_slice()),
1328            ("a.txt", b'0', b"second".as_slice()),
1329        ]);
1330        let results = collect(
1331            TarSplitConfig {
1332                duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
1333                ..Default::default()
1334            },
1335            data,
1336        )
1337        .await;
1338        let paths: Vec<String> = results
1339            .iter()
1340            .map(|r| header_string(r.as_ref().expect("fragment ok"), CAMEL_TAR_ENTRY_PATH))
1341            .collect();
1342        assert_eq!(
1343            paths,
1344            [
1345                "a.txt".to_string(),
1346                "a.1.txt".to_string(),
1347                "a.2.txt".to_string()
1348            ],
1349            "the second a.txt must skip the occupied a.1.txt"
1350        );
1351
1352        // Reverse order: a literal name colliding with an already-emitted
1353        // indexed name is itself re-indexed, keeping every emitted name
1354        // unique.
1355        let data = tar_archive(&[
1356            ("a.txt", b'0', b"first".as_slice()),
1357            ("a.txt", b'0', b"second".as_slice()),
1358            ("a.1.txt", b'0', b"literal".as_slice()),
1359        ]);
1360        let results = collect(
1361            TarSplitConfig {
1362                duplicate_names_policy: DuplicatePolicy::AllowWithIndex,
1363                ..Default::default()
1364            },
1365            data,
1366        )
1367        .await;
1368        let paths: Vec<String> = results
1369            .iter()
1370            .map(|r| header_string(r.as_ref().expect("fragment ok"), CAMEL_TAR_ENTRY_PATH))
1371            .collect();
1372        assert_eq!(
1373            paths,
1374            [
1375                "a.txt".to_string(),
1376                "a.1.txt".to_string(),
1377                "a.1.1.txt".to_string()
1378            ],
1379            "the literal a.1.txt must skip the emitted indexed a.1.txt"
1380        );
1381    }
1382
1383    #[tokio::test]
1384    async fn tar_split_empty_default_is_rejected() {
1385        let results = collect(TarSplitConfig::default(), tar_archive(&[])).await;
1386        assert_eq!(results.len(), 1);
1387        let err = results[0]
1388            .as_ref()
1389            .expect_err("empty archive must fail closed by default")
1390            .to_string();
1391        assert!(
1392            err.contains("no regular entries") && err.contains("allow_empty_archive"),
1393            "expected the fail-closed empty-archive error: {err}"
1394        );
1395    }
1396}