ajhahn.de
← FlashOS
Flash 176 lines
// pwfile: /etc/passwd line parser.
//
// Pure, allocation-free, no externs. One parser for the account database,
// shared by every consumer that previously rolled its own or had none:
//   * the kernel's sys_passwd (src/sys.zig) — maps the caller's uid back
//     to a login name to enforce "non-root may only change its own record"
//   * /bin/login (tools/login_elf.zig) — name → {uid, gid, shell} for the
//     privilege drop after authentication
//   * fsh's whoami builtin (user_space/fsh/fsh.zig) — uid → name
//
// Line format: `user:uid:gid:home:shell` (exactly 5 colon-delimited
// fields). /etc/passwd itself stays an initramfs file — the account LIST
// is build-time-immutable; only passwords (/etc/shadow, /mnt/shadow) are
// mutable state. The host tests below pin the format against
// user_space/etc/passwd.

pub const Entry = struct {
    user []u8,
    uid u32,
    gid u32,
    home []u8,
    shell []u8,
}

// Find the entry whose login name equals `name`. Returns null when absent
// or when the matching line is malformed.
pub fn lookupByName(content []u8, name []u8) ?Entry {
    var it = LineIter{ .content = content }
    while it.next() |line| {
        const e = parseLine(line) orelse continue
        if (bytesEqual(e.user, name)) { return e }
    }
    return null
}

// Find the entry whose uid equals `uid`. First match wins (uids are
// unique in the seed database). Returns null when absent.
pub fn lookupByUid(content []u8, uid u32) ?Entry {
    var it = LineIter{ .content = content }
    while it.next() |line| {
        const e = parseLine(line) orelse continue
        if (e.uid == uid) { return e }
    }
    return null
}

// Split one passwd line (no trailing newline) into its five fields.
// Returns null on a missing or extra field or a non-decimal uid/gid.
pub fn parseLine(line []u8) ?Entry {
    var fields [5][]u8 = undefined
    var nf usize = 0
    var fstart usize = 0
    var j usize = 0
    while (j <= line.len) {
        if (j == line.len) || (line[j] == ':') {
            if (nf == 5) { return null } // a 6th field is malformed
            fields[nf] = line[fstart..j]
            nf += 1
            fstart = j + 1
        }
        j += 1
    }
    if (nf != 5) { return null }
    if (fields[0].len == 0) { return null }

    const uid = parseDecimalU32(fields[1]) orelse return null
    const gid = parseDecimalU32(fields[2]) orelse return null

    return .{
        .user = fields[0],
        .uid = uid,
        .gid = gid,
        .home = fields[3],
        .shell = fields[4],
    }
}

const LineIter = struct {
    content []u8,
    pos usize = 0,

    fn next(self *mut LineIter) ?[]u8 {
        if (self.pos >= self.content.len) { return null }
        const start = self.pos
        var end = start
        while (end < self.content.len) && (self.content[end] != '\n') { end += 1 }
        self.pos = end + 1
        var line = self.content[start..end]
        // CRLF tolerance, mirroring overlay.parse.
        if (line.len > 0) && (line[line.len - 1] == '\r') { line = line[0 .. line.len - 1] }
        return line
    }
}

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

// 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 ----
const std = #import("std")
const testing = std.testing

// Mirrors user_space/etc/passwd.
const FIXTURE =
    "root:0:0:/root:/bin/fsh\n" ++
    "flash:1000:1000:/home/flash:/bin/fsh\n"

test "lookupByName: finds root and flash" {
    const root = lookupByName(FIXTURE, "root").?
    try testing.expectEqual(#as(u32, 0), root.uid)
    try testing.expectEqual(#as(u32, 0), root.gid)
    try testing.expectEqualStrings("/bin/fsh", root.shell)

    const flash = lookupByName(FIXTURE, "flash").?
    try testing.expectEqual(#as(u32, 1000), flash.uid)
    try testing.expectEqualStrings("/home/flash", flash.home)
}

test "lookupByName: misses an absent user" {
    try testing.expectEqual(#as(?Entry, null), lookupByName(FIXTURE, "anton"))
    // Prefix of an existing name must not match.
    try testing.expectEqual(#as(?Entry, null), lookupByName(FIXTURE, "fla"))
}

test "lookupByUid: reverse lookup finds the right record" {
    const flash = lookupByUid(FIXTURE, 1000).?
    try testing.expectEqualStrings("flash", flash.user)
    const root = lookupByUid(FIXTURE, 0).?
    try testing.expectEqualStrings("root", root.user)
}

test "lookupByUid: misses an absent uid" {
    try testing.expectEqual(#as(?Entry, null), lookupByUid(FIXTURE, 4711))
}

test "parseLine: rejects missing / extra fields and bad numbers" {
    try testing.expectEqual(#as(?Entry, null), parseLine("flash:1000:1000:/home/flash"))
    try testing.expectEqual(#as(?Entry, null), parseLine("flash:1000:1000:/home/flash:/bin/fsh:extra"))
    try testing.expectEqual(#as(?Entry, null), parseLine("flash:10x0:1000:/home/flash:/bin/fsh"))
    try testing.expectEqual(#as(?Entry, null), parseLine(":0:0:/root:/bin/fsh"))
    try testing.expectEqual(#as(?Entry, null), parseLine(""))
}

test "lookups skip malformed lines instead of failing the file" {
    const mixed =
        "# not a passwd line at all\n" ++
        "root:0:0:/root:/bin/fsh\n"
    const root = lookupByName(mixed, "root").?
    try testing.expectEqual(#as(u32, 0), root.uid)
}

test "CRLF line endings are tolerated" {
    const crlf = "root:0:0:/root:/bin/fsh\r\nflash:1000:1000:/home/flash:/bin/fsh\r\n"
    const flash = lookupByName(crlf, "flash").?
    try testing.expectEqual(#as(u32, 1000), flash.uid)
    try testing.expectEqualStrings("/bin/fsh", flash.shell)
}