Luigit
repositories / termux-janitor

termux-janitor

Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.

owned by admin

src/runlog.zig

Raw
//! Run log owned by `spec/PRODUCT.md` section 11 (`TJ-LOG-01` through
//! `TJ-LOG-06`): an append-only sequence of framed UTF-8 JSON records,
//! one line each:
//!
//! ```text
//! <decimal payload length> <eight lowercase CRC-32/ISO-HDLC hex digits> <JSON payload>\n
//! ```
//!
//! Creation failure enters review-only mode. Write or flush failure before
//! mutation admission blocks the action; failure after mutation pauses
//! execution and disables further mutation. Authority fields never truncate.
const std = @import("std");
const spec_data = @import("spec_data");

pub const payload_bytes_max: u32 = @intCast(spec_data.limit_value.log_payload_bytes);
pub const escaped_value_bytes_max: u32 = @intCast(spec_data.limit_value.escaped_log_value_bytes);
pub const write_attempts_max: u32 = @intCast(spec_data.limit_value.log_write_attempts);
pub const eintr_retries_max: u32 = @intCast(spec_data.limit_value.log_eintr_retries);
pub const audit_events_per_action_max: u32 = @intCast(spec_data.limit_value.audit_events_per_action);

/// CRC-32/ISO-HDLC (the standard reflected CRC-32, polynomial 0xEDB88320),
/// matching the frame format required by `TJ-LOG-04`.
pub fn crc32(bytes: []const u8) u32 {
    const table = comptime blk: {
        @setEvalBranchQuota(100_000);
        var computed: [256]u32 = undefined;
        for (0..256) |index| {
            var value: u32 = @intCast(index);
            for (0..8) |_| {
                const masked = value & 1 != 0;
                value >>= 1;
                if (masked) value ^= 0xEDB88320;
            }
            computed[index] = value;
        }
        break :blk computed;
    };
    var crc: u32 = 0xFFFFFFFF;
    for (bytes) |byte| {
        crc = (crc >> 8) ^ table[(crc ^ byte) & 0xFF];
    }
    return ~crc;
}

pub const Frame = struct {
    bytes: [payload_bytes_max + 32]u8 = undefined,
    len: usize = 0,
};

/// Renders one complete frame (`<len> <crc> <payload>\n`) into `frame`.
/// Returns null when the payload exceeds the registered bound.
pub fn renderFrame(payload: []const u8, frame: *Frame) bool {
    if (payload.len == 0 or payload.len > payload_bytes_max) return false;
    const header = std.fmt.bufPrint(&frame.bytes, "{d} {x:0>8} ", .{
        payload.len,
        crc32(payload),
    }) catch return false;
    if (header.len + payload.len + 1 > frame.bytes.len) return false;
    @memcpy(frame.bytes[header.len..][0..payload.len], payload);
    frame.bytes[header.len + payload.len] = '\n';
    frame.len = header.len + payload.len + 1;
    return true;
}

/// One open, append-only run log. Exactly one descriptor stays open for the
/// whole run; the log is never rotated or replaced during that run.
pub const RunLog = struct {
    file: std.Io.File = undefined,
    opened: bool = false,
    sequence: u64 = 0,
    /// Set when creation, open, or the initial flush failed: review-only
    /// mode. Every mutation is unavailable and a warning stays persistent.
    broken: bool = false,
    /// Set when a partial frame exists or a post-mutation write failed:
    /// the audit log is incomplete and mutation is disabled for the run.
    audit_incomplete: bool = false,

    /// Opens or creates `path` with append-only, no-follow, close-on-exec
    /// semantics and flushes the file and its parent on creation
    /// (`TJ-LOG-01`). Any failure returns a broken log in review-only mode.
    pub fn open(path: []const u8) RunLog {
        var log = RunLog{ .broken = true };
        const flags: std.posix.O = .{
            .ACCMODE = .WRONLY,
            .CREAT = true,
            .APPEND = true,
            .NOFOLLOW = true,
            .CLOEXEC = true,
        };
        const fd = std.posix.openat(std.posix.AT.FDCWD, path, flags, 0o600) catch return log;
        log.file = .{ .handle = fd, .flags = .{ .nonblocking = false } };
        log.opened = true;
        log.flushRaw() catch {
            log.broken = true;
            log.audit_incomplete = true;
            _ = std.os.linux.close(fd);
            log.opened = false;
            return log;
        };
        // Flush the parent directory so a freshly created log file entry
        // survives a crash before the first frame is written.
        if (std.mem.lastIndexOfScalar(u8, path, '/')) |slash| {
            const parent_fd = std.posix.openat(
                std.posix.AT.FDCWD,
                path[0..slash],
                .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true },
                0,
            ) catch {
                log.broken = true;
                log.audit_incomplete = true;
                _ = std.os.linux.close(fd);
                log.opened = false;
                return log;
            };
            defer _ = std.os.linux.close(parent_fd);
            _ = std.posix.system.fdatasync(parent_fd);
        }
        log.broken = false;
        return log;
    }

    /// Appends one complete frame and flushes it durably. Returns false when
    /// the frame could not be written completely; the caller then fails
    /// closed (`TJ-LOG-06`). A partial frame marks the audit log incomplete
    /// and disables mutation for the rest of the run.
    pub fn append(self: *RunLog, frame: *const Frame) bool {
        std.debug.assert(frame.len > 0);
        if (self.broken) return false;
        var written: usize = 0;
        var retries: u32 = 0;
        while (written < frame.len) {
            const rc = std.posix.system.write(
                self.file.handle,
                frame.bytes[written..].ptr,
                frame.len - written,
            );
            if (rc < 0) {
                if (std.posix.errno(rc) == .INTR) {
                    if (retries >= eintr_retries_max) {
                        if (written > 0) {
                            self.audit_incomplete = true;
                            self.broken = true;
                        }
                        return false;
                    }
                    retries += 1;
                    continue;
                }
                if (written > 0) {
                    self.audit_incomplete = true;
                    self.broken = true;
                }
                return false;
            }
            const sent: usize = @intCast(rc);
            std.debug.assert(sent > 0);
            written += sent;
        }
        self.sequence += 1;
        self.flushRaw() catch {
            self.audit_incomplete = true;
            return false;
        };
        return true;
    }

    fn flushRaw(self: *RunLog) !void {
        var attempts: u32 = 0;
        while (attempts < write_attempts_max) : (attempts += 1) {
            const rc = std.posix.system.fdatasync(self.file.handle);
            if (rc == 0) return;
            const err = std.posix.errno(rc);
            if (err == .INTR) continue;
            if (err == .OPNOTSUPP or err == .INVAL) {
                // `fsync` is accepted only when `fdatasync` is unsupported
                // and succeeds (`TJ-LOG-03`).
                const rc2 = std.posix.system.fsync(self.file.handle);
                if (rc2 == 0) return;
                if (std.posix.errno(rc2) == .INTR) continue;
                return error.FlushFailed;
            }
            return error.FlushFailed;
        }
        return error.FlushFailed;
    }

    pub fn close(self: *RunLog) void {
        if (!self.opened) return;
        _ = std.os.linux.close(self.file.handle);
        self.opened = false;
    }
};

/// Losslessly escapes one JSON string value into `out`; returns null when
/// the escaped form does not fit, which the caller must treat as an
/// authority overflow rather than truncating.
pub fn escapeInto(value: []const u8, out: []u8) ?[]const u8 {
    var written: usize = 0;
    for (value) |byte| {
        switch (byte) {
            '"' => {
                if (written + 2 > out.len) return null;
                @memcpy(out[written..][0..2], "\\\"");
                written += 2;
            },
            '\\' => {
                if (written + 2 > out.len) return null;
                @memcpy(out[written..][0..2], "\\\\");
                written += 2;
            },
            '\n' => {
                if (written + 2 > out.len) return null;
                @memcpy(out[written..][0..2], "\\n");
                written += 2;
            },
            '\r' => {
                if (written + 2 > out.len) return null;
                @memcpy(out[written..][0..2], "\\r");
                written += 2;
            },
            '\t' => {
                if (written + 2 > out.len) return null;
                @memcpy(out[written..][0..2], "\\t");
                written += 2;
            },
            else => {
                if (byte < 0x20) {
                    const text = std.fmt.bufPrint(out[written..], "\\u{x:0>4}", .{byte}) catch
                        return null;
                    written += text.len;
                } else {
                    if (written + 1 > out.len) return null;
                    out[written] = byte;
                    written += 1;
                }
            },
        }
    }
    return out[0..written];
}

comptime {
    // ISO-HDLC check values verify the frame checksum at compile time.
    std.debug.assert(crc32("123456789") == 0xCBF43926);
    std.debug.assert(crc32("") == 0x00000000);
    // Escape rendering round-trips the bounded format.
    var escape_out: [64]u8 = undefined;
    std.debug.assert(escapeInto("ab", &escape_out) != null);
}