ferrosys-cli 0.3.0

Command-line ext2/3/4 formatter, inspector, and extractor built on ferrosys
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
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
//! `ferrosys` — write, inspect, and read back ext2/3/4 filesystems.
//!
//! The binary is a shell around the `ferrosys` library: it parses a command line,
//! opens files, and renders results. Every decision about what an ext filesystem *is*
//! belongs to the library.
//!
//! # Exit codes
//!
//! The codes mirror `e2fsck`'s, and the line between 4 and 8 is whether an opinion about
//! a filesystem could be formed at all:
//!
//! - `0` — the command did what it was asked, and any filesystem it read is sound.
//! - `4` — a filesystem was read and it is bad.
//! - `8` — the command could not be carried out: the host got in the way, or the bytes
//!   given are not an ext filesystem, so there is no filesystem to have an opinion
//!   about.
//! - `16` — the command line could not be understood.
//!
//! # Streams
//!
//! The standard output carries exactly one artifact per run: a report, a listing, a tar
//! stream, or one file's bytes. Everything else — progress, warnings, errors, the summary
//! a format prints — goes to the standard error, so the output of a run is always
//! something a pipe can consume whole. The output is flushed explicitly and the result
//! checked, so a closed or full pipe fails the run rather than truncating it in silence.
//!
//! # Determinism
//!
//! Everything an image's bytes depend on is an input the tool is given. A format's UUID is
//! required, its time is required (or comes from `SOURCE_DATE_EPOCH`), and its hash seed
//! defaults to the UUID's bytes — so two runs given the same inputs write the same bytes,
//! always.

// The tool inherits the library's bar: there is no `unsafe` here, ever.
#![forbid(unsafe_code)]

mod args;
mod dest;
mod detect;
mod extract;
mod format;
mod identity;
mod inspect;
mod json;
mod parse;
mod render;

use std::ffi::OsString;
use std::io::{self, Write};
use std::path::Path;
use std::process::ExitCode;

use ferrosys::DetectError;
// The directory source `--from-dir` walks with, and the failures it reports, exist on the
// platform the library builds it for. Every other command and option is the same
// everywhere; `format::from_dir` is where the difference is confined.
#[cfg(any(target_os = "linux", target_os = "android"))]
use ferrosys::ext::HostError;
use ferrosys::ext::{ArchiveError, FormatError, GeometryError, IdentityError, ReadError, Severity};

use crate::args::{Command, Topic, UsageError};

/// The exit codes, mirroring `e2fsck`'s.
pub mod exit {
    /// The command did what it was asked.
    pub const OK: u8 = 0;
    /// A filesystem was read, and it is bad.
    pub const IMAGE_BAD: u8 = 4;
    /// The command could not be carried out at all.
    pub const OPERATIONAL: u8 = 8;
    /// The command line could not be understood.
    pub const USAGE: u8 = 16;
}

