From 8658f7cc957cf470df55670b67489567e164ae06 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 05:38:52 +0200 Subject: [PATCH] Add visible v1 diagnostics guardrails --- src/diagnostics.zig | 48 ++++++++++++++++++++++++++++ src/job.zig | 7 +++-- src/lsp.zig | 5 +++ src/main.zig | 2 ++ src/repo.zig | 17 ++++++++-- src/tui.zig | 31 ++++++++++++++++-- src/v1_smoke.zig | 77 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 src/diagnostics.zig diff --git a/src/diagnostics.zig b/src/diagnostics.zig new file mode 100644 index 0000000..a91d2a6 --- /dev/null +++ b/src/diagnostics.zig @@ -0,0 +1,48 @@ +const std = @import("std"); + +// Local v1 guardrails for SSH/iPhone use: visible rows instead of silent stalls. +// req: testing/001, testing/002, testing/003, testing/004 +pub const max_file_bytes: usize = 1024 * 1024; +pub const max_search_rows: usize = 64; +pub const process_stdout_limit: usize = 256 * 1024; +pub const process_stderr_limit: usize = 256 * 1024; + +pub fn searchTruncatedRowAlloc(allocator: std.mem.Allocator, shown: usize, limit: usize) ![]u8 { + return std.fmt.allocPrint(allocator, "diagnostic:search_truncated:showing_{d}_of_limit_{d}", .{ shown, limit }); +} + +pub fn fileTooLargeRowAlloc(allocator: std.mem.Allocator, bytes: usize, limit: usize) ![]u8 { + return std.fmt.allocPrint(allocator, "diagnostic:file_too_large:{d}_bytes_limit_{d}", .{ bytes, limit }); +} + +pub fn unsupportedFileRowAlloc(allocator: std.mem.Allocator, reason: []const u8) ![]u8 { + return std.fmt.allocPrint(allocator, "diagnostic:unsupported_file:{s}", .{reason}); +} + +pub fn processLimitRowAlloc(allocator: std.mem.Allocator, kind: []const u8, stdout_limit: usize, stderr_limit: usize) ![]u8 { + return std.fmt.allocPrint(allocator, "diagnostic:{s}_limits:stdout_{d}:stderr_{d}", .{ kind, stdout_limit, stderr_limit }); +} + +pub fn saveFailedRowAlloc(allocator: std.mem.Allocator, reason: []const u8) ![]u8 { + return std.fmt.allocPrint(allocator, "diagnostic:save_failed:{s}", .{reason}); +} + +pub fn sanitizeReason(reason: []const u8) []const u8 { + if (reason.len == 0) return "unknown"; + for (reason) |byte| { + if (byte <= 0x20 or byte == ':' or byte == '|') return "invalid"; + } + return reason; +} + +test "regular: diagnostic rows are compact and actionable" { + const row = try searchTruncatedRowAlloc(std.testing.allocator, 64, 64); + defer std.testing.allocator.free(row); + try std.testing.expectEqualStrings("diagnostic:search_truncated:showing_64_of_limit_64", row); +} + +test "adversarial: unsafe diagnostic reasons are sanitized" { + try std.testing.expectEqualStrings("invalid", sanitizeReason("two words")); + try std.testing.expectEqualStrings("unknown", sanitizeReason("")); + try std.testing.expectEqualStrings("spawn_error", sanitizeReason("spawn_error")); +} diff --git a/src/job.zig b/src/job.zig index 8164916..8c5ad4a 100644 --- a/src/job.zig +++ b/src/job.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const diagnostics = @import("diagnostics.zig"); // Explicit local build/test job execution for the shared panel model. // req: repo/002, ui/002, testing/001, testing/002, testing/003, testing/004 @@ -57,10 +58,11 @@ fn runRowsWithPreviewAlloc( const result = std.process.run(allocator, io, .{ .argv = argv, .cwd = .{ .path = cwd }, - .stdout_limit = .limited(256 * 1024), - .stderr_limit = .limited(256 * 1024), + .stdout_limit = .limited(diagnostics.process_stdout_limit), + .stderr_limit = .limited(diagnostics.process_stderr_limit), }) catch |err| { try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:status:spawn_error_{s}", .{ kind, @errorName(err) })); + try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, kind, diagnostics.process_stdout_limit, diagnostics.process_stderr_limit)); return rows.toOwnedSlice(allocator); }; defer allocator.free(result.stdout); @@ -70,6 +72,7 @@ fn runRowsWithPreviewAlloc( try appendOutputRows(allocator, &rows, "stdout", result.stdout); try appendOutputRows(allocator, &rows, "stderr", result.stderr); if (rows.items.len == 2) try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:output_empty", .{kind})); + try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, kind, diagnostics.process_stdout_limit, diagnostics.process_stderr_limit)); return rows.toOwnedSlice(allocator); } diff --git a/src/lsp.zig b/src/lsp.zig index f00a98d..10adc64 100644 --- a/src/lsp.zig +++ b/src/lsp.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const diagnostics = @import("diagnostics.zig"); // Minimal LSP process lifecycle + document sync transport. // req: coding/003, repo/002, testing/001, testing/002, testing/003, testing/004 @@ -313,6 +314,7 @@ pub fn runDocumentSyncRowsAlloc( .stderr = .ignore, }) catch |err| { try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:status:spawn_error_{s}", .{@errorName(err)})); + try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, "lsp", diagnostics.process_stdout_limit, diagnostics.process_stderr_limit)); return rows.toOwnedSlice(allocator); }; defer child.kill(io); @@ -320,6 +322,7 @@ pub fn runDocumentSyncRowsAlloc( child.stdin.?.writeStreamingAll(io, transcript) catch |err| { child.stdin.?.close(io); try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:status:write_error_{s}", .{@errorName(err)})); + try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, "lsp", diagnostics.process_stdout_limit, diagnostics.process_stderr_limit)); _ = child.wait(io) catch {}; return rows.toOwnedSlice(allocator); }; @@ -333,10 +336,12 @@ pub fn runDocumentSyncRowsAlloc( const term = child.wait(io) catch |err| { try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:status:wait_error_{s}", .{@errorName(err)})); + try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, "lsp", diagnostics.process_stdout_limit, diagnostics.process_stderr_limit)); return rows.toOwnedSlice(allocator); }; child.id = null; try appendTermRow(allocator, &rows, term); + try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, "lsp", diagnostics.process_stdout_limit, diagnostics.process_stderr_limit)); return rows.toOwnedSlice(allocator); } diff --git a/src/main.zig b/src/main.zig index e93aacb..c8561d4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const context_mod = @import("context.zig"); +const diagnostics = @import("diagnostics.zig"); const input = @import("input.zig"); const job = @import("job.zig"); const layout = @import("layout.zig"); @@ -224,6 +225,7 @@ fn collectRemainingArgs(allocator: std.mem.Allocator, args: *std.process.Args.It test { _ = context_mod; + _ = diagnostics; _ = input; _ = job; _ = layout; diff --git a/src/repo.zig b/src/repo.zig index eb7a013..915246e 100644 --- a/src/repo.zig +++ b/src/repo.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const diagnostics = @import("diagnostics.zig"); // Minimal repo index for file picker/tree behavior. // req: repo/001, ui/002, testing/001, testing/002, testing/003, testing/004 @@ -88,16 +89,24 @@ pub const Index = struct { } pub fn searchRowsAlloc(self: *const Index, allocator: std.mem.Allocator, query: []const u8, include_ignored: bool) ![][]const u8 { + return self.searchRowsLimitedAlloc(allocator, query, include_ignored, diagnostics.max_search_rows); + } + + pub fn searchRowsLimitedAlloc(self: *const Index, allocator: std.mem.Allocator, query: []const u8, include_ignored: bool, limit: usize) ![][]const u8 { try validateQuery(query); + if (limit == 0) return Error.InvalidQuery; var rows = std.ArrayList([]const u8).empty; errdefer { for (rows.items) |row| allocator.free(row); rows.deinit(allocator); } + var truncated = false; for (self.entries.items) |entry| { if (!include_ignored and self.isIgnored(entry.path)) continue; - try appendSearchRowsForEntry(allocator, &rows, entry, query); + try appendSearchRowsForEntry(allocator, &rows, entry, query, limit, &truncated); + if (truncated) break; } + if (truncated) try rows.append(allocator, try diagnostics.searchTruncatedRowAlloc(allocator, rows.items.len, limit)); return rows.toOwnedSlice(allocator); } @@ -147,11 +156,15 @@ fn validateQuery(query: []const u8) !void { } } -fn appendSearchRowsForEntry(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), entry: Entry, query: []const u8) !void { +fn appendSearchRowsForEntry(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), entry: Entry, query: []const u8, limit: usize, truncated: *bool) !void { var line_no: usize = 1; var lines = std.mem.splitScalar(u8, entry.content, '\n'); while (lines.next()) |line| : (line_no += 1) { if (std.mem.indexOf(u8, line, query)) |column_zero| { + if (rows.items.len >= limit) { + truncated.* = true; + return; + } const preview = try previewAlloc(allocator, line); defer allocator.free(preview); try rows.append(allocator, try std.fmt.allocPrint( diff --git a/src/tui.zig b/src/tui.zig index 63bfb6d..4ddea77 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1,5 +1,6 @@ const std = @import("std"); const context_mod = @import("context.zig"); +const diagnostics_mod = @import("diagnostics.zig"); const input = @import("input.zig"); const job_mod = @import("job.zig"); const leader_mod = @import("leader.zig"); @@ -327,8 +328,20 @@ pub const Client = struct { } fn addRepoFile(self: *Client, payload: []const u8) !void { - const separator = std.mem.indexOfScalar(u8, payload, '=') orelse return repo_mod.Error.InvalidPath; - try self.repo.addFile(payload[0..separator], payload[separator + 1 ..]); + const separator = std.mem.indexOfScalar(u8, payload, '=') orelse { + self.message = "diagnostic:unsupported_file:invalid_repo_file_payload"; + return repo_mod.Error.InvalidPath; + }; + const file_bytes = payload[separator + 1 ..]; + if (file_bytes.len > diagnostics_mod.max_file_bytes) { + self.message = "diagnostic:file_too_large"; + return Error.ProtocolRejected; + } + self.repo.addFile(payload[0..separator], file_bytes) catch |err| { + self.message = "diagnostic:unsupported_file"; + return err; + }; + self.message = null; } fn openRepoList(self: *Client, title: []const u8, tree: bool, include_ignored: bool) !void { @@ -355,8 +368,9 @@ pub const Client = struct { for (rows) |row| self.allocator.free(row); self.allocator.free(rows); } + const truncated = rowsContain(rows, "diagnostic:search_truncated"); try self.session.openListPanel("search", rows); - self.message = null; + self.message = if (truncated) "diagnostic:search_truncated" else null; } fn openSelectedSearchResult(self: *Client) !void { @@ -669,6 +683,10 @@ pub const Client = struct { fn save(self: *Client) !void { const snap = try self.session.snapshot(); + if (snap.bytes.len > diagnostics_mod.max_file_bytes) { + self.message = "diagnostic:save_failed:file_too_large"; + return Error.ProtocolRejected; + } const copy = try self.allocator.dupe(u8, snap.bytes); if (self.saved_bytes) |old| self.allocator.free(old); self.saved_bytes = copy; @@ -807,6 +825,13 @@ fn assertLinesFit(frame: []const u8, width: usize) !void { } } +fn rowsContain(rows: []const []const u8, needle: []const u8) bool { + for (rows) |row| { + if (std.mem.indexOf(u8, row, needle) != null) return true; + } + return false; +} + fn freeOwnedRows(allocator: std.mem.Allocator, rows: []const []const u8) void { for (rows) |row| allocator.free(row); allocator.free(rows); diff --git a/src/v1_smoke.zig b/src/v1_smoke.zig index a092c22..c332004 100644 --- a/src/v1_smoke.zig +++ b/src/v1_smoke.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const diagnostics = @import("diagnostics.zig"); +const job = @import("job.zig"); const lsp = @import("lsp.zig"); const profile = @import("profile.zig"); const repo = @import("repo.zig"); @@ -107,6 +109,81 @@ test "adversarial: v1 smoke catches realistic profile and boundary failures" { try std.testing.expect(std.mem.indexOf(u8, status, "plugin_host=not_built") != null); } +test "regular: v1 smoke exposes bounded search, job, LSP, and save diagnostics" { + var client = try tui.Client.init(allocator, .{ .width = 72, .height = 12 }); + defer client.deinit(); + + try client.handleTraceLine("open ready"); + var line_buf: [64]u8 = undefined; + var i: usize = 0; + while (i < diagnostics.max_search_rows + 3) : (i += 1) { + const line = try std.fmt.bufPrint(&line_buf, "repo_file src/file{d}.zig=needle_{d}", .{ i, i }); + try client.handleTraceLine(line); + } + try client.handleTraceLine("search_text needle"); + { + const frame = try client.render(allocator); + defer allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "diagnostic:search_truncated") != null); + } + + const job_rows = try job.runRowsAlloc(allocator, std.Io.failing, ".", &.{"definitely-missing-mim-command"}); + defer freeRows(job_rows); + try std.testing.expect(rowsContain(job_rows, "diagnostic:job_limits")); + try std.testing.expect(rowsContain(job_rows, "job:status:spawn_error")); + + const lsp_rows = try lsp.runDocumentSyncRowsAlloc(allocator, std.Io.failing, ".", &.{"definitely-missing-lsp"}, .{ + .uri = "file:///repo/src/main.zig", + .language_id = "zig", + .text = "pub fn main() void {}", + }); + defer freeRows(lsp_rows); + try std.testing.expect(rowsContain(lsp_rows, "diagnostic:lsp_limits")); + try std.testing.expect(rowsContain(lsp_rows, "lsp:status:spawn_error")); +} + +test "adversarial: v1 smoke surfaces large or unsupported file and save failures" { + var client = try tui.Client.init(allocator, .{ .width = 72, .height = 6 }); + defer client.deinit(); + + try std.testing.expectError(repo.Error.InvalidPath, client.handleTraceLine("repo_file ../secret=hidden")); + { + const frame = try client.render(allocator); + defer allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "diagnostic:unsupported_file") != null); + } + + const large_bytes = try allocator.alloc(u8, diagnostics.max_file_bytes + 1); + defer allocator.free(large_bytes); + @memset(large_bytes, 'x'); + const large_command = try std.fmt.allocPrint(allocator, "repo_file src/large.txt={s}", .{large_bytes}); + defer allocator.free(large_command); + try std.testing.expectError(tui.Error.ProtocolRejected, client.handleTraceLine(large_command)); + { + const frame = try client.render(allocator); + defer allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "diagnostic:file_too_large") != null); + } + + try client.handleTraceLine("open ok"); + const insert_command = try std.fmt.allocPrint(allocator, "insert {s}", .{large_bytes}); + defer allocator.free(insert_command); + try client.handleTraceLine(insert_command); + try std.testing.expectError(tui.Error.ProtocolRejected, client.handleTraceLine("save")); + { + const frame = try client.render(allocator); + defer allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "diagnostic:save_failed:file_too_large") != null); + } +} + +fn rowsContain(rows: []const []const u8, needle: []const u8) bool { + for (rows) |row| { + if (std.mem.indexOf(u8, row, needle) != null) return true; + } + return false; +} + fn freeRows(rows: [][]const u8) void { for (rows) |row| allocator.free(row); allocator.free(rows);