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"))
}