/// A run that did not finish, and the exit code that reports it.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// The command line could not be understood.
    #[error(transparent)]
    Usage(#[from] UsageError),
    /// A file could not be opened, read, or written.
    #[error("{what}: {source}")]
    Io {
        /// What was being read or written.
        what: String,
        /// The failure.
        #[source]
        source: io::Error,
    },
    /// The destination of a format is not a regular file.
    ///
    /// Formatting writes only the blocks the filesystem uses and extends the destination
    /// to its full size with a single byte at the end, so every byte it does not write
    /// must already read as zero. A freshly created or truncated regular file satisfies
    /// that; a block device does not, and formatting one would leave whatever it held
    /// interleaved with the new filesystem.
    #[error(
        "{0}: not a regular file — a format writes only the blocks the filesystem uses, \
         so every other byte of the destination must already read as zero"
    )]
    NotARegularFile(String),
    /// No compiled-in family recognized the image, so there is nothing to classify it as.
    #[error("{path}: {source}")]
    NotDetected {
        /// The file that was opened.
        path: String,
        /// What detection made of it.
        #[source]
        source: DetectError,
    },
    /// The bytes are not an ext filesystem: no superblock could be read from them, so
    /// there is nothing to have an opinion about.
    #[error("{path}: not an ext filesystem: {source}")]
    NotExt {
        /// The file that was opened.
        path: String,
        /// What the reader made of it.
        #[source]
        source: ReadError,
    },
    /// Re-identifying an image failed.
    #[error("{path}: {source}")]
    Identity {
        /// The image that was being rewritten.
        path: String,
        /// What refused it.
        #[source]
        source: ferrosys::ext::IdentityError,
    },
    /// Writing the filesystem failed.
    #[error(transparent)]
    Format(#[from] FormatError),
    /// The source archive could not be read.
    #[error(transparent)]
    Archive(#[from] ArchiveError),
    /// The source directory tree could not be walked.
    #[cfg(any(target_os = "linux", target_os = "android"))]
    #[error(transparent)]
    Host(#[from] HostError),
    /// `--from-dir` was given on a platform that has no directory source to walk with.
    #[cfg(not(any(target_os = "linux", target_os = "android")))]
    #[error(
        "--from-dir is not available on this platform: walking a tree records Linux inode \
         metadata and Linux extended attributes, so the directory source is built on Linux \
         alone. --from-tar reads an archive anywhere"
    )]
    NoDirectorySource,
    /// `--to-dir` was given on a platform that has no directory sink to write with.
    #[cfg(not(any(target_os = "linux", target_os = "android")))]
    #[error(
        "--to-dir is not available on this platform: writing a tree out sets Linux inode \
         metadata and Linux extended attributes, so the directory sink is built on Linux \
         alone. --to-tar writes the same contents as an archive anywhere"
    )]
    NoDirectorySink,
    /// The image could not be read as far as it had to be.
    #[error("reading the image: {0}")]
    ImageIo(String),
    /// A path the filesystem does not have.
    #[error("no such path in the filesystem: {}", String::from_utf8_lossy(.0))]
    NoSuchPath(Vec<u8>),
    /// A filesystem was read, and it is bad.
    #[error("the filesystem is malformed: {0}")]
    Image(#[source] ReadError),
    /// The scan found what the caller asked to be told about.
    #[error(
        "the filesystem holds {}{count} {}, the worst of them {}",
        if *truncated { "at least " } else { "" },
        if *count == 1 { "anomaly" } else { "anomalies" },
        worst.as_str()
    )]
    Verdict {
        /// How many anomalies the scan found.
        count: usize,
        /// The severity of the most serious one.
        worst: Severity,
        /// Whether the scan stopped at its cap with the image unfinished, which makes the
        /// count and the severity a floor rather than the whole account.
        truncated: bool,
    },
    /// `--cat` named something that is not a regular file.
    #[error("{}: not a regular file", String::from_utf8_lossy(.0))]
    NotAFile(Vec<u8>),
}

impl Error {
    /// The option that resolves this failure, where one does.
    ///
    /// A geometry a filesystem cannot hold and a journal it has no room for are both
    /// failures of a *default* rather than of anything the caller typed, so the message
    /// alone leaves them with nothing to change. The hint names the option that decides the
    /// thing at fault. Every other failure names its own cause and gets none.
    fn hint(&self) -> Option<String> {
        match self {
            // The growth reservation is part of group 0's overhead, and on a small
            // filesystem it can be most of it. `--grow` is what decides it.
            Error::Format(FormatError::Geometry(GeometryError::TooSmall {
                reserved_gdt_blocks,
                ..
            })) if *reserved_gdt_blocks > 0 => Some(format!(
                "{reserved_gdt_blocks} of those blocks are growth headroom: `--grow none` \
                 reserves none, and `--grow SIZE` reserves only what growing to SIZE needs"
            )),
            // `orphan_file` is part of the default profile and requires a journal, so
            // `-O ^has_journal` on its own is refused at parse time. Both spellings here
            // are ones the tool accepts.
            Error::Format(FormatError::FilesystemTooSmallForJournal { minimum, .. }) => {
                Some(format!(
                    "a journal needs {minimum} blocks of its own: `-t ext2` builds a \
                     filesystem without one, as does `-O ^has_journal,^orphan_file`"
                ))
            }
            Error::Format(FormatError::JournalDoesNotFit { .. }) => Some(
                "`--journal N` sets the log's size in filesystem blocks, and `-t ext2` \
                 builds without one"
                    .to_string(),
            ),
            _ => None,
        }
    }

