1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! The `stuffr` command's argument surface.
//!
//! Pulled out of `main.rs` and re-exported from `lib.rs` so an integration
//! test can ask clap itself — via [`clap::CommandFactory`] — what flags and
//! subcommands exist, rather than keeping a second, hand-maintained list that
//! the examples page could silently drift out of sync with. `main.rs` still
//! owns everything that isn't the argument shape: parsing, dispatch, and the
//! examples text itself.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "stuffr",
version,
about = "Universal compression and archive toolkit"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
/// Print detailed usage examples covering every feature, then exit.
#[arg(long)]
pub examples: bool,
}
#[derive(Subcommand)]
pub enum Command {
/// Compress a file, or collect several into an archive.
Pack {
/// Input paths, or `-` for stdin.
///
/// More than one path needs an output that names a CONTAINER (`-o
/// bundle.tar`, or `--format tar`): a codec compresses one stream and
/// has nowhere to put a second.
#[arg(required = true, num_args = 1.., value_name = "PATHS")]
paths: Vec<String>,
/// Output path. Defaults to INPUT plus the format's extension.
#[arg(short, long)]
output: Option<String>,
/// Use this format instead of inferring one from the output name.
#[arg(long)]
format: Option<String>,
/// Compression level. gzip accepts 0-9.
#[arg(long)]
level: Option<i32>,
/// Overwrite an existing output.
#[arg(long)]
force: bool,
/// Skip the fsync that makes the output durable before it is published.
#[arg(long)]
no_sync: bool,
/// Use a weak fallback encoder that this build ships in place of the
/// format's real one. Worse ratio, and buffers the whole input.
#[arg(long)]
allow_weak_encoder: bool,
/// Encode with N worker threads. 0 means auto-detect.
///
/// OMITTING this flag is not the same as passing 0: without it, stuffr
/// encodes single-threaded, so the same input always produces the same
/// bytes on any machine. Multi-threaded xz and zstd split the input
/// per worker, so their output depends on the worker count.
#[arg(long)]
threads: Option<usize>,
/// Use the full detected CPU budget, uncapped.
///
/// Lifts the CPU cap only — `--memory-limit` still binds. Turbo means
/// "use my cores", not "ignore the OOM killer".
#[arg(long)]
turbo: bool,
/// Cap the memory stuffr will ask for, e.g. 512M or 2G.
///
/// On encode this bounds the worker count. Defaults to 25% of
/// available RAM, honouring cgroup limits.
#[arg(long, value_name = "SIZE")]
memory_limit: Option<String>,
/// Fail (exit 4) if anything was approximated or lost.
///
/// On the pack side that means something the walk met and could not
/// store — a socket, an undecodable name, an unreadable directory —
/// or an entry shape this container has none of, such as a directory
/// or a symlink in an `ar`.
#[arg(long)]
strict_fidelity: bool,
},
/// Decompress a file, or extract an archive's entries.
Unpack {
/// Input path, or `-` for stdin.
input: String,
/// Entries to extract; every entry when none are given.
///
/// An exact entry name, or a directory selecting everything beneath
/// it. Needs -C, which is what asks for entry-aware extraction.
#[arg(value_name = "PATTERNS")]
patterns: Vec<String>,
/// Extract the entry at this position instead of naming it. Repeatable.
///
/// 0-BASED, counting in archive order — the same number `stuffr list`
/// prints in its first column. This is how you reach an entry whose
/// name you cannot type: `Makefile` and `makefile` from a
/// case-sensitive filesystem on a case-insensitive one. Needs -C,
/// like PATTERNS, and cannot be combined with them — they are two
/// ways of saying the same thing.
///
/// It does NOT reach a record a zip's own index shadows: `unpack`
/// counts the entries the index enumerates, so a record a later one
/// hides is not at any index here — it has no number at all. `stuffr
/// salvage --list` finds those, and `salvage --index N` selects them
/// by SCAN position, which is a different numbering.
#[arg(long, value_name = "N")]
index: Vec<usize>,
/// Extract the archive's entries into this directory.
///
/// Absolute entry paths, `..` traversal and symlinks escaping this
/// directory are refused (exit 7), never silently sanitised.
#[arg(short = 'C', long, value_name = "DIR")]
directory: Option<String>,
/// Fail (exit 4) if anything was approximated or lost.
///
/// On extraction that means an entry whose mode or mtime could not be
/// restored, or one skipped for having no shape on disk.
#[arg(long)]
strict_fidelity: bool,
/// Output path. Defaults to INPUT with its extension removed.
#[arg(short, long)]
output: Option<String>,
/// Overwrite an existing output.
#[arg(long)]
force: bool,
/// Use this format instead of detecting one. Required for formats with
/// no magic bytes and no extension.
#[arg(long)]
format: Option<String>,
/// Refuse a decode expanding by more than this ratio.
#[arg(long)]
max_ratio: Option<u64>,
/// Skip the fsync that makes the output durable before it is published.
#[arg(long)]
no_sync: bool,
/// Cap the memory stuffr will ask for, e.g. 512M or 2G.
///
/// Defaults to 25% of available RAM, honouring cgroup limits.
#[arg(long, value_name = "SIZE")]
memory_limit: Option<String>,
},
/// Change a file's compression or its archive format, without unpacking
/// it to disk.
///
/// Codec mode — the container is kept (`.tar.gz` -> `.tar.zst`, `.gz` ->
/// `.xz`, `.tar.xz` -> `.tar`): the container, or the plain stream, is
/// copied byte for byte and only the codec around it changes.
///
/// Entry mode — the container changes (`.tgz` -> `.zip`, `.zip` ->
/// `.tar.xz`): every entry is read from the source and written into the
/// target under pack's rules, and what the target cannot hold (a
/// directory or symlink in an ar, a device anywhere) is skipped with a
/// fidelity warning. A hard link stays a link into tar and is written
/// as a copy of its target elsewhere, or skipped, named, when no copy
/// is available.
///
/// The input's chain is detected by content, never by its name; the
/// target's comes from OUT's name or --format.
Convert {
/// Input path, or `-` for stdin.
input: String,
/// Output path; the same as -o. Give one or the other, not both.
#[arg(value_name = "OUT")]
output_pos: Option<String>,
/// Output path, or `-` for stdout (which needs --format).
#[arg(short, long)]
output: Option<String>,
/// Use this target format instead of inferring one from the output
/// name. A codec or a container; one the name contradicts is refused.
#[arg(long)]
format: Option<String>,
/// Compression level for the target codec. gzip accepts 0-9.
///
/// Also what makes converting a file to the chain it already has
/// (`.tar.gz` -> `.tar.gz`) a re-encode rather than "nothing to
/// convert".
#[arg(long)]
level: Option<i32>,
/// Encode with N worker threads. 0 means auto-detect.
///
/// OMITTING this flag is not the same as passing 0: without it, stuffr
/// encodes single-threaded, so the same input always produces the same
/// bytes on any machine. Multi-threaded xz and zstd split the input
/// per worker, so their output depends on the worker count.
#[arg(long)]
threads: Option<usize>,
/// Use the full detected CPU budget, uncapped.
///
/// Lifts the CPU cap only — `--memory-limit` still binds. Turbo means
/// "use my cores", not "ignore the OOM killer".
#[arg(long)]
turbo: bool,
/// Use a weak fallback encoder that this build ships in place of the
/// format's real one. Worse ratio, and buffers the whole input.
#[arg(long)]
allow_weak_encoder: bool,
/// Overwrite an existing output.
#[arg(long)]
force: bool,
/// Fail (exit 4) if anything was approximated or lost.
///
/// In codec mode the container is copied byte for byte and nothing
/// is lost. In entry mode every skipped entry or missing field is a
/// warning, and this turns any of them into exit 4 — with the output
/// still written, as with pack.
#[arg(long)]
strict_fidelity: bool,
/// Refuse a decode expanding by more than this ratio.
#[arg(long)]
max_ratio: Option<u64>,
/// Cap the memory stuffr will ask for, e.g. 512M or 2G.
///
/// Bounds every source codec layer's decoder and the encoder's worker
/// count. Defaults to 25% of available RAM, honouring cgroup limits.
#[arg(long, value_name = "SIZE")]
memory_limit: Option<String>,
/// Skip the fsync that makes the output durable before it is published.
#[arg(long)]
no_sync: bool,
},
/// Decompress a file, or stream an archive's entries, to stdout.
Cat {
/// Input path, or `-` for stdin.
input: String,
/// Entries whose bytes to stream, in archive order.
///
/// An exact entry name, or a directory selecting everything beneath
/// it. Naming one is what asks for entry-aware streaming; without
/// any, `cat` decodes the input as a single stream.
#[arg(value_name = "PATTERNS")]
patterns: Vec<String>,
/// Stream the entry at this position instead of naming it. Repeatable.
///
/// 0-BASED, counting in archive order — the same number `stuffr list`
/// prints in its first column. `stuffr cat a.zip --index 6 > out.sh`
/// is how you pull out an entry whose name is ambiguous or
/// untypeable. Like PATTERNS, naming one asks for entry-aware
/// streaming; the two cannot be combined.
#[arg(long, value_name = "N")]
index: Vec<usize>,
/// Use this format instead of detecting one. Required for formats with
/// no magic bytes and no extension.
#[arg(long)]
format: Option<String>,
/// Refuse a decode expanding by more than this ratio.
#[arg(long)]
max_ratio: Option<u64>,
/// Cap the memory stuffr will ask for, e.g. 512M or 2G.
///
/// Defaults to 25% of available RAM, honouring cgroup limits.
#[arg(long, value_name = "SIZE")]
memory_limit: Option<String>,
},
/// Identify a stream without decoding it.
Info {
/// Input path, or `-` for stdin.
input: String,
/// Emit machine-readable JSON instead of the human-readable report.
#[arg(long)]
json: bool,
/// Cap the memory stuffr will ask for, e.g. 512M or 2G.
///
/// Defaults to 25% of available RAM, honouring cgroup limits.
#[arg(long, value_name = "SIZE")]
memory_limit: Option<String>,
},
/// List the formats this build contains.
Formats,
/// Create compatibility symlinks (`bzip2`, `bunzip2`, `bzcat`,
/// `bzip2recover`) to this binary in a directory. Opt-in, never automatic.
InstallLinks {
/// Directory to put the links in. It must already exist.
#[arg(long, value_name = "DIR")]
dir: std::path::PathBuf,
/// Only these names, comma-separated or repeated. Default: all.
#[arg(long, value_name = "NAME", value_delimiter = ',')]
names: Vec<String>,
/// Replace an existing file or foreign link of the same name.
#[arg(long)]
force: bool,
/// Remove stuffr's links instead of creating them.
#[arg(long)]
remove: bool,
/// Say what would change, and change nothing.
#[arg(long)]
dry_run: bool,
},
/// List an archive's entries without extracting.
///
/// The first column is the entry's 0-based position in archive order,
/// which is what `cat --index` and `unpack --index` select by.
#[command(alias = "ls")]
List {
/// Archive path, or `-` for stdin.
input: String,
/// Emit machine-readable JSON instead of one entry per line.
#[arg(long)]
json: bool,
/// Refuse a codec layer beneath the container expanding by more
/// than this ratio (e.g. a `.tar.gz` bomb).
#[arg(long)]
max_ratio: Option<u64>,
/// Cap the memory stuffr will ask for, e.g. 512M or 2G.
///
/// Bounds the codec layer beneath the container, which `--max-ratio`
/// cannot see: a pure xz/lzma/lzip decoder sizes its dictionary from
/// a value the file declares in its own header, before producing any
/// output. Defaults to 25% of available RAM, honouring cgroup limits.
#[arg(long, value_name = "SIZE")]
memory_limit: Option<String>,
/// Fail (exit 4) if the read approximated or lost anything.
///
/// A listing can be incomplete — a zip whose index shadows records
/// enumerates fewer entries than the archive declares — so `list`
/// gates on fidelity for the same reason `test` and `unpack -C` do.
#[arg(long)]
strict_fidelity: bool,
},
/// Verify every entry's integrity without extracting.
Test {
/// Archive path, or `-` for stdin.
input: String,
/// Refuse a codec layer beneath the container expanding by more
/// than this ratio (e.g. a `.tar.gz` bomb).
#[arg(long)]
max_ratio: Option<u64>,
/// Cap the memory stuffr will ask for, e.g. 512M or 2G.
///
/// Bounds the codec layer beneath the container, which `--max-ratio`
/// cannot see: a pure xz/lzma/lzip decoder sizes its dictionary from
/// a value the file declares in its own header, before producing any
/// output. Defaults to 25% of available RAM, honouring cgroup limits.
#[arg(long, value_name = "SIZE")]
memory_limit: Option<String>,
/// Fail (exit 4) if the read approximated or lost anything.
#[arg(long)]
strict_fidelity: bool,
},
/// Recover what a damaged zip's scan can prove, even where its central
/// directory alone would show less — or would wrongly unify two records
/// that happen to share a name.
///
/// The one recovery-biased verb in this tool. `list`, `cat`, `unpack` and
/// `test` all stay exactly as uncompromising as they already are, and it
/// is salvage's willingness to accept less-than-proven content that buys
/// them that freedom.
Salvage {
/// Input path.
///
/// Salvage scans back and forth over the archive, which needs real
/// random access — unlike every other verb here, this one does not
/// accept `-` for stdin.
input: String,
/// Print every record the scan found, including ones this build
/// never writes (a shadow, a skipped partial, an unverified entry).
///
/// A record that repeats an earlier record's name is marked one of
/// two ways, and they mean different things. `[shadowed: dup of #N]`
/// is a MEASUREMENT: name, declared length and checksum all agree
/// with record N, so nothing is lost by not writing this one.
/// `[name collision: #N uses this name too]` is the weaker fact:
/// the name repeats but the content was not proven identical, so
/// both records are worth having and `-C` writes the second one as
/// `name.salvaged-N`, never over the first. It is the marker
/// `stuffr list`'s own "repeat a name … and are shadowed" fidelity
/// warning corresponds to.
///
/// `stuffr list` on the same archive can print FEWER rows than this:
/// a record a later one shadows is invisible to `list`, which only
/// ever sees what the central directory's own name index kept. Works
/// with no destination at all (neither `-C` nor `-o`) — listing is
/// not recovering, and this is the diagnostic half of the feature:
/// seeing what an archive holds without extracting anything.
#[arg(long)]
list: bool,
/// Recover entries into this directory.
///
/// One of `-C`, `-o` or `--list` is required. Mutually exclusive
/// with `-o`: a directory tree and one named file are two different
/// destinations.
///
/// A damaged archive can hold two records under one name. The
/// second one recovered lands as `name.salvaged-N`, where N is its
/// scan position, rather than over the first — both records exist,
/// both are worth having, and the run exits 4 so a script knows a
/// name had to be changed.
#[arg(short = 'C', long, value_name = "DIR")]
directory: Option<String>,
/// Recover exactly one entry's bytes to this file, naming it with
/// exactly one `--index N`.
///
/// A `Partial` entry still lands under `FILE.partial`, never `FILE`
/// itself — the same rule `-C` follows, for the same reason: a
/// truncated file under the name you asked for would be
/// indistinguishable from a whole one. Refused (exit 2) with more
/// than one `--index`, or together with `-C`.
#[arg(short = 'o', long, value_name = "FILE")]
output: Option<String>,
/// Recover only the record at this SCAN position instead of every
/// one found. Repeatable with `-C`; `-o` takes exactly one.
///
/// A scan position is 0-based and counts every record the scan
/// found, shadowed ones included — a DIFFERENT numbering from
/// `stuffr list`'s own index, which never counts a shadowed record at
/// all. On an archive where a name is duplicated, `list --index 6`
/// and `salvage --index 6` can name different entries, or `list`'s
/// may not exist at all. Always a scan position, never a list index.
#[arg(long, value_name = "N")]
index: Vec<usize>,
/// What to do with an entry whose payload could not be fully proven:
/// `keep` (default) writes it as `name.partial`, never under its
/// real name; `skip` recovers nothing for it; `ask` has no terminal
/// to ask from at this layer and is read conservatively as `skip`.
#[arg(long, value_name = "POLICY")]
partial: Option<String>,
/// Raise the structural ceiling on a single entry's declared length,
/// e.g. 8G. A declaration over the ceiling is refused before
/// anything is allocated on its say-so, either way.
#[arg(long, value_name = "SIZE")]
max_entry: Option<String>,
/// Demand proof: skip every partial entry regardless of `--partial`,
/// and every unattested one (a format with no checksum and no header
/// self-check, whose entries are otherwise written under their real
/// names). The strict path every other verb in this tool already
/// stands on, applied here on request instead of by default.
#[arg(long)]
strict: bool,
/// Use this format instead of detecting one.
///
/// Selects among the formats this build's salvage can attempt to
/// scan — `zip`, `tar`, `ar`, `cpio`, `arc`, `zoo`, `lha`, `arj` — not
/// merely accepted or rejected as in an earlier stage. Naming one
/// this build has no salvage scanner for is refused at exit 3,
/// exactly as an undetected archive of that same format would be.
/// `ar`'s scanner walks members in order and cannot cross a damaged
/// header; it says on stderr where it stopped.
///
/// Scanners exist for: zip, arc, zoo, lha, arj, tar, cpio, ar.
#[arg(long)]
format: Option<String>,
},
}