ferrosys-cli 0.3.0

Command-line ext2/3/4 formatter, inspector, and extractor built on ferrosys
//! Rendering values into the forms their readers require.
//!
//! Most of what this module renders comes out of an image and is rendered for a person: a
//! label, a mode, a time. [`uri_reference`] renders for a machine instead — a host path
//! in the URI dialect a SARIF consumer requires.
//!
//! This module is pure and has no calendar of its own: [`iso8601`] computes a civil date
//! from a count of seconds arithmetically, so a timestamp renders the same everywhere.
//! Every time this tool prints is UTC, computed rather than looked up.

use std::fmt::Write as _;
use std::path::Path;

use ferrosys::ext::acl::{EXEC, READ, WRITE};
use ferrosys::ext::{Acl, AclQualifier};

use crate::args::os;

/// Seconds in a day.
const DAY: i64 = 86_400;

/// The canonical dashed form of a 16-byte identifier: the filesystem UUID, and the
/// directory-hash seed, which is written the same way.
#[must_use]
pub fn uuid(bytes: &[u8; 16]) -> String {
    let mut out = String::with_capacity(36);
    for (i, b) in bytes.iter().enumerate() {
        if matches!(i, 4 | 6 | 8 | 10) {
            out.push('-');
        }
        let _ = write!(out, "{b:02x}");
    }
    out
}

/// A volume label as a person reads it: the bytes up to the first NUL, rendered lossily,
/// or `None` when the label is empty.
///
/// The field is bytes, not guaranteed text, so a non-UTF-8 label renders with the
/// replacement character rather than failing — the same forensic reading the reader gives
/// a label it did not write.
#[must_use]
pub fn label(name: &[u8; 16]) -> Option<String> {
    let end = name.iter().position(|&b| b == 0).unwrap_or(name.len());
    (end != 0).then(|| printable(&name[..end]))
}

/// Render image-controlled bytes for a person to read on a terminal, with every character
/// that acts on the terminal rather than appearing on it replaced by a visible escape.
///
/// A name, symlink target, or label comes from the filesystem, which a reader does not
/// trust: left raw, an escape sequence in one could move the cursor, recolor the line, or
/// erase what precedes it, and a direction override could reverse the rest of the line so
/// that a path reads as one thing and resolves as another. So a crafted image could forge
/// or hide output. Escaping those characters renders the value faithfully without handing
/// the terminal their effect. The backslash escapes itself, so what comes out names
/// exactly one input: a name holding the four characters `\x1b` and one holding the escape
/// byte do not render alike. Invalid UTF-8 still renders lossily, as elsewhere; the JSON
/// projection escapes these bytes on its own, so only the human renderers need this.
#[must_use]
pub fn printable(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len());
    for c in String::from_utf8_lossy(bytes).chars() {
        match c {
            // Without this the escapes below would be ambiguous, and an escape a reader
            // cannot invert is one they cannot trust.
            '\\' => out.push_str("\\\\"),
            // A control character is at most `U+009F`, so two hex digits name it.
            c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
            c if is_direction_control(c) => out.push_str(&format!("\\u{{{:04x}}}", c as u32)),
            c => out.push(c),
        }
    }
    out
}

/// Whether `c` is a bidirectional formatting character — one that reorders the text around
/// it without occupying a column of its own.
///
/// These are not `char::is_control`: they are category `Cf`, and a terminal honors them.
/// Left raw, `U+202E` alone makes the rest of a line render right to left, which is enough
/// to display a name as its own reverse. The set is closed and small, so it is named here
/// rather than reached for through a Unicode table.
fn is_direction_control(c: char) -> bool {
    matches!(
        c,
        // The marks and the embedding/override run: LRM, RLM, ALM.
        '\u{200e}' | '\u{200f}' | '\u{061c}'
        // LRE, RLE, PDF, LRO, RLO.
        | '\u{202a}'..='\u{202e}'
        // LRI, RLI, FSI, PDI.
        | '\u{2066}'..='\u{2069}'
    )
}