    /// The exit code this failure reports.
    fn exit_code(&self) -> u8 {
        match self {
            Error::Usage(_) => exit::USAGE,
            // A malformed filesystem is an opinion formed about one; everything else here
            // is a failure to form one at all.
            Error::Image(_) | Error::Verdict { .. } => exit::IMAGE_BAD,
            // Writing an archive out of an image reads that image, so some of what an
            // archive failure carries is a verdict about the filesystem: a structure that
            // cannot be read, and a stored ACL that does not decode, are the image's faults.
            // A socket, an unwritable attribute name, and a destination that cannot be
            // written are not — the filesystem is sound and the request cannot be carried
            // out.
            Error::Archive(ArchiveError::Read(e)) => match e {
                ReadError::Io { .. } => exit::OPERATIONAL,
                _ => exit::IMAGE_BAD,
            },
            Error::Archive(ArchiveError::Acl { .. }) => exit::IMAGE_BAD,
            // Re-identifying splits the same way. A superblock that does not check and a
            // backup copy that is not one are verdicts about the image; a UUID that would
            // invalidate the checksums, and a seed asked for where it does nothing, are
            // verdicts about the *request* — the filesystem is sound and the change cannot
            // be made — so they read as a command that could not be carried out.
            Error::Identity { source, .. } => match source {
                IdentityError::Read(ReadError::Io { .. }) | IdentityError::Io(_) => {
                    exit::OPERATIONAL
                }
                IdentityError::Read(_)
                | IdentityError::BackupNotASuperblock { .. }
                | IdentityError::SuperblockChecksumMismatch { .. } => exit::IMAGE_BAD,
                _ => exit::OPERATIONAL,
            },
            // A tree that cannot be walked and a platform with no walk to run are both
            // failures to carry the command out, so both land here; only one of them
            // exists in any one build.
            #[cfg(any(target_os = "linux", target_os = "android"))]
            Error::Host(_) => exit::OPERATIONAL,
            #[cfg(not(any(target_os = "linux", target_os = "android")))]
            Error::NoDirectorySource | Error::NoDirectorySink => exit::OPERATIONAL,
            Error::Io { .. }
            | Error::NotARegularFile(_)
            | Error::NotDetected { .. }
            | Error::NotExt { .. }
            | Error::Format(_)
            | Error::Archive(_)
            | Error::ImageIo(_)
            | Error::NoSuchPath(_)
            | Error::NotAFile(_) => exit::OPERATIONAL,
        }
    }

    /// An I/O failure against a named file.
    fn io(what: impl AsRef<Path>, source: io::Error) -> Self {
        Error::Io {
            what: what.as_ref().display().to_string(),
            source,
        }
    }
}

/// A read that failed after the filesystem opened.
///
/// The filesystem was there to be read, so a structural failure is the image's: it is
/// what a bad image looks like. A failure of the host's (the source could not be read) or
/// of the caller's (a path the filesystem does not have) is neither, and says nothing
/// about whether the filesystem is sound.
fn from_read(e: ReadError) -> Error {
    match e {
        ReadError::Io { message, .. } => Error::ImageIo(message),
        ReadError::NotFound { path, .. } | ReadError::NotADirectory { path, .. } => {
            Error::NoSuchPath(path)
        }
        other => Error::Image(other),
    }
}

fn main() -> ExitCode {
    let argv: Vec<OsString> = std::env::args_os().skip(1).collect();
    // The one environment value the tool honours, and the only input it does not take on
    // the command line. It supplies `format --time`; nothing else consults it.
    let source_date_epoch = std::env::var_os("SOURCE_DATE_EPOCH");

    match run(argv, source_date_epoch) {
        Ok(()) => ExitCode::from(exit::OK),
        Err(e) => {
            eprintln!("{}: {e}", args::TOOL);
            if let Some(hint) = e.hint() {
                eprintln!("hint: {hint}");
            }
            if matches!(e, Error::Usage(_)) {
                eprintln!("try `{} --help`", args::TOOL);
            }
            ExitCode::from(e.exit_code())
        }
    }
}

