diff --git a/build.zig b/build.zig index d930e64..3163829 100644 --- a/build.zig +++ b/build.zig @@ -576,4 +576,12 @@ pub fn build(b: *std.Build) void { const run_mpp_decoder_test = b.addRunArtifact(mpp_decoder_test); run_mpp_decoder_test.addArtifactArg(fake_mpp_valid); test_step.dependOn(&run_mpp_decoder_test.step); + + const bootstrap_tests = b.addTest(.{ + .root_source_file = b.path("src/media/video/bootstrap_cache.zig"), + .target = b.graph.host, + .optimize = .Debug, + }); + addZigImport(b, bootstrap_tests.root_module, b.graph.host, .Debug, "h264_depacketizer", "src/media/rtp/h264_depacketizer.zig"); + test_step.dependOn(&b.addRunArtifact(bootstrap_tests).step); } diff --git a/src/media/video/bootstrap_cache.zig b/src/media/video/bootstrap_cache.zig new file mode 100644 index 0000000..f6fba5b --- /dev/null +++ b/src/media/video/bootstrap_cache.zig @@ -0,0 +1,129 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub const capacity = 4104; + +pub fn read(directory: std.fs.Dir, path: []const u8, buffer: *[capacity + 1]u8) ![]const u8 { + const file = try directory.openFile(path, .{}); + defer file.close(); + // Zig's File.stat() uses statx, which Linux 4.9 does not provide. + const length = try file.readAll(buffer); + if (length > capacity) return error.FileTooBig; + return buffer[0..length]; +} + +const parameter_sets = [_]u8{ 0, 0, 0, 1, 0x67, 0x42, 0, 0, 0, 1, 0x68, 0xce }; + +test "cache reads preserve parameter sets" { + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + try directory.dir.writeFile(.{ .sub_path = "cache.bin", .data = ¶meter_sets }); + var buffer: [capacity + 1]u8 = undefined; + const data = try read(directory.dir, "cache.bin", &buffer); + try std.testing.expectEqualSlices(u8, ¶meter_sets, data); + + const h264 = @import("h264_depacketizer"); + var depacketizer = h264.Depacketizer.init(102); + try std.testing.expect(depacketizer.setBootstrap(data)); + @memset(&buffer, 0); + try std.testing.expectEqualSlices(u8, &.{ 0x67, 0x42 }, depacketizer.sps[0..depacketizer.sps_length]); + try std.testing.expectEqualSlices(u8, &.{ 0x68, 0xce }, depacketizer.pps[0..depacketizer.pps_length]); +} + +test "empty cache returns no parameter sets" { + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + try directory.dir.writeFile(.{ .sub_path = "cache.bin", .data = "" }); + var buffer: [capacity + 1]u8 = undefined; + try std.testing.expectEqual(@as(usize, 0), (try read(directory.dir, "cache.bin", &buffer)).len); +} + +test "malformed cache does not replace active parameter sets" { + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + try directory.dir.writeFile(.{ .sub_path = "cache.bin", .data = "invalid H.264" }); + var buffer: [capacity + 1]u8 = undefined; + const data = try read(directory.dir, "cache.bin", &buffer); + const h264 = @import("h264_depacketizer"); + var depacketizer = h264.Depacketizer.init(102); + try std.testing.expect(depacketizer.setBootstrap(¶meter_sets)); + try std.testing.expect(!depacketizer.setBootstrap(data)); + try std.testing.expectEqualSlices(u8, &.{ 0x67, 0x42 }, depacketizer.sps[0..depacketizer.sps_length]); + try std.testing.expectEqualSlices(u8, &.{ 0x68, 0xce }, depacketizer.pps[0..depacketizer.pps_length]); +} + +test "cache accepts the exact capacity and rejects one extra byte" { + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + var data = [_]u8{0x55} ** (capacity + 1); + @memcpy(data[0..5], &[_]u8{ 0, 0, 0, 1, 0x67 }); + @memcpy(data[2052..2057], &[_]u8{ 0, 0, 0, 1, 0x68 }); + try directory.dir.writeFile(.{ .sub_path = "cache.bin", .data = data[0..capacity] }); + var buffer: [capacity + 1]u8 = undefined; + const loaded = try read(directory.dir, "cache.bin", &buffer); + try std.testing.expectEqualSlices(u8, data[0..capacity], loaded); + const h264 = @import("h264_depacketizer"); + var depacketizer = h264.Depacketizer.init(102); + try std.testing.expect(depacketizer.setBootstrap(loaded)); + + try directory.dir.writeFile(.{ .sub_path = "cache.bin", .data = &data }); + try std.testing.expectError(error.FileTooBig, read(directory.dir, "cache.bin", &buffer)); +} + +test "large cache is rejected rather than parsed as a prefix" { + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + var data = [_]u8{0x55} ** 16384; + @memcpy(data[0..parameter_sets.len], ¶meter_sets); + try directory.dir.writeFile(.{ .sub_path = "cache.bin", .data = &data }); + var buffer: [capacity + 1]u8 = undefined; + try std.testing.expectError(error.FileTooBig, read(directory.dir, "cache.bin", &buffer)); +} + +test "missing cache reports FileNotFound" { + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + var buffer: [capacity + 1]u8 = undefined; + try std.testing.expectError(error.FileNotFound, read(directory.dir, "missing.bin", &buffer)); +} + +test "cache read errors are returned" { + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + try directory.dir.makeDir("not-a-file"); + var buffer: [capacity + 1]u8 = undefined; + try std.testing.expectError(error.IsDir, read(directory.dir, "not-a-file", &buffer)); +} + +test "cache loads when the kernel has no statx syscall" { + if (builtin.os.tag != .linux) return error.SkipZigTest; + var directory = std.testing.tmpDir(.{}); + defer directory.cleanup(); + try directory.dir.writeFile(.{ .sub_path = "cache.bin", .data = ¶meter_sets }); + + const pid = try std.posix.fork(); + if (pid == 0) { + const linux = std.os.linux; + const bpf = linux.BPF; + const Filter = extern struct { code: u16, jt: u8 = 0, jf: u8 = 0, k: u32 }; + const Program = extern struct { len: u16, filter: [*]const Filter }; + const filters = [_]Filter{ + .{ .code = bpf.LD | bpf.W | bpf.ABS, .k = @offsetOf(linux.SECCOMP.data, "nr") }, + .{ .code = bpf.JMP | bpf.JEQ | bpf.K, .jf = 1, .k = @intFromEnum(linux.SYS.statx) }, + .{ .code = bpf.RET | bpf.K, .k = linux.SECCOMP.RET.ERRNO | @as(u32, @intFromEnum(linux.E.NOSYS)) }, + .{ .code = bpf.RET | bpf.K, .k = linux.SECCOMP.RET.ALLOW }, + }; + const program = Program{ .len = filters.len, .filter = &filters }; + // Keep the filter isolated from the test runner. + if (linux.prctl(@intFromEnum(linux.PR.SET_NO_NEW_PRIVS), 1, 0, 0, 0) != 0 or + linux.seccomp(linux.SECCOMP.SET_MODE_FILTER, 0, &program) != 0) linux.exit(1); + var stat: linux.Statx = undefined; + if (linux.E.init(linux.statx(-1, "", 0, 0, &stat)) != .NOSYS) linux.exit(2); + var buffer: [capacity + 1]u8 = undefined; + const data = read(directory.dir, "cache.bin", &buffer) catch linux.exit(3); + linux.exit(if (std.mem.eql(u8, ¶meter_sets, data)) 0 else 4); + } + const result = std.posix.waitpid(pid, 0); + try std.testing.expect(std.posix.W.IFEXITED(result.status)); + try std.testing.expectEqual(@as(u8, 0), std.posix.W.EXITSTATUS(result.status)); +} diff --git a/src/media/video/video_pipeline.zig b/src/media/video/video_pipeline.zig index 9dded60..458bf72 100644 --- a/src/media/video/video_pipeline.zig +++ b/src/media/video/video_pipeline.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const bootstrap_cache = @import("bootstrap_cache.zig"); const c = @cImport({ @cInclude("SDL2/SDL.h"); @@ -12,7 +13,7 @@ const c = @cImport({ @cInclude("video_pipeline.h"); }); -const bootstrap_capacity = 4104; +const bootstrap_capacity = bootstrap_cache.capacity; const packet_capacity = 4096; const queue_capacity = 128; const backpressure_attempts = 64; @@ -145,21 +146,21 @@ fn zSlice(buffer: []const u8) []const u8 { fn loadBootstrap(pipeline: *Pipeline) c_int { const path = zSlice(&pipeline.bootstrap_path); if (path.len == 0) return 0; - const file = std.fs.cwd().openFile(path, .{}) catch |err| { - if (err != error.FileNotFound and debugEnabled()) - std.debug.print("Ignoring unreadable H.264 bootstrap cache: {s}\n", .{@errorName(err)}); + var data: [bootstrap_capacity + 1]u8 = undefined; + const cached = bootstrap_cache.read(std.fs.cwd(), path, &data) catch |err| { + if (debugEnabled()) switch (err) { + error.FileNotFound => {}, + error.FileTooBig => std.debug.print("Ignoring oversized H.264 bootstrap cache (over {d} bytes)\n", .{bootstrap_capacity}), + else => std.debug.print("Ignoring unreadable H.264 bootstrap cache: {s}\n", .{@errorName(err)}), + }; return 0; }; - defer file.close(); - const size = file.getEndPos() catch return 0; - if (size == 0 or size > bootstrap_capacity) return 0; - var data: [bootstrap_capacity]u8 = undefined; - file.reader().readNoEof(data[0..@intCast(size)]) catch return 0; - if (c.go_h264_depacketizer_set_bootstrap(pipeline.depacketizer, &data, @intCast(size)) != 0) { + if (cached.len == 0) return 0; + if (c.go_h264_depacketizer_set_bootstrap(pipeline.depacketizer, cached.ptr, cached.len) != 0) { if (debugEnabled()) std.debug.print("Ignoring invalid H.264 bootstrap cache\n", .{}); return 0; } - if (debugEnabled()) std.debug.print("H.264 bootstrap loaded ({d} bytes)\n", .{size}); + if (debugEnabled()) std.debug.print("H.264 bootstrap loaded ({d} bytes)\n", .{cached.len}); return 0; }