terminal/snapshot: encode screen pages safely

SCREEN encoding assumed every page from the active boundary onward was
resident. A debug assertion guarded that PageList policy invariant, but
release builds immediately used pageAssumeResident. If compression policy
ever allowed a SCREEN suffix page to remain compressed, the encoder would
read an inactive union field, causing undefined behavior and potentially
a crash or corrupt snapshot.

Use pagePreservingState for every SCREEN suffix page, as HISTORY already
does, and include allocation failure in EncodeError. Resident pages remain
a zero-allocation borrow while compressed pages decode into temporary
read-only storage without changing the source representation. Exercise the
path with an explicitly compressed active suffix page.
This commit is contained in:
Mitchell Hashimoto
2026-08-02 20:49:48 -07:00
parent cbc9f360b1
commit e89ff37aa8

View File

@@ -233,7 +233,7 @@ const PayloadEncodeError = hyperlink.EncodeError || error{
};
/// Errors possible while encoding a SCREEN and its complete PAGE sequence.
pub const EncodeError = PayloadEncodeError || page.EncodeError || error{
pub const EncodeError = Allocator.Error || PayloadEncodeError || page.EncodeError || error{
/// The active area spans more pages than the SCREEN header can declare.
PageCountOverflow,
};
@@ -273,13 +273,14 @@ pub fn encode(
try destination.finish();
}
// PageList never compresses the active-boundary page or any later page.
// Encoding this resident suffix therefore does not restore cold history or
// otherwise mutate the source screen.
// Active pages are resident today, but use the representation-safe access
// path so a future PageList compression-policy change cannot turn this
// wire encoder's optimization invariant into undefined behavior.
node = first;
while (node) |current| : (node = current.next) {
std.debug.assert(current.pageIfResident() != null);
try page.encode(current.pageAssumeResident(), destination);
var preserved = try current.pagePreservingState(screen.alloc);
defer preserved.deinit();
try page.encode(preserved.page(), destination);
}
}
@@ -1963,6 +1964,62 @@ test "SCREEN encodes the minimal complete-page active suffix" {
try std.testing.expectError(error.EndOfStream, restore_source.takeByte());
}
test "SCREEN encoding preserves a compressed suffix page" {
const testing = std.testing;
const compression = @import("../compress.zig");
var screen = try TerminalScreen.init(
testing.io,
testing.allocator,
.{ .cols = 8, .rows = 2, .max_scrollback_bytes = 0 },
);
defer screen.deinit();
screen.pages.getCell(.{ .active = .{} }).?.cell.* = .init('A');
// Force the active suffix into the representation that current PageList
// policy normally reserves for history. This models a future policy change
// and makes pageAssumeResident an invalid tagged-union access.
const node = screen.pages.getTopLeft(.active).node;
const resident = node.pageAssumeResident();
const scratch = try testing.allocator.alloc(
u8,
try compression.Page.requiredScratch(resident.memory.len),
);
defer testing.allocator.free(scratch);
var table: compression.lz4.HashTable = undefined;
const compressed = (try compression.Page.init(
testing.allocator,
resident,
scratch,
&table,
)).?;
node.data = .{ .compressed = compressed };
try testing.expectEqual(.compressed, node.storage());
var destination: std.Io.Writer.Allocating = .init(testing.allocator);
defer destination.deinit();
var stream: record.Writer = .init(testing.allocator, &destination.writer);
defer stream.deinit();
try encode(&screen, .primary, &stream);
// Encoding borrows or clones through PreservedPage and never changes the
// source node's storage representation.
try testing.expectEqual(.compressed, node.storage());
var source: std.Io.Reader = .fixed(destination.written());
var decoded = try decode(
&source,
testing.io,
testing.allocator,
.{ .cols = 8, .rows = 2, .max_scrollback_bytes = 0 },
);
defer decoded.deinit();
try testing.expectEqual(
@as(u21, 'A'),
decoded.screen.pages.getCell(.{ .active = .{} }).?.cell.codepoint(),
);
}
test "SCREEN restoration normalizes invalid cursor positions" {
var screen = try TerminalScreen.init(
std.testing.io,