ajhahn.de
← FlashOS
Flash 288 lines
// overlay: FAT32 permission-overlay parser.
//
// Pure, allocation-free, no externs. FAT32 has no native owner/mode
// concept, so /mnt files get their permission metadata from a root-level
// text file (/mnt/PERMS.TAB) instead of the 8d hard default.
// src/fat32_backend.zig reads that file once at mount time, parses it with
// these helpers into a fixed table, and open() consults the table;
// un-annotated paths keep the documented default (0666 root:root, except
// the shadow basename, which floors at 0600 — see fat32_backend.open).
// The overlay protects itself through its own entry (`PERMS.TAB 0600 0 0`).
//
// Line format: `NAME MODE UID GID`
//   * NAME — 8.3 basename as it appears in the FAT32 root; matched
//     case-insensitively (FAT32 names are caseless)
//   * MODE — octal permission word, low 9 bits only (no file-type bits;
//     the backend ORs the regular-file type back in)
//   * UID / GID — decimal
//   * `#` starts a comment; blank lines are skipped; CRLF tolerated
//     (cards get hand-edited on host machines)
//
// parse() rejects a malformed overlay WHOLESALE (returns null) instead of
// skipping bad lines: a half-applied policy is indistinguishable from a
// truncated or corrupted file, and the backend's corruption response (loud
// boot message + shadow floor) must fire for those, not silently shrink
// the table. The truth table below is the stage gate: no backend wiring
// ships until every row passes.

pub const MAX_ENTRIES usize = 16
// 8.3 basename: 8 name chars + '.' + 3 extension chars.
pub const MAX_NAME usize = 12

pub const Entry = struct {
    name_buf [MAX_NAME]u8,
    name_len u8,
    mode u32,
    uid u32,
    gid u32,

    pub fn name(self *Entry) []u8 {
        return self.name_buf[0..self.name_len]
    }
}

// Parse the overlay text into `out`. Returns the entry count (0 for an
// empty or comment-only file — valid), or null on the first malformed
// line: missing field, 5th field, non-octal mode, mode above 0o777,
// non-decimal uid/gid, empty or over-long name, or more entries than
// `out` holds.
pub fn parse(content []u8, out []mut Entry) ?usize {
    var count usize = 0
    var line_start usize = 0
    var i usize = 0
    while (i <= content.len) {
        if (i == content.len) || (content[i] == '\n') {
            var line = content[line_start..i]
            line_start = i + 1
            // CRLF tolerance: strip one trailing carriage return.
            if (line.len > 0) && (line[line.len - 1] == '\r') { line = line[0 .. line.len - 1] }
            const trimmed = trim(line)
            if (trimmed.len != 0) && (trimmed[0] != '#') {
                // Split into exactly 4 whitespace-separated fields.
                var fields [4][]u8 = undefined
                var nf usize = 0
                var j usize = 0
                while (j < trimmed.len) {
                    while (j < trimmed.len) && isSpace(trimmed[j]) { j += 1 }
                    if (j >= trimmed.len) { break }
                    const fstart = j
                    while (j < trimmed.len) && (!isSpace(trimmed[j])) { j += 1 }
                    if (nf == 4) { return null } // a 5th field is malformed
                    fields[nf] = trimmed[fstart..j]
                    nf += 1
                }
                if (nf != 4) { return null }

                const fname = fields[0]
                if (fname.len == 0) || (fname.len > MAX_NAME) { return null }
                const mode = parseOctalU32(fields[1]) orelse return null
                if (mode > 0o777) { return null }
                const uid = parseDecimalU32(fields[2]) orelse return null
                const gid = parseDecimalU32(fields[3]) orelse return null

                if (count == out.len) { return null } // over capacity
                out[count] = .{
                    .name_buf = undefined,
                    .name_len = #intCast(fname.len),
                    .mode = mode,
                    .uid = uid,
                    .gid = gid,
                }
                for c, k in fname { out[count].name_buf[k] = c }
                count += 1
            }
        }
        i += 1
    }
    return count
}

