1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
|
//! Continuation record type.
//!
//! Continuation uses the standard record header. All trailing bytes
//! are payload bytes for the continuation state.
//!
//! A continuation's payload must be the minimal bytes to go from
//! a ground state to a non-ground state in a VT emulator. Any extra
//! bytes are treated as a validation error.
const std = @import("std");
const Allocator = std.mem.Allocator;
const record = @import("record.zig");
const stream_continuation = @import("../stream_continuation.zig");
const test_fixture = @import("fixture.zig");
/// A borrowed continuation value supplied to encode, or an allocator-owned
/// value stored in a decoded complete snapshot.
pub const Value = union(enum) {
/// Neither the VT parser nor UTF-8 decoder has unfinished state.
ground,
/// Canonical replay-safe PTY bytes. Empty is equivalent to `ground`.
bytes: []const u8,
};
pub const ValidateError = stream_continuation.ValidateError || error{
/// The common record's u32 payload length cannot represent these bytes.
Overflow,
};
/// Validate a continuation before any snapshot bytes are emitted.
pub fn validate(value: Value) ValidateError!void {
switch (value) {
.ground => {},
.bytes => |bytes| {
if (std.math.cast(u32, bytes.len) == null) {
return error.Overflow;
}
try stream_continuation.validate(bytes);
},
}
}
pub const EncodeError = ValidateError || record.Writer.FinishError;
/// Encode one complete CONTINUATION record.
pub fn encode(
value: Value,
stream: *record.Writer,
) EncodeError!void {
try validate(value);
const payload = stream.begin(.continuation);
errdefer stream.cancel();
switch (value) {
.ground => {},
.bytes => |bytes| try payload.writeAll(bytes),
}
try stream.finish();
}
pub const DecodeError = Allocator.Error ||
stream_continuation.ValidateError ||
record.Reader.InitError ||
record.Reader.FinishError ||
std.Io.Reader.Error ||
error{
/// The next record is valid but is not CONTINUATION.
UnexpectedRecordTag,
/// The declared payload exceeds caller policy.
ContinuationLimitExceeded,
};
/// Decode and validate one CONTINUATION record.
///
/// A zero-length payload returns `ground` without allocating. A nonempty
/// payload returns allocator-owned bytes; although `Value.bytes` is a const
/// slice, the caller must eventually free it with the same allocator.
pub fn decode(
alloc: Allocator,
source: *std.Io.Reader,
max_bytes: usize,
) DecodeError!Value {
var record_reader: record.Reader = undefined;
try record_reader.init(source);
if (record_reader.header.tag != .continuation) {
return error.UnexpectedRecordTag;
}
// Validate the header payload vs our max size since the header
// enforces it.
const len: usize = record_reader.header.payload_len;
if (len > max_bytes) return error.ContinuationLimitExceeded;
if (len == 0) {
// Empty is the ground representation, but finish is still required:
// its CRC covers the tag and zero length even though there is no body.
try record_reader.finish();
return .ground;
}
// Consume the bytes
const bytes = try alloc.alloc(u8, len);
errdefer alloc.free(bytes);
try record_reader.payloadReader().readSliceAll(bytes);
try record_reader.finish();
// Framing is valid, so it is now safe to classify the bytes as replay
// state. Validation requires a minimal, unfinished, side-effect-free tail.
try stream_continuation.validate(bytes);
// Ownership transfers to the returned Value only after all validation.
return .{ .bytes = bytes };
}
const test_ground_fixture = test_fixture.parse(@embedFile("testdata/continuation-ground-v1.hex"));
const test_utf8_fixture = test_fixture.parse(@embedFile("testdata/continuation-utf8-v1.hex"));
const test_esc_fixture = test_fixture.parse(@embedFile("testdata/continuation-esc-v1.hex"));
const test_csi_fixture = test_fixture.parse(@embedFile("testdata/continuation-csi-v1.hex"));
const test_osc_fixture = test_fixture.parse(@embedFile("testdata/continuation-osc-v1.hex"));
const test_dcs_fixture = test_fixture.parse(@embedFile("testdata/continuation-dcs-v1.hex"));
const test_apc_fixture = test_fixture.parse(@embedFile("testdata/continuation-apc-v1.hex"));
test "continuation golden records" {
const Golden = struct {
path: []const u8,
candidate: []const u8,
value: Value,
expected: []const u8,
};
const values = [_]Golden{
.{
.path = "src/terminal/snapshot/testdata/continuation-ground-v1.hex",
.candidate = "snapshot_fixture-continuation-ground-v1.hex",
.value = .ground,
.expected = &test_ground_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-utf8-v1.hex",
.candidate = "snapshot_fixture-continuation-utf8-v1.hex",
.value = .{ .bytes = "\xF0\x9F\x98" },
.expected = &test_utf8_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-esc-v1.hex",
.candidate = "snapshot_fixture-continuation-esc-v1.hex",
.value = .{ .bytes = "\x1b" },
.expected = &test_esc_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-csi-v1.hex",
.candidate = "snapshot_fixture-continuation-csi-v1.hex",
.value = .{ .bytes = "\x1b[31" },
.expected = &test_csi_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-osc-v1.hex",
.candidate = "snapshot_fixture-continuation-osc-v1.hex",
.value = .{ .bytes = "\x1b]2;title" },
.expected = &test_osc_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-dcs-v1.hex",
.candidate = "snapshot_fixture-continuation-dcs-v1.hex",
.value = .{ .bytes = "\x1bPqdata" },
.expected = &test_dcs_fixture,
},
.{
.path = "src/terminal/snapshot/testdata/continuation-apc-v1.hex",
.candidate = "snapshot_fixture-continuation-apc-v1.hex",
.value = .{ .bytes = "\x1b_Gdata" },
.expected = &test_apc_fixture,
},
};
for (values) |golden| {
var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer encoded.deinit();
var stream: record.Writer = .init(
std.testing.allocator,
&encoded.writer,
);
defer stream.deinit();
try encode(golden.value, &stream);
try test_fixture.expectEqual(
.bytes,
golden.path,
golden.candidate,
golden.expected,
encoded.written(),
);
}
}
test "continuation validates supported unfinished states" {
const values = [_][]const u8{
"\x1b",
"\x1b[31",
"\x1b]2;title",
"\x1bPqdata",
"\x1b_Gdata",
"\xF0\x9F\x98",
};
for (values) |bytes| try validate(.{ .bytes = bytes });
}
test "empty continuation bytes encode and decode as ground" {
const testing = std.testing;
try validate(.{ .bytes = "" });
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
var stream: record.Writer = .init(testing.allocator, &encoded.writer);
defer stream.deinit();
try encode(.{ .bytes = "" }, &stream);
try testing.expectEqualStrings(&test_ground_fixture, encoded.written());
var source: std.Io.Reader = .fixed(encoded.written());
const decoded = try decode(testing.allocator, &source, 0);
try testing.expect(decoded == .ground);
}
test "continuation rejects invalid semantic shapes" {
const testing = std.testing;
try testing.expectError(
error.NoPendingState,
validate(.{ .bytes = "\x1b[31m" }),
);
try testing.expectError(
error.ReplayWouldCommit,
validate(.{ .bytes = "\x1b[31\x07" }),
);
try testing.expectError(
error.NonCanonicalContinuation,
validate(.{ .bytes = "prefix\x1b[31" }),
);
try testing.expectError(
error.NonCanonicalContinuation,
validate(.{ .bytes = "\x1b[31\x1b[4" }),
);
}
test "continuation record round trip and cap" {
const testing = std.testing;
const values = [_]Value{
.ground,
.{ .bytes = "\x1b[31" },
.{ .bytes = "\xF0\x9F" },
};
for (values) |value| {
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
var stream: record.Writer = .init(testing.allocator, &encoded.writer);
defer stream.deinit();
try encode(value, &stream);
var source: std.Io.Reader = .fixed(encoded.written());
const decoded = try decode(testing.allocator, &source, 1024);
defer switch (decoded) {
.ground => {},
.bytes => |bytes| testing.allocator.free(bytes),
};
switch (value) {
.ground => try testing.expect(decoded == .ground),
.bytes => |expected| try testing.expectEqualStrings(
expected,
decoded.bytes,
),
}
}
var encoded: std.Io.Writer.Allocating = .init(testing.allocator);
defer encoded.deinit();
var stream: record.Writer = .init(testing.allocator, &encoded.writer);
defer stream.deinit();
try encode(.{ .bytes = "\x1b[31" }, &stream);
var capped_source: std.Io.Reader = .fixed(encoded.written());
var failing = testing.FailingAllocator.init(testing.allocator, .{
.fail_index = 0,
});
try testing.expectError(
error.ContinuationLimitExceeded,
decode(failing.allocator(), &capped_source, 3),
);
try testing.expectEqual(
@as(usize, record.Header.len),
capped_source.seek,
);
}
test "continuation record rejects truncation, checksum, and tag" {
const testing = std.testing;
for (0..test_csi_fixture.len) |len| {
var source: std.Io.Reader = .fixed(test_csi_fixture[0..len]);
try testing.expectError(
error.EndOfStream,
decode(testing.allocator, &source, 1024),
);
}
var invalid_checksum = test_csi_fixture;
invalid_checksum[6] ^= 1;
var checksum_source: std.Io.Reader = .fixed(&invalid_checksum);
try testing.expectError(
error.InvalidChecksum,
decode(testing.allocator, &checksum_source, 1024),
);
var wrong_tag = test_ground_fixture;
std.mem.writeInt(u16, wrong_tag[0..2], @intFromEnum(record.Tag.ready), .little);
var tag_source: std.Io.Reader = .fixed(&wrong_tag);
try testing.expectError(
error.UnexpectedRecordTag,
decode(testing.allocator, &tag_source, 1024),
);
}
|