/// The mode as `ls` writes it: the type letter, then the owner, group, and other
/// permission triples, with the `setuid`, `setgid`, and sticky bits folded into the
/// execute positions as they are on a terminal.
#[must_use]
pub fn mode(mode: u16) -> String {
    let kind = match mode & 0o170000 {
        0o140000 => 's',
        0o120000 => 'l',
        0o100000 => '-',
        0o060000 => 'b',
        0o040000 => 'd',
        0o020000 => 'c',
        0o010000 => 'p',
        _ => '?',
    };
    let mut out = String::with_capacity(10);
    out.push(kind);
    // Each triple's execute position carries the set-id or sticky bit when one is set:
    // `s`/`t` when the execute bit is also set, `S`/`T` when it is not.
    let triple = |shift: u32, special: bool, special_set: char, special_clear: char| {
        let bits = (mode >> shift) & 0o7;
        let mut t = String::with_capacity(3);
        t.push(if bits & 4 != 0 { 'r' } else { '-' });
        t.push(if bits & 2 != 0 { 'w' } else { '-' });
        t.push(match (bits & 1 != 0, special) {
            (true, true) => special_set,
            (false, true) => special_clear,
            (true, false) => 'x',
            (false, false) => '-',
        });
        t
    };
    out.push_str(&triple(6, mode & 0o4000 != 0, 's', 'S'));
    out.push_str(&triple(3, mode & 0o2000 != 0, 's', 'S'));
    out.push_str(&triple(0, mode & 0o1000 != 0, 't', 'T'));
    out
}

/// A host path as a URI reference, for a consumer that requires one.
///
/// A path is not a URI: a space is not allowed at all, and `#`, `?`, and `%` each mean
/// something else, so a validator reading a document that carries a path verbatim rejects
/// it — SARIF's `artifactLocation.uri` is the case at hand. Every byte outside the
/// unreserved set (`A`-`Z`, `a`-`z`, `0`-`9`, `-`, `.`, `_`, `~`) is percent-encoded and
/// `/` is kept as the separator it already is, so the reference is legal to parse and
/// decodes back to the path byte for byte. Encoding `:` along with the rest keeps a
/// relative path's first segment from reading as a scheme.
///
/// A rooted path becomes an absolute `file://` URI, naming the host it was read on; any
/// other path stays a relative reference, naming what the invocation named.
#[must_use]
pub fn uri_reference(path: &Path) -> String {
    let bytes = os::bytes(path.as_os_str());
    let mut out = String::new();
    if bytes.first() == Some(&b'/') {
        out.push_str("file://");
    }
    for &b in bytes {
        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
            out.push(char::from(b));
        } else {
            let _ = write!(out, "%{b:02X}");
        }
    }
    out
}

/// A time as `YYYY-MM-DDTHH:MM:SSZ`, in UTC.
///
/// ext4 timestamps reach from 1901 to 2446, and a negative count of seconds is a time
/// before the epoch, so the arithmetic floors rather than truncates: `-1` second is
/// `1969-12-31T23:59:59Z`, not one second into 1970.
#[must_use]
pub fn iso8601(secs: i64) -> String {
    let days = secs.div_euclid(DAY);
    let rem = secs.rem_euclid(DAY);
    let (y, m, d) = civil_from_days(days);
    let (h, min, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
    format!("{y:04}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}Z")
}

/// The civil year, month, and day a count of days since 1970-01-01 names, in the
/// proleptic Gregorian calendar.
///
/// The computation shifts the epoch to March 1st of year 0, which puts the leap day at
/// the end of the year and makes the month lengths a regular sequence; the era is the
/// 400-year cycle over which the calendar repeats exactly.
fn civil_from_days(days: i64) -> (i64, u32, u32) {
    // 719468 days from 0000-03-01 to 1970-01-01.
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097); // day of era, 0..=146096
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // 0..=399
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // 0..=365, from March 1st
    let mp = (5 * doy + 2) / 153; // 0..=11, March is 0
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // 1..=31
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // 1..=12
    let y = yoe + era * 400 + i64::from(m <= 2);
    (y, m, d)
}