// Case-insensitive lookup of `name_in` among `entries` (pass the parsed
// prefix, e.g. table[0..count]). First match wins.
pub fn lookup(entries []Entry, name_in []u8) ?Entry {
    for e in entries {
        if (nameEql(e.name(), name_in)) { return e }
    }
    return null
}

// FAT32 name equality: case-insensitive byte comparison (8.3 names are
// caseless). Public so the backend can apply the same rule to names that
// are not in the table (the shadow floor check).
pub fn nameEql(a []u8, b []u8) bool {
    return eqlIgnoreCase(a, b)
}

fn isSpace(c u8) bool {
    return (c == ' ') || (c == '\t')
}

fn trim(s []u8) []u8 {
    var start usize = 0
    var end usize = s.len
    while (start < end) && isSpace(s[start]) { start += 1 }
    while (end > start) && isSpace(s[end - 1]) { end -= 1 }
    return s[start..end]
}

fn toLower(c u8) u8 {
    return if ((c >= 'A') && (c <= 'Z')) c + ('a' - 'A') else c
}

fn eqlIgnoreCase(a []u8, b []u8) bool {
    if (a.len != b.len) { return false }
    var i usize = 0
    while (i < a.len) {
        if (toLower(a[i]) != toLower(b[i])) { return false }
        i += 1
    }
    return true
}

// Octal u32 parse, exact (digits 0-7 only, no 0o prefix, no sign).
fn parseOctalU32(s []u8) ?u32 {
    if (s.len == 0) { return null }
    var v u64 = 0
    for c in s {
        if (c < '0') || (c > '7') { return null }
        v = v * 8 + (c - '0')
        if (v > 0xffff_ffff) { return null }
    }
    return #intCast(v)
}

// Decimal u32 parse, exact (no sign, no whitespace).
fn parseDecimalU32(s []u8) ?u32 {
    if (s.len == 0) { return null }
    var v u64 = 0
    for c in s {
        if (c < '0') || (c > '9') { return null }
        v = v * 10 + (c - '0')
        if (v > 0xffff_ffff) { return null }
    }
    return #intCast(v)
}

// ---- Host tests ----
//
// The truth table below is the gate for the FAT32 overlay: the backend
// wiring (fat32_backend.applyOverlay / open lookup) does not ship until
// every row passes. Rows pin the format the seed file
// (user_space/etc/perms.tab) and the deploy/make_test_disk seeding use.
const std = #import("std")
const testing = std.testing