/// Parse the command line and carry out what it asked for.
fn run(argv: Vec<OsString>, source_date_epoch: Option<OsString>) -> Result<(), Error> {
    match args::parse(argv, source_date_epoch)? {
        Command::Format(a) => format::run(*a),
        Command::Inspect(a) => inspect::run(a),
        Command::Extract(a) => extract::run(a),
        Command::Detect(a) => detect::run(a),
        Command::Identity(a) => identity::run(a),
        // Help and the version are what the run was asked to produce, so they are the
        // artifact and go to the standard output.
        Command::Help(topic) => emit(help(topic).as_bytes()),
        Command::Version => {
            emit(format!("{} {}\n", args::TOOL, env!("CARGO_PKG_VERSION")).as_bytes())
        }
    }
}

/// Write the run's one artifact to the standard output, flushing it and checking the
/// result — so a closed or full pipe fails the run rather than truncating it in silence.
fn emit(bytes: &[u8]) -> Result<(), Error> {
    let stdout = io::stdout();
    let mut out = stdout.lock();
    out.write_all(bytes).map_err(stdout_failed)?;
    out.flush().map_err(stdout_failed)
}

/// The standard output could not be written or flushed.
fn stdout_failed(source: io::Error) -> Error {
    Error::Io {
        what: "standard output".to_string(),
        source,
    }
}

/// The usage text for one topic.
fn help(topic: Topic) -> &'static str {
    match topic {
        Topic::General => GENERAL_HELP,
        Topic::Format => FORMAT_HELP,
        Topic::Inspect => INSPECT_HELP,
        Topic::Extract => EXTRACT_HELP,
        Topic::Detect => DETECT_HELP,
        Topic::Identity => IDENTITY_HELP,
    }
}

const GENERAL_HELP: &str = "\
ferrosys — write, inspect, and read back ext2/3/4 filesystems

usage:
  ferrosys format  [options] OUT.img    write a filesystem
  ferrosys inspect [options] IMAGE      report on a filesystem
  ferrosys extract [options] IMAGE      read a filesystem's contents back out
  ferrosys detect  [options] IMAGE      say which filesystem an image holds
  ferrosys identity [options] IMAGE     change what a filesystem is known by

  ferrosys <command> --help             the options one command takes
  ferrosys --version                    the version