/// A POSIX ACL in `getfacl`'s `tag:qualifier:perms` spelling, entries comma-joined on one
/// line, in the order the ACL stores them. One line rather than `getfacl`'s one entry per
/// line, because this is a value in a label-and-value table and a multi-line value would
/// break the column.
///
/// The on-disk form is ext's compact encoding, which is neither what a person reads nor what
/// any other tool speaks, so an ACL that is only ever shown as bytes is an ACL nobody can
/// check. Named users and groups carry their numeric id, since a filesystem records ids and
/// this tool resolves no names — the host's `/etc/passwd` has nothing to do with the image's.
#[must_use]
pub fn acl(acl: &Acl) -> String {
    let mut out = String::new();
    for entry in acl.entries() {
        if !out.is_empty() {
            out.push(',');
        }
        let (tag, qualifier) = match entry.who {
            AclQualifier::UserObj => ("user", String::new()),
            AclQualifier::User(uid) => ("user", uid.to_string()),
            AclQualifier::GroupObj => ("group", String::new()),
            AclQualifier::Group(gid) => ("group", gid.to_string()),
            AclQualifier::Mask => ("mask", String::new()),
            AclQualifier::Other => ("other", String::new()),
        };
        let bits = [(READ, 'r'), (WRITE, 'w'), (EXEC, 'x')]
            .iter()
            .map(|&(bit, ch)| if entry.perm & bit != 0 { ch } else { '-' })
            .collect::<String>();
        out.push_str(&format!("{tag}:{qualifier}:{bits}"));
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_uuid_is_written_in_the_canonical_dashed_form() {
        assert_eq!(
            uuid(&[
                0xf0, 0xe1, 0x70, 0x55, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0
            ]),
            "f0e17055-0000-4000-8000-000000000000"
        );
        assert_eq!(uuid(&[0; 16]), "00000000-0000-0000-0000-000000000000");
    }

    #[test]
    fn a_label_reads_up_to_its_first_nul() {
        assert_eq!(
            label(b"rootfs\0\0\0\0\0\0\0\0\0\0").as_deref(),
            Some("rootfs")
        );
        // A full sixteen bytes has no terminator.
        assert_eq!(
            label(b"0123456789abcdef").as_deref(),
            Some("0123456789abcdef")
        );
        // An empty field is no label at all.
        assert_eq!(label(&[0u8; 16]), None);
        // A non-UTF-8 label renders lossily rather than failing.
        assert_eq!(
            label(b"a\xffb\0\0\0\0\0\0\0\0\0\0\0\0\0").as_deref(),
            Some("a\u{fffd}b")
        );
        // A control byte in a label is escaped, not sent to the terminal raw.
        assert_eq!(
            label(b"a\x1bb\0\0\0\0\0\0\0\0\0\0\0\0\0").as_deref(),
            Some("a\\x1bb")
        );
    }

    #[test]
    fn printable_escapes_control_bytes_and_keeps_the_rest() {
        // Ordinary text, including the path separator, passes through untouched.
        assert_eq!(printable(b"/etc/passwd"), "/etc/passwd");
        // A terminal escape sequence a crafted image might carry is neutralized: the ESC
        // and the carriage return become visible escapes rather than acting on the
        // terminal.
        assert_eq!(
            printable(b"safe\x1b[31mred\rgone"),
            "safe\\x1b[31mred\\x0dgone"
        );
        // NUL and DEL are controls too.
        assert_eq!(printable(b"a\0b\x7fc"), "a\\x00b\\x7fc");
        // Invalid UTF-8 still renders lossily.
        assert_eq!(printable(b"a\xffb"), "a\u{fffd}b");
    }

    #[test]
    fn printable_names_exactly_one_input() {
        // The backslash escapes itself, so a name holding the four characters `\x1b`
        // does not render as the one holding the ESC byte. Without this the rendering
        // would be ambiguous in the direction that matters: a crafted name could be
        // written to look like the escaped form of an innocent one.
        assert_eq!(printable(br"a\x1bb"), "a\\\\x1bb");
        assert_ne!(printable(br"a\x1bb"), printable(b"a\x1bb"));
    }

    #[test]
    fn printable_escapes_the_direction_overrides() {
        // `U+202E` reverses everything after it, so a terminal would show this name as
        // `gpj.exe`. It is category Cf rather than a control, so `is_control` misses it.
        assert_eq!(
            printable("photo\u{202e}exe.jpg".as_bytes()),
            "photo\\u{202e}exe.jpg"
        );
        // The isolates and the plain marks are escaped on the same grounds.
        assert_eq!(
            printable("a\u{2066}b\u{200f}c".as_bytes()),
            "a\\u{2066}b\\u{200f}c"
        );
        // An ordinary non-ASCII character is not one of them and renders as itself.
        assert_eq!(printable("café".as_bytes()), "café");
    }

    #[test]
    fn a_path_renders_as_a_uri_reference_that_decodes_back_to_it() {
        // The common case: a rooted path of ordinary characters becomes a `file://` URI
        // with nothing encoded.
        assert_eq!(
            uri_reference(Path::new("/var/tmp/disk.img")),
            "file:///var/tmp/disk.img"
        );
        // Every character the URI grammar treats specially is encoded. A space is not
        // allowed in a URI at all; `#` starts a fragment, `?` a query, `%` an escape, and
        // a `:` in a relative reference's first segment would read as a scheme.
        assert_eq!(
            uri_reference(Path::new("a b#c?d%e:f")),
            "a%20b%23c%3Fd%25e%3Af"
        );
        // Non-ASCII is encoded a byte at a time, which is what a URI carries.
        assert_eq!(uri_reference(Path::new("café.img")), "caf%C3%A9.img");
        // The unreserved set survives, and `/` stays the separator.
        assert_eq!(
            uri_reference(Path::new("/a-b/c.d/e_f/g~h")),
            "file:///a-b/c.d/e_f/g~h"
        );
        // A relative path stays relative: it names what the invocation named.
        assert_eq!(uri_reference(Path::new("./sub/disk.img")), "./sub/disk.img");
    }

    #[test]
    fn a_mode_reads_as_it_does_on_a_terminal() {
        assert_eq!(mode(0o040755), "drwxr-xr-x");
        assert_eq!(mode(0o100644), "-rw-r--r--");
        assert_eq!(mode(0o120777), "lrwxrwxrwx");
        assert_eq!(mode(0o020666), "crw-rw-rw-");
        assert_eq!(mode(0o060660), "brw-rw----");
        assert_eq!(mode(0o010600), "prw-------");
        assert_eq!(mode(0o140666), "srw-rw-rw-");
        // The set-id and sticky bits sit in the execute positions, upper-cased when the
        // execute bit they share is clear.
        assert_eq!(mode(0o104755), "-rwsr-xr-x");
        assert_eq!(mode(0o104644), "-rwSr--r--");
        assert_eq!(mode(0o041777), "drwxrwxrwt");
        assert_eq!(mode(0o041666), "drw-rw-rwT");
    }

    #[test]
    fn a_time_renders_as_utc_without_a_calendar_to_consult() {
        assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
        assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20Z");
        // A leap day in a year divisible by 400, which the 100-year rule would otherwise
        // have skipped.
        assert_eq!(iso8601(951_782_400), "2000-02-29T00:00:00Z");
        // Before the epoch the arithmetic floors: one second before 1970 is the last
        // second of 1969, not the first of 1970.
        assert_eq!(iso8601(-1), "1969-12-31T23:59:59Z");
        // The ends of the range an ext4 timestamp reaches.
        assert_eq!(iso8601(-2_147_483_648), "1901-12-13T20:45:52Z");
        assert_eq!(iso8601(15_032_385_535), "2446-05-10T22:38:55Z");
    }
}