test "parse: well-formed multi-line overlay" {
    var table [MAX_ENTRIES]Entry = undefined
    const content =
        "PERMS.TAB 0600 0 0\n" ++
        "SHADOW 0600 0 0\n" ++
        "ROUNDTR.DAT 0666 0 0\n"
    const n = parse(content, &table).?
    try testing.expectEqual(#as(usize, 3), n)
    try testing.expectEqualStrings("PERMS.TAB", table[0].name())
    try testing.expectEqual(#as(u32, 0o600), table[0].mode)
    try testing.expectEqual(#as(u32, 0), table[0].uid)
    try testing.expectEqual(#as(u32, 0), table[0].gid)
    try testing.expectEqualStrings("SHADOW", table[1].name())
    try testing.expectEqual(#as(u32, 0o666), table[2].mode)
}

test "parse: comments, blank lines, and surrounding whitespace are skipped" {
    var table [MAX_ENTRIES]Entry = undefined
    const content =
        "# FlashOS FAT32 permission overlay\n" ++
        "\n" ++
        "   \n" ++
        "  SHADOW   0600  0   0  \n" ++
        "# trailing comment\n"
    const n = parse(content, &table).?
    try testing.expectEqual(#as(usize, 1), n)
    try testing.expectEqualStrings("SHADOW", table[0].name())
}

test "parse: CRLF line endings are tolerated" {
    var table [MAX_ENTRIES]Entry = undefined
    const content = "SHADOW 0600 0 0\r\nPERMS.TAB 0600 0 0\r\n"
    const n = parse(content, &table).?
    try testing.expectEqual(#as(usize, 2), n)
    try testing.expectEqualStrings("SHADOW", table[0].name())
    try testing.expectEqualStrings("PERMS.TAB", table[1].name())
}

test "parse: no trailing newline on the last line still parses" {
    var table [MAX_ENTRIES]Entry = undefined
    const n = parse("SHADOW 0600 0 0", &table).?
    try testing.expectEqual(#as(usize, 1), n)
}

test "parse: empty and comment-only files are valid with zero entries" {
    var table [MAX_ENTRIES]Entry = undefined
    try testing.expectEqual(#as(usize, 0), parse("", &table).?)
    try testing.expectEqual(#as(usize, 0), parse("# nothing here\n", &table).?)
}

test "parse: missing field rejects the whole overlay" {
    var table [MAX_ENTRIES]Entry = undefined
    try testing.expectEqual(#as(?usize, null), parse("SHADOW 0600 0\n", &table))
    try testing.expectEqual(#as(?usize, null), parse("SHADOW 0600\n", &table))
    try testing.expectEqual(#as(?usize, null), parse("SHADOW\n", &table))
}

test "parse: a 5th field rejects the whole overlay" {
    var table [MAX_ENTRIES]Entry = undefined
    try testing.expectEqual(#as(?usize, null), parse("SHADOW 0600 0 0 extra\n", &table))
}

test "parse: one malformed line rejects the whole overlay (no partial table)" {
    var table [MAX_ENTRIES]Entry = undefined
    const content =
        "SHADOW 0600 0 0\n" ++
        "PERMS.TAB 9999 0 0\n" // 9 is not an octal digit
    try testing.expectEqual(#as(?usize, null), parse(content, &table))
}

test "parse: non-octal mode and mode above 0777 reject" {
    var table [MAX_ENTRIES]Entry = undefined
    try testing.expectEqual(#as(?usize, null), parse("SHADOW 08 0 0\n", &table))
    try testing.expectEqual(#as(?usize, null), parse("SHADOW abc 0 0\n", &table))
    try testing.expectEqual(#as(?usize, null), parse("SHADOW 1777 0 0\n", &table))
}

test "parse: non-decimal uid / gid rejects" {
    var table [MAX_ENTRIES]Entry = undefined
    try testing.expectEqual(#as(?usize, null), parse("SHADOW 0600 root 0\n", &table))
    try testing.expectEqual(#as(?usize, null), parse("SHADOW 0600 0 0x0\n", &table))
}

test "parse: empty or over-long name rejects" {
    var table [MAX_ENTRIES]Entry = undefined
    // 13 chars: one past the 8.3 maximum.
    try testing.expectEqual(#as(?usize, null), parse("ABCDEFGHI.TXT 0600 0 0\n", &table))
}

test "parse: more entries than the table holds rejects" {
    var small [2]Entry = undefined
    const content =
        "A 0600 0 0\n" ++
        "B 0600 0 0\n" ++
        "C 0600 0 0\n"
    try testing.expectEqual(#as(?usize, null), parse(content, &small))
}

test "lookup: case-insensitive hit and miss" {
    var table [MAX_ENTRIES]Entry = undefined
    const n = parse("SHADOW 0600 0 0\nPERMS.TAB 0600 0 0\n", &table).?
    // The backend looks paths up by their lowercase /mnt basename.
    const hit = lookup(table[0..n], "shadow").?
    try testing.expectEqual(#as(u32, 0o600), hit.mode)
    const hit2 = lookup(table[0..n], "perms.tab").?
    try testing.expectEqualStrings("PERMS.TAB", hit2.name())
    try testing.expectEqual(#as(?Entry, null), lookup(table[0..n], "roundtr.dat"))
}

test "lookup: empty table always misses" {
    const empty [0]Entry = .{}
    try testing.expectEqual(#as(?Entry, null), lookup(&empty, "shadow"))
}