exit codes (as e2fsck's):
  0   the command did what it was asked
  4   a filesystem was read and it is bad
  8   the command could not be carried out
  16  the command line could not be understood

The standard output carries exactly one artifact per run — a report, a listing, a tar
stream, or one file's bytes. Everything else goes to the standard error.

The tool reads neither the clock nor a random source, so a format's output is a function
of its inputs alone: the same inputs write the same bytes.
";

const FORMAT_HELP: &str = "\
ferrosys format — write an ext2, ext3, or ext4 filesystem

usage:
  ferrosys format --size SIZE --uuid HEX --time SECS [options] OUT.img

  ferrosys format --size 512M --uuid \"$(uuidgen)\" --time \"$(date +%s)\" rootfs.img

  ferrosys format --size auto --slack 20% --from-dir staging \\
    --uuid \"$(uuidgen)\" --time \"$(date +%s)\" rootfs.img

Both --uuid and --time are required because an image's bytes are a function of its
inputs alone: the tool reads neither the clock nor a random source, so the same inputs
write the same bytes. SOURCE_DATE_EPOCH supplies --time when it is set.

required:
  --size SIZE|auto     the filesystem's size: a byte count, optionally suffixed K, M, G,
                       or T — or `auto`, which sizes the filesystem to what goes in it.
                       `auto` finds the smallest filesystem that holds the contents by
                       planning candidate sizes and placing the contents into each, so
                       the size it settles on is one that formats, and one block less
                       does not. Use --slack to leave room in it
  --uuid HEX           the filesystem UUID, dashed or bare (32 hex digits). The tool
                       mints none: pipe in `uuidgen`, of whatever version you like
  --time SECS          the filesystem's creation time, in seconds since the epoch. Taken
                       from SOURCE_DATE_EPOCH when the option is absent

contents (at most one):
  --from-tar FILE|-    populate the filesystem from a tar archive. A named FILE is left on
                       disk and each member read as its file is placed, so peak memory is
                       the largest single member; `-` reads the standard input, which
                       cannot be sought back over and so is held whole. The archive must
                       be uncompressed — decompress it into `-` with `gunzip -c f.tar.gz |
                       ferrosys format ... --from-tar -`
  --from-dir DIR       populate the filesystem from a directory tree on this machine. DIR
                       itself becomes the filesystem root. Modes, ownership, all three
                       times, symlinks, hard links, device and FIFO nodes, sockets, and
                       extended attributes with their POSIX ACLs are all carried; symlinks
                       are recorded, never followed. Each file is read as it is placed, so
                       peak memory is the largest single file. Walking a tree records Linux
                       inode metadata and Linux extended attributes, so this option is
                       carried out on Linux alone; --from-tar reads an archive anywhere
  --owner UID:GID      own every entry of a --from-dir tree by this user and group,
                       whatever the host files say. A build that does not run as root
                       usually wants --owner 0:0: without it the image is owned by the
                       user that built it

labelling:
  --label NAME         the volume label, up to 16 bytes. A longer one is refused rather
                       than truncated

profile:
  -t, --type ext2|ext3|ext4   the base feature set to write (default ext4). -O and the
                       geometry options layer on top, so `-t ext2 -O has_journal` is ext3.
                       The image is judged by the features it carries, not the profile it
                       started from

geometry:
  --slack PCT%|SIZE    with --size auto, how much of the filesystem must still be free
                       once the contents are written: `20%` of it, or `64M` of it. Without
                       this, `auto` leaves nothing — the right answer for an image that
                       will only be read, and useless for one that will be written to.
                       The share is of the finished filesystem, so `--slack 20%` is what
                       `df` reports as 80% used. Up to 90%
  --block-size N       1024, 2048, or 4096 (the default)
  --inode-size N       a power of two from 128 up to the block size (default 256)
  --inodes N           the inode count, rounded up to fill each group's tables. Overrides
                       the size-driven default
  --bytes-per-inode N  one inode per N bytes of filesystem — the density the count is
                       derived from. This and --inodes share one setting; the last wins
  --reserved-percent P blocks held back for the super-user, from 0 to 50, with up to two
                       decimal places (default 5)
  -O feat,^feat,none   turn features on and off, left to right, over the selected profile.
                       `none` clears every feature. The names are the on-disk ones:
                       64bit, metadata_csum, has_journal, … . filetype names the directory
                       format this tool always writes, so clearing it is refused. Clearing
                       extent drops to the block-mapped ext2/ext3 family, which then carries
                       none of the ext4-layer features (flex_bg, 64bit, metadata_csum, …);
                       -t is the direct way to that base
  --grow none|max|SIZE reserved descriptor blocks, which are what let the filesystem grow
                       online without relocating its descriptor table. They are empty
                       blocks held at the front of the filesystem and cost free space 1:1,
                       and a filesystem that will not grow has no use for them:
                         none   reserve nothing. The smallest image, and still growable
                                offline by an unmounted `resize2fs`
                         SIZE   reserve exactly what growing online to SIZE needs — 3
                                blocks for 32G, 127 for 1T. The precise answer when the
                                largest device the image will be written to is known
                         max    (default) as much as the format allows without spending
                                more than 1/64 of the filesystem on it: the full ~8 TiB
                                reach from 256M up, and a proportional share below that,
                                so a 16M image reserves 64 blocks and still grows to 512G
                       The format summary reports what was reserved and what is left free
  --journal auto|N     the journal's size in filesystem blocks (default: sized from the
                       filesystem). The journal is a real file and costs its size in free
                       space — 4 MiB of a 16 MiB filesystem — so `-t ext2` is the way to
                       build a small filesystem without one
  --errors continue|remount-ro|panic   what the kernel does on a detected filesystem
                       error (`s_errors`): note it and carry on, remount read-only, or
                       panic. Defaults to `continue`, the kernel's own default

determinism:
  --fixed-time SECS    force every inode's times to this value, whatever the source says
  --hash half_md4|tea|legacy       the directory-hash algorithm (default half_md4)
  --hash-signedness signed|unsigned  how a name's bytes are read when hashed. Unsigned by
                       default, which makes the bytes independent of the host
  --hash-seed HEX      the 16-byte directory-hash seed. Defaults to the UUID's bytes

