Changes
3 changed files (+386/-0)
-
-
@@ -77,4 +77,14 @@ example_c.linkLibrary(lib);example_c.addIncludePath(.{ .cwd_relative = b.h_dir }); example_c_step.dependOn(&install_header.step); example_c_step.dependOn(&b.addInstallArtifact(example_c, .{}).step); // Unit tests. const test_step = b.step("test", "Run unit tests"); const unit_tests = b.addTest(.{ .name = "sood_test", .root_source_file = b.path("src/lib.zig"), .target = target, .optimize = optimize, }); test_step.dependOn(&b.addRunArtifact(unit_tests).step); }
-
-
src/Message.zig (new)
-
@@ -0,0 +1,370 @@// Copyright 2025 Shota FUJI // // Licensed under the Zero-Clause BSD License or the Apache License, Version 2.0, at your option. // You may not use, copy, modify, or distribute this file except according to those terms. You can // find a copy of the Zero-Clause BSD License at LICENSES/0BSD.txt, and a copy of the Apache License, // Version 2.0 at LICENSES/Apache-2.0.txt. You may also obtain a copy of the Apache License, Version // 2.0 at <https://www.apache.org/licenses/LICENSE-2.0> // // SPDX-License-Identifier: 0BSD OR Apache-2.0 //! This module contains parsing of and serializing from SOOD message used by Roon Core and //! discovery clients. References: //! <https://github.com/RoonLabs/node-roon-api/blob/51258392f8bfae3fe218740dda5bc049a822872e/sood.js> //! <https://github.com/pavoni/pyroon/blob/981a62b715c0bd31664342a7cff94a8624e18f79/roonapi/soodmessage.py> //! Since there is no official documentation and the message format is flexible (key-value fields,) //! this module does not add semantics or "static types" over it. const std = @import("std"); const Self = @This(); const magic_string = "SOOD\x02"; header: Header, body: Properties, pub const Kind = enum { /// A message has unsupported/unknown type field. Caller may reject a message of this kind. /// This variant exists for forward compatibility. unknown, /// Query message. A client performing discovery sends a message of this kind. query, /// Response message. Roon Core received a Query sends a message of this kind. response, }; pub const Header = struct { /// Message type. Using "kind" instead of "type" because the latter is keyword, and writing /// `@"type"` everytime would be annoying. kind: Kind = .unknown, pub const byte_size: usize = magic_string.len + 1; pub const ParseError = error{ /// A message header is incomplete. InvalidHeaderSize, /// A message does not have valid SOOD message signature: `['S', 'O', 'O', 'D', 0x2]`. InvalidSignature, }; /// Parse header part of the SOOD message bytes and populate struct fields, /// then returns bytes count read. pub fn parse(self: *Header, bytes: []const u8) ParseError!usize { if (bytes.len < byte_size) { return ParseError.InvalidHeaderSize; } if (!std.mem.startsWith(u8, bytes, magic_string)) { return ParseError.InvalidSignature; } self.kind = switch (bytes[5]) { 'Q' => .query, 'R' => .response, else => .unknown, }; return byte_size; } pub const WriteError = error{ /// Target buffer does not have enough space for the header. NoEnoughSpace, /// Tried to write unknown type message. UnknownKind, }; }; /// A pair of key and value exist in a message. pub const Property = struct { key: []const u8, value: []const u8, }; pub const Properties = struct { bytes: []const u8, pub const ParseError = error{ /// Value of key size field is 0. EmptyKey, /// Key is not long enough indicated by size field. IncompleteKey, /// Key is not valid UTF-8 string. NonUTF8Key, /// Same key appeared more than once. DuplicatedKey, /// Value size field is missing, or lacking a byte. InvalidValueSizeField, /// Value is not long enough indicated by size field. IncompleteValue, /// Value is not valid UTF-8 string. NonUTF8Value, }; pub const Iterator = struct { /// Position in the `bytes`. i: usize = 0, /// Reference to the Message. props: *const Properties, pub fn next(it: *Iterator) ParseError!?Property { if (it.i >= it.props.bytes.len) { return null; } const start_i = it.i; errdefer { it.i = start_i; } const key_size = it.props.bytes[it.i]; it.i += 1; if (key_size == 0) { return ParseError.EmptyKey; } const key_start = it.i; it.i += key_size; if (it.i > it.props.bytes.len) { return ParseError.IncompleteKey; } const key = it.props.bytes[key_start..it.i]; if (!std.unicode.utf8ValidateSlice(key)) { return ParseError.NonUTF8Key; } const value_size_start = it.i; it.i += 2; if (it.i > it.props.bytes.len) { return ParseError.InvalidValueSizeField; } const value_size = std.mem.readInt( u16, &.{ it.props.bytes[value_size_start], it.props.bytes[value_size_start + 1] }, .big, ); const value_start = it.i; it.i += value_size; if (it.i > it.props.bytes.len) { return ParseError.IncompleteValue; } const value = it.props.bytes[value_start..it.i]; if (!std.unicode.utf8ValidateSlice(value)) { return ParseError.NonUTF8Value; } return .{ .key = key, .value = value }; } }; pub fn iterator(props: *const Properties) Iterator { return .{ .props = props }; } pub fn estimateByteSize(props: []const Property) usize { var n: usize = 0; for (props) |p| { n += p.estimateByteSize(); } return n; } }; /// Returns a parsed message. Created Message's fields refer to the `bytes`: freeing `bytes` then /// accessing a Message's field would be use-after-free. pub fn parse(bytes: []const u8) !Self { var header = Header{}; const body_start = try header.parse(bytes); return .{ .header = header, .body = .{ .bytes = bytes[body_start..], }, }; } test parse { const message = try parse("SOOD\x02R\x03foo\x00\x03bar"); var it = message.body.iterator(); const foo = try it.next(); try std.testing.expect(std.mem.eql(u8, foo.?.key, "foo")); try std.testing.expect(std.mem.eql(u8, foo.?.value, "bar")); try std.testing.expect(try it.next() == null); } test "Should not parse non-SOOD messages" { { // PNG file signature const message = parse("\x89PNG\r\n\x1a\n"); try std.testing.expectError(Header.ParseError.InvalidSignature, message); } { // No `0x02` after `SOOD` const message = parse("SOODR\x02foo"); try std.testing.expectError(Header.ParseError.InvalidSignature, message); } { // Too short const message = parse("SOOD\x02"); try std.testing.expectError(Header.ParseError.InvalidHeaderSize, message); } } test "Should parse unknown message type" { { const message = try parse("SOOD\x02r"); try std.testing.expectEqual(Kind.unknown, message.header.kind); } { const message = try parse("SOOD\x02\x02"); try std.testing.expectEqual(Kind.unknown, message.header.kind); } } pub const WriteError = error{ /// Tried to write unknown type message. UnknownKind, /// Size of property key is too large. TooLargePropertyKey, /// Size of property value is too large. TooLargePropertyValue, }; /// Generate SOOD message bytes. Caller is responsible for `free`-ing the returned data. pub fn write(allocator: std.mem.Allocator, header: Header, props: []const Property) ![]u8 { var props_size: usize = 0; for (props) |p| { props_size += 1 + p.key.len + 2 + p.value.len; } const buf = try allocator.alloc(u8, Header.byte_size + props_size); errdefer allocator.free(buf); std.mem.copyForwards(u8, buf, magic_string); buf[magic_string.len] = switch (header.kind) { .query => 'Q', .response => 'R', else => return WriteError.UnknownKind, }; var i: usize = magic_string.len + 1; for (props) |p| { if (p.key.len > std.math.maxInt(u8)) { return WriteError.TooLargePropertyKey; } buf[i] = @truncate(p.key.len); i += 1; std.mem.copyForwards(u8, buf[i..], p.key); i += p.key.len; if (p.value.len > std.math.maxInt(u16)) { return WriteError.TooLargePropertyValue; } var value_size: [2]u8 = undefined; std.mem.writeInt(u16, &value_size, @truncate(p.value.len), .big); std.mem.copyForwards(u8, buf[i..], &value_size); i += 2; std.mem.copyForwards(u8, buf[i..], p.value); i += p.value.len; } return buf; } test write { const props = &[_]Property{ .{ .key = "foo", .value = "foo-foo" }, .{ .key = "bar", .value = "bar-bar" }, }; const bytes = try write(std.testing.allocator, .{ .kind = .query }, props); defer std.testing.allocator.free(bytes); try std.testing.expect( std.mem.eql(u8, bytes, "SOOD\x02Q\x03foo\x00\x07foo-foo\x03bar\x00\x07bar-bar"), ); } test "Should not write unknown message type" { const result = write(std.testing.allocator, .{ .kind = .unknown }, &.{}); try std.testing.expectError(WriteError.UnknownKind, result); } test "Should not write a key that its size exceeds MAX(u8)" { const key = try std.testing.allocator.alloc(u8, std.math.maxInt(u8) + 1); defer std.testing.allocator.free(key); @memset(key, '?'); const props = &[_]Property{ .{ .key = key, .value = "!" }, }; const result = write(std.testing.allocator, .{ .kind = .query }, props); try std.testing.expectError(WriteError.TooLargePropertyKey, result); } test "Should not write a value that its size exceeds MAX(u16)" { const value = try std.testing.allocator.alloc(u8, std.math.maxInt(u16) + 1); defer std.testing.allocator.free(value); @memset(value, '?'); const props = &[_]Property{ .{ .key = "?", .value = value }, }; const result = write(std.testing.allocator, .{ .kind = .query }, props); try std.testing.expectError(WriteError.TooLargePropertyValue, result); } test "Should write large property" { const key = try std.testing.allocator.alloc(u8, std.math.maxInt(u8) - 1); defer std.testing.allocator.free(key); @memset(key, 'K'); const value = try std.testing.allocator.alloc(u8, std.math.maxInt(u16) - 1); defer std.testing.allocator.free(value); @memset(value, 'V'); const props = &[_]Property{ .{ .key = key, .value = value }, }; const bytes = try write(std.testing.allocator, .{ .kind = .query }, props); defer std.testing.allocator.free(bytes); try std.testing.expect(bytes.len > 0); const message = try parse(bytes); try std.testing.expectEqual(Kind.query, message.header.kind); var it = message.body.iterator(); const prop = try it.next(); try std.testing.expectEqual(std.math.maxInt(u8) - 1, prop.?.key.len); try std.testing.expectEqual(std.math.maxInt(u16) - 1, prop.?.value.len); }
-
-
-
@@ -12,6 +12,12 @@ // ===// // Zig API. pub const Message = @import("Message.zig"); pub fn add(a: i32, b: i32) i32 { return a + b; } test { _ = @import("Message.zig"); }
-