//! Classification owned by `spec/CLASSIFICATION.md` and `spec/PRODUCT.md` //! sections 5 and 6. Every finding receives exactly one category, a risk, //! and a manual-only policy decision. Only producer-specific evidence may //! recommend; heuristics limit automation and never expand it. The decision //! reads only captured scan metadata and configuration; it never touches the //! filesystem again. const std = @import("std"); const spec_data = @import("spec_data"); const scan_mod = @import("scan.zig"); pub const Category = enum { lock_file, broken_symlink, rotated_log, live_log, stale_temporary, heuristic_temporary, download, document, source, workspace, trash, other, /// Compiled category label; identical across color and glyph modes. pub fn label(category: Category) []const u8 { return @tagName(category); } }; pub const Risk = enum { low, medium, high }; pub const Context = struct { workspace_roots: []const []const u8, document_roots: []const []const u8, thresholds: *const @import("config.zig").Thresholds, realtime_sec: i64, }; pub const Result = struct { category: Category = .other, risk: Risk = .medium, manual_only: bool = true, }; fn ageSeconds(context: *const Context, finding: *const scan_mod.Finding) ?i64 { if (!finding.mtime_known) return null; const elapsed = context.realtime_sec - finding.mtime_sec; if (elapsed < 0) return null; // future timestamps never satisfy an age rule return elapsed; } fn hasSuffix(name: []const u8, suffix: []const u8) bool { return std.mem.endsWith(u8, name, suffix); } fn hasSuffixCI(name: []const u8, suffix: []const u8) bool { if (name.len < suffix.len) return false; return std.ascii.eqlIgnoreCase(name[name.len - suffix.len ..], suffix); } fn allDigits(text: []const u8) bool { if (text.len == 0) return false; for (text) |byte| { if (byte < '0' or byte > '9') return false; } return true; } /// Rotated logs: a `.log` identity plus `.N`, `.old`, `.gz`, `.bz2`, `.xz`, /// or `.zst` rotation (`spec/CLASSIFICATION.md`). fn rotatedLogName(name: []const u8) bool { var rest = name; while (true) { const dot = std.mem.lastIndexOfScalar(u8, rest, '.') orelse return false; const tail = rest[dot + 1 ..]; if (std.mem.eql(u8, tail, ".log")) return false; if (hasSuffix(rest[0..dot], ".log")) return true; if (!allDigits(tail)) return false; rest = rest[0..dot]; } } fn temporaryName(name: []const u8) bool { const markers = [_][]const u8{ "~", ".tmp", ".temp", ".swp", ".swo", ".part" }; for (markers) |marker| { if (std.mem.startsWith(u8, name, marker)) return true; if (hasSuffix(name, marker)) return true; } return false; } const installer_suffixes = [_][]const u8{ ".zip", ".gz", ".bz2", ".xz", ".zst", ".tar", ".deb", ".rpm", ".apk", ".jar" }; /// Download recognition requires suffix and file magic to agree; a mismatch /// is uncertain purpose and stays `.other` (manual-only). fn downloadKind(name: []const u8, first_bytes: []const u8) bool { for (installer_suffixes) |suffix| { if (!hasSuffixCI(name, suffix)) continue; if (first_bytes.len < 4) return false; if (std.mem.eql(u8, suffix, ".gz")) return first_bytes[0] == 0x1F and first_bytes[1] == 0x8B; if (std.mem.eql(u8, suffix, ".bz2")) return std.mem.startsWith(u8, first_bytes, "BZh"); if (std.mem.eql(u8, suffix, ".xz")) return std.mem.startsWith(u8, first_bytes, "\xfd7zXZ\x00"); if (std.mem.eql(u8, suffix, ".zst")) { return first_bytes[0] == 0x28 and first_bytes[1] == 0xB5 and first_bytes[2] == 0x2F and first_bytes[3] == 0xFD; } if (std.mem.eql(u8, suffix, ".deb")) return std.mem.startsWith(u8, first_bytes, "!"); if (std.mem.eql(u8, suffix, ".rpm")) { return first_bytes[0] == 0xED and first_bytes[1] == 0xAB and first_bytes[2] == 0xEE and first_bytes[3] == 0xDB; } if (std.mem.eql(u8, suffix, ".tar")) return false; // header lies past magic window return std.mem.startsWith(u8, first_bytes, "PK\x03\x04"); } return false; } fn isSource(name: []const u8) bool { for (spec_data.classification_source_basenames) |basename| { if (std.mem.eql(u8, name, basename)) return true; } for (spec_data.classification_source_suffixes) |suffix| { if (hasSuffixCI(name, suffix)) return true; } return false; } /// Classifies one retained finding given its raw path (reconstructed by the /// caller) and up to four leading file bytes for magic checks. pub fn classify( finding: *const scan_mod.Finding, path: []const u8, first_bytes: []const u8, context: *const Context, ) Result { var result: Result = .{}; const name = finding.name; if (pathIsUnder(path, context.workspace_roots)) { // Workspace content is source-bearing and manual-only pending VCS // metadata (`TJ-CLASS-13`); version 1 marks the class conservatively. result.category = .workspace; result.risk = .high; return result; } if (finding.kind == .file and (hasSuffix(name, ".lock") or hasSuffix(name, ".lck") or hasSuffix(name, ".pid"))) { result.category = .lock_file; result.risk = .high; return result; } if (finding.kind == .symlink and finding.target_status == .broken) { result.category = .broken_symlink; result.risk = .medium; return result; } if (finding.kind == .file) { if (rotatedLogName(name)) { result.category = .rotated_log; const elapsed = ageSeconds(context, finding) orelse 0; result.risk = if (elapsed >= @as(i64, @intCast(context.thresholds.old_rotated_log_seconds))) .medium else .high; return result; } if (hasSuffix(name, ".log")) { result.category = .live_log; result.risk = .high; return result; } if (temporaryName(name)) { const elapsed = ageSeconds(context, finding) orelse 0; result.category = if (elapsed >= @as(i64, @intCast(context.thresholds.stale_temporary_seconds))) .stale_temporary else .heuristic_temporary; result.risk = .high; return result; } if (pathIsUnder(path, context.document_roots)) { if (downloadKind(name, first_bytes)) { result.category = .download; result.risk = .high; return result; } result.category = .document; result.risk = .high; return result; } if (isSource(name)) { result.category = .source; result.risk = .high; return result; } } result.category = .other; result.risk = .medium; return result; } fn pathIsUnder(path: []const u8, roots: []const []const u8) bool { for (roots) |root| { if (@import("config.zig").prefixMatches(root, path)) return true; } return false; } comptime { std.debug.assert(installer_suffixes.len > 0); std.debug.assert(downloadKind("x.zip", "PK\x03\x04zz")); std.debug.assert(!downloadKind("x.zip", "notzip")); std.debug.assert(rotatedLogName("access.log.1")); std.debug.assert(rotatedLogName("access.log.gz")); std.debug.assert(!rotatedLogName("access.log")); }