output:
  --json               print the geometry the format realized, as JSON, on the standard
                       output. Without it, a summary goes to the standard error
  --dry-run            report the geometry this command would realize and write nothing.
                       The destination is not opened, created, or truncated

destination:
  --atomic             write the image to a sibling temporary file and rename it over the
                       destination once it is complete, so the destination holds either the
                       image that was there before or the whole new one — never a partial
                       one. Note that it becomes a new file: its mode comes from this
                       process's umask, and any ownership, ACLs, or extra hard links the
                       old file had do not survive. Without it the image is written in
                       place, and a failure part-way through leaves a partial image

The destination must be a regular file. A format writes only the blocks the filesystem
uses, so every byte it does not write must already read as zero — which a block device
does not guarantee, and which is why the file is created or truncated as part of
formatting. That happens only once the archive has parsed and the geometry has planned, so
a run that fails for any other reason leaves the file that was there untouched.
";

const INSPECT_HELP: &str = "\
ferrosys inspect — report on an ext filesystem

usage:
  ferrosys inspect [options] IMAGE

options:
  --offset N           where the filesystem begins within the file, for a partition
                       inside a whole-disk image. A byte count, optionally suffixed K, M,
                       G, or T
  --json               report as JSON rather than as text
  --sarif              report the scan's findings as a SARIF 2.1.0 log, for a static-
                       analysis or forensic pipeline; reports findings alone, not the
                       superblock description, so it needs the scan and cannot pair with
                       --quick
  --groups             report every block group's descriptor as well
  --quick              report the superblock alone, without scanning the image
  --fail-on SEVERITY|never
                       the severity at which the scan's findings make the filesystem bad:
                       cosmetic, conformance, integrity (the default), structural, or
                       never. `integrity` faults a filesystem whose own bytes contradict
                       each other; `conformance` also faults one that is valid ext but not
                       the form this tool writes, which is a check on this tool's own output
                       rather than on ext

The whole image is scanned unless --quick says otherwise, so an image that is bad is
reported as bad (exit 4) rather than merely described. What counts as bad is --fail-on,
which defaults to `integrity`: a filesystem whose own bytes contradict each other fails,
and a valid ext filesystem that another tool wrote does not. That is the default a CI
gate inherits, and `--fail-on conformance` is the stricter line to draw deliberately.
";

const EXTRACT_HELP: &str = "\
ferrosys extract — read an ext filesystem's contents back out

usage:
  ferrosys extract [--offset N] IMAGE --to-tar FILE|-
  ferrosys extract [--offset N] IMAGE --to-dir DIR
  ferrosys extract [--offset N] IMAGE --cat PATH
  ferrosys extract [--offset N] IMAGE --stat PATH [--json]
  ferrosys extract [--offset N] IMAGE --list [--json]

exactly one of:
  --to-tar FILE|-      write the whole tree as a tar archive; `-` writes the standard
                       output. Ownership, modes, times (to the nanosecond), symlinks,
                       hard links, device and FIFO nodes, extended attributes, and POSIX
                       ACLs all survive, carried in PAX records
  --to-dir DIR         write the whole tree into a directory on this host, the inverse of
                       `format --from-dir`. DIR is made if it is not there and must be
                       empty. Everything the archive carries is carried here too, set on
                       the files themselves; DIR takes the filesystem root's own mode,
                       ownership, times, and attributes, and `/lost+found` is not written.
                       A device node needs CAP_MKNOD and a recorded owner needs CAP_CHOWN,
                       so an unprivileged run stops at the first of either unless
                       --skip-privileged is given. Two things no host lets a caller set:
                       an inode's change time and its creation time, so the tree carries
                       the times it was written for those two alone
  --cat PATH           write one file's bytes to the standard output, and nothing else.
                       PATH is a path inside the image, taken as the bytes you typed
  --stat PATH          report everything the filesystem records about one path: its type,
                       mode (octal and symbolic), ownership, link count, size, all four
                       times, a device node's numbers, a symlink's target, and its extended
                       attributes with any POSIX ACL decoded. A path naming a symlink
                       describes the link, not its target; --json reports it as JSON
  --list               list the tree; --json lists it as JSON, with each entry's extended
                       attributes and decoded ACLs

options:
  --offset N           where the filesystem begins within the file
  --max-file-bytes N   refuse to read a file larger than N bytes, for an image whose
                       declared sizes have not earned trust: a file's size is the image's
                       own claim, and a sparse file legitimately dwarfs the filesystem
                       holding it, so nothing structural bounds it. Over the cap the read
                       is an error rather than a short file
  --skip-privileged    with --to-dir, write what this process may rather than failing on
                       what it may not: a device node it cannot create is left out, the
                       tree is owned by this process, and a security or trusted extended
                       attribute it may not set is not set. What was left out is named on
                       the standard error, so an incomplete tree says so
  --atomic             with --to-tar FILE, write the archive to a sibling temporary file
                       and rename it over FILE once the walk is complete, so a walk that
                       fails part-way leaves whatever was at FILE untouched. --to-dir has
                       no equivalent — no rename publishes a whole tree at once — which is
                       why its destination must start empty

Reading holds no whole file: --cat streams to the standard output and --to-tar streams each
member into the archive, so a multi-gigabyte file costs a working set rather than its size.

The archive holds a `./` member for the root and skips `/lost+found`, so what comes out
is what `ferrosys format --from-tar` reads back in; --to-dir writes the tree the same way,
so `format --from-dir` reads that one back.

A JSON mode's `mode` field is the permission bits as a decimal number, since JSON has no
octal literal — 509 is 0o775 — and `mode_octal` beside it carries the usual spelling.
";

const DETECT_HELP: &str = "\
ferrosys detect — say which filesystem an image holds

usage:
  ferrosys detect [--offset N] [--json] IMAGE

options:
  --offset N           where the filesystem begins within the file, for a partition inside
                       a whole-disk image or a region a carver located. A byte count,
                       optionally suffixed K, M, G, or T
  --json               report as JSON rather than as one word

The answer is one word on the standard output — ext2, ext3, ext4, or `unrecognized` — so it
reads well in a shell test. An unrecognized image exits 8, since there is no filesystem to
have an opinion about. A fourth word, `unknown`, is the answer when the library classifies
a family this build has no name for: something recognized the image, so calling it
unrecognized would be wrong.

This asks what an image *is*, not whether it is sound: an image with a quirk `inspect` would
refuse still classifies here. Use `inspect` to be told whether a filesystem is well-formed.
";

const IDENTITY_HELP: &str = "\
ferrosys identity — change what an existing filesystem is known by

usage:
  ferrosys identity [--uuid HEX] [--label TEXT] [--set-checksum-seed] [--json] IMAGE

options:
  --uuid HEX           the new filesystem UUID: 32 hex digits, dashed or bare
  --label TEXT         the new volume label, at most 16 bytes
  --set-checksum-seed  record the seed the current UUID implies and set
                       metadata_csum_seed, so the UUID can change without invalidating the
                       filesystem's metadata checksums
  --json               report what was written as JSON rather than as text

At least one of --uuid, --label, and --set-checksum-seed is required: a run that would
write nothing is a command line that meant to say something.

Every superblock copy is rewritten — the primary and each group's backup — along with the
journal's own record of the UUID, so no copy is left claiming the old identity. Each copy
is patched in place: it keeps every field this change does not name.

Nothing is written until every copy has been read and every check has passed, so a refusal
leaves the image exactly as it was. There is no --atomic: an image is rewritten where it
lies, and a temporary copy would mean writing every byte of it to change sixteen.

A filesystem with metadata_csum and without metadata_csum_seed seeds every checksum it
holds from the UUID itself, so changing the UUID would invalidate all of them at once.
That is refused, and --set-checksum-seed is the way through: it records the seed the
current UUID implies, after which the UUID moves and every existing checksum stays valid.
It sets an incompatible feature, so a kernel that does not know metadata_csum_seed will
not mount the result — which is why it is asked for rather than assumed.
";

#[cfg(test)]
mod tests {
    use super::*;
    use ferrosys::ext::ondisk::Timestamp;
    use ferrosys::ext::{FormatOptions, Reader, TreeBuilder};

    #[test]
    fn every_failure_names_the_exit_code_it_reports() {
        // The line between 4 and 8: a filesystem was read and found bad, versus no
        // filesystem was read at all.
        assert_eq!(Error::Usage(UsageError::NoCommand).exit_code(), exit::USAGE);
        assert_eq!(
            Error::Image(ReadError::BadDirectory).exit_code(),
            exit::IMAGE_BAD
        );
        assert_eq!(
            Error::Verdict {
                count: 1,
                worst: Severity::Structural,
                truncated: false,
            }
            .exit_code(),
            exit::IMAGE_BAD
        );
        // A report that stopped at its cap says the count is a floor, so the verdict a
        // person reads does not claim to have counted the whole image.
        assert_eq!(
            Error::Verdict {
                count: 10_000,
                worst: Severity::Structural,
                truncated: true,
            }
            .to_string(),
            "the filesystem holds at least 10000 anomalies, the worst of them structural"
        );
        // One finding is one anomaly: the verdict is a line a person reads.
        assert_eq!(
            Error::Verdict {
                count: 1,
                worst: Severity::Integrity,
                truncated: false,
            }
            .to_string(),
            "the filesystem holds 1 anomaly, the worst of them integrity"
        );
        assert_eq!(
            Error::NotExt {
                path: "blob".into(),
                source: ReadError::BadJournal
            }
            .exit_code(),
            exit::OPERATIONAL
        );
        assert_eq!(
            Error::NoSuchPath(b"/nowhere".to_vec()).exit_code(),
            exit::OPERATIONAL
        );
    }

    #[test]
    fn a_read_failure_is_the_image_s_only_when_it_is_about_the_image() {
        // A structural failure is what a bad image looks like. Every structural variant
        // takes the same arm, so one of them stands for all.
        assert_eq!(
            from_read(ReadError::BadDirectory).exit_code(),
            exit::IMAGE_BAD
        );
        // ...while the host failing to read, or a path the filesystem does not have, says
        // nothing at all about whether the filesystem is sound. Both are taken from the
        // library rather than assembled here: a literal would prove only that this match
        // arm is spelled the way this test spells it, where a real failure proves the
        // library still reports the variant the arm is for.
        let host_failed = ReadError::from(std::io::Error::other("disk on fire"));
        assert_eq!(from_read(host_failed).exit_code(), exit::OPERATIONAL);

        let time = Timestamp::from_secs(1_700_000_000);
        let image = ferrosys::ext::format(
            TreeBuilder::new(),
            64 << 20,
            FormatOptions::new([0x11; 16], time, [0; 16]),
        )
        .expect("format a minimal image");
        let mut reader =
            Reader::open(std::io::Cursor::new(image.as_bytes())).expect("open the image");
        let absent = reader
            .lookup(b"/nowhere")
            .expect_err("the image has no such path");
        assert_eq!(from_read(absent).exit_code(), exit::OPERATIONAL);
    }

    #[test]
    fn every_help_topic_has_text() {
        for topic in [
            Topic::General,
            Topic::Format,
            Topic::Inspect,
            Topic::Extract,
            Topic::Detect,
        ] {
            let text = help(topic);
            assert!(text.starts_with("ferrosys"), "{topic:?} names the tool");
            assert!(text.contains("usage:"), "{topic:?} states its usage");
        }
        // The help is public prose describing the tool's own surface: the general topic
        // lists every subcommand a user can run, and each subcommand's help names the
        // command it documents. Asserting what the help *is* keeps it honest without
        // naming anything it must not.
        let general = help(Topic::General);
        for command in ["format", "inspect", "extract", "detect"] {
            assert!(
                general.contains(command),
                "the general help lists the `{command}` command"
            );
            assert!(
                help_for(command).contains(command),
                "the `{command}` help names its own command"
            );
        }
    }

    /// The help topic for a subcommand name, for the property check above.
    fn help_for(command: &str) -> &'static str {
        match command {
            "format" => help(Topic::Format),
            "inspect" => help(Topic::Inspect),
            "extract" => help(Topic::Extract),
            "detect" => help(Topic::Detect),
            other => panic!("no help topic for {other}"),
        }
    }
}