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