Changes
5 changed files (+209/-218)
-
-
@@ -17,13 +17,17 @@ //! this module does not add semantics or "static types" over it.const std = @import("std"); const Self = @This(); const constants = @import("constants.zig"); const property = @import("Message/property.zig"); const magic_string = "SOOD\x02"; pub const Property = property.Property; pub const PropertyParseError = property.ParseError; header: Header, const Self = @This(); body: Properties, kind: Kind, body: []const u8, pub const Kind = enum { /// A message has unsupported/unknown type field. Caller may reject a message of this kind.
-
@@ -37,223 +41,108 @@ /// 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 HeaderParseError = error{ /// A message header is incomplete. InvalidHeaderSize, 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 message does not have valid SOOD message signature: `['S', 'O', 'O', 'D', 0x2]`. InvalidSignature, }; /// A pair of key and value exist in a message. pub const Property = struct { key: []const u8, value: []const u8, }; pub const ReadPropertyError = 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, /// 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 fn readProperty(bytes: []const u8, dst: *Property) ReadPropertyError!usize { var i: usize = 0; const key_size = bytes[i]; i += 1; if (key_size == 0) { return ReadPropertyError.EmptyKey; /// 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) HeaderParseError!Self { if (bytes.len < constants.header_byte_size) { return HeaderParseError.InvalidHeaderSize; } const key_start = i; i += key_size; if (i > bytes.len) { return ReadPropertyError.IncompleteKey; } const key = bytes[key_start..i]; if (!std.unicode.utf8ValidateSlice(key)) { return ReadPropertyError.NonUTF8Key; } const value_size_start = i; i += 2; if (i > bytes.len) { return ReadPropertyError.InvalidValueSizeField; } const value_size = std.mem.readInt( u16, &.{ bytes[value_size_start], bytes[value_size_start + 1] }, .big, ); const value_start = i; i += value_size; if (i > bytes.len) { return ReadPropertyError.IncompleteValue; } const value = bytes[value_start..i]; if (!std.unicode.utf8ValidateSlice(value)) { return ReadPropertyError.NonUTF8Value; if (!std.mem.startsWith(u8, bytes, constants.magic_string)) { return HeaderParseError.InvalidSignature; } dst.key = key; dst.value = value; return i; } pub const Properties = struct { bytes: []const u8, pub const ParseError = ReadPropertyError; pub const Iterator = struct { /// Position in the `bytes`. i: usize = 0, /// Reference to the Message. props: *const Properties, pub fn next(it: *Iterator) ReadPropertyError!?Property { if (it.i >= it.props.bytes.len) { return null; } const start_i = it.i; errdefer { it.i = start_i; } var p: Property = undefined; it.i += try readProperty(it.props.bytes[it.i..], &p); return p; } const kind: Kind = switch (bytes[5]) { 'Q' => .query, 'R' => .response, else => .unknown, }; 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..], }, .kind = kind, .body = bytes[constants.header_byte_size..], }; } 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); try std.testing.expectError(HeaderParseError.InvalidSignature, message); } { // No `0x02` after `SOOD` const message = parse("SOODR\x02foo"); try std.testing.expectError(Header.ParseError.InvalidSignature, message); try std.testing.expectError(HeaderParseError.InvalidSignature, message); } { // Too short const message = parse("SOOD\x02"); try std.testing.expectError(Header.ParseError.InvalidHeaderSize, message); try std.testing.expectError(HeaderParseError.InvalidHeaderSize, message); } } test "Should parse unknown message type" { { const message = try parse("SOOD\x02r"); try std.testing.expectEqual(Kind.unknown, message.header.kind); try std.testing.expectEqual(Kind.unknown, message.kind); } { const message = try parse("SOOD\x02\x02"); try std.testing.expectEqual(Kind.unknown, message.header.kind); try std.testing.expectEqual(Kind.unknown, message.kind); } } pub const Iterator = struct { /// Remaining bytes. bytes: []const u8, pub fn next(it: *Iterator) PropertyParseError!?Property { if (it.bytes.len == 0) { return null; } var p: Property = undefined; const i = try property.parseInto(it.bytes, &p); it.bytes = it.bytes[i..]; return p; } }; pub fn iterator(self: *const Self) Iterator { return .{ .bytes = self.body }; } test iterator { const message = try parse("SOOD\x02R\x03foo\x00\x03bar"); var it = message.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); } pub const WriteError = error{ /// Target buffer does not have enough space for the header. NoEnoughSpace, /// Tried to write unknown type message. UnknownKind,
-
@@ -265,24 +154,24 @@ 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 { pub fn write(allocator: std.mem.Allocator, kind: Kind, 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); const buf = try allocator.alloc(u8, constants.header_byte_size + props_size); errdefer allocator.free(buf); std.mem.copyForwards(u8, buf, magic_string); std.mem.copyForwards(u8, buf, constants.magic_string); buf[magic_string.len] = switch (header.kind) { buf[constants.magic_string.len] = switch (kind) { .query => 'Q', .response => 'R', else => return WriteError.UnknownKind, }; var i: usize = magic_string.len + 1; var i: usize = constants.magic_string.len + 1; for (props) |p| { if (p.key.len > std.math.maxInt(u8)) { return WriteError.TooLargePropertyKey;
-
@@ -310,7 +199,7 @@ const props = &[_]Property{.{ .key = "foo", .value = "foo-foo" }, .{ .key = "bar", .value = "bar-bar" }, }; const bytes = try write(std.testing.allocator, .{ .kind = .query }, props); const bytes = try write(std.testing.allocator, .query, props); defer std.testing.allocator.free(bytes); try std.testing.expect(
-
@@ -319,7 +208,7 @@ );} test "Should not write unknown message type" { const result = write(std.testing.allocator, .{ .kind = .unknown }, &.{}); const result = write(std.testing.allocator, .unknown, &.{}); try std.testing.expectError(WriteError.UnknownKind, result); }
-
@@ -332,7 +221,7 @@const props = &[_]Property{ .{ .key = key, .value = "!" }, }; const result = write(std.testing.allocator, .{ .kind = .query }, props); const result = write(std.testing.allocator, .query, props); try std.testing.expectError(WriteError.TooLargePropertyKey, result); }
-
@@ -345,7 +234,7 @@const props = &[_]Property{ .{ .key = "?", .value = value }, }; const result = write(std.testing.allocator, .{ .kind = .query }, props); const result = write(std.testing.allocator, .query, props); try std.testing.expectError(WriteError.TooLargePropertyValue, result); }
-
@@ -362,16 +251,16 @@const props = &[_]Property{ .{ .key = key, .value = value }, }; const bytes = try write(std.testing.allocator, .{ .kind = .query }, props); const bytes = try write(std.testing.allocator, .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); try std.testing.expectEqual(Kind.query, message.kind); var it = message.body.iterator(); var it = message.iterator(); const prop = try it.next();
-
-
src/Message/property.zig (new)
-
@@ -0,0 +1,82 @@// 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 const std = @import("std"); /// A pair of key and value exist in a message. pub const Property = struct { key: []const u8, value: []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, /// 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 fn parseInto(bytes: []const u8, dst: *Property) ParseError!usize { var i: usize = 0; const key_size = bytes[i]; i += 1; if (key_size == 0) { return ParseError.EmptyKey; } const key_start = i; i += key_size; if (i > bytes.len) { return ParseError.IncompleteKey; } const key = bytes[key_start..i]; if (!std.unicode.utf8ValidateSlice(key)) { return ParseError.NonUTF8Key; } const value_size_start = i; i += 2; if (i > bytes.len) { return ParseError.InvalidValueSizeField; } const value_size = std.mem.readInt( u16, &.{ bytes[value_size_start], bytes[value_size_start + 1] }, .big, ); const value_start = i; i += value_size; if (i > bytes.len) { return ParseError.IncompleteValue; } const value = bytes[value_start..i]; if (!std.unicode.utf8ValidateSlice(value)) { return ParseError.NonUTF8Value; } dst.key = key; dst.value = value; return i; }
-
-
-
@@ -21,7 +21,10 @@ // the header file. They should appear in the same order as possible. If you modified definitions// in this file, update `c.h` too. const std = @import("std"); const sood = @import("lib.zig"); const property = @import("Message/property.zig"); const constants = @import("constants.zig"); const sood_message_kind = enum(c_int) { SOOD_MESSAGE_UNKNOWN = 0,
-
@@ -45,13 +48,13 @@ export fn sood_parse(dst: *sood_message, ptr: [*]const u8, len: usize) sood_parse_result {const bytes = ptr[0..len]; const message = sood.Message.parse(bytes) catch |err| return switch (err) { sood.Message.Header.ParseError.InvalidHeaderSize => .SOOD_PARSE_ERROR_HEADER_SIZE_MISMATCH, sood.Message.Header.ParseError.InvalidSignature => .SOOD_PARSE_ERROR_INVALID_SIGNATURE, sood.Message.HeaderParseError.InvalidHeaderSize => .SOOD_PARSE_ERROR_HEADER_SIZE_MISMATCH, sood.Message.HeaderParseError.InvalidSignature => .SOOD_PARSE_ERROR_INVALID_SIGNATURE, }; dst.raw_ptr = ptr; dst.raw_len = len; dst.sood_message_kind = switch (message.header.kind) { dst.sood_message_kind = switch (message.kind) { .unknown => .SOOD_MESSAGE_UNKNOWN, .query => .SOOD_MESSAGE_QUERY, .response => .SOOD_MESSAGE_RESPONSE,
-
@@ -85,7 +88,7 @@ };export fn sood_message_iterator(dst: *sood_message_iter, msg: *const sood_message) void { dst.msg = msg; dst.i = sood.Message.Header.byte_size; dst.i = constants.header_byte_size; } export fn sood_message_iter_next(
-
@@ -99,13 +102,13 @@ }var p: sood.Message.Property = undefined; iter.i += sood.Message.readProperty(bytes, &p) catch |err| return switch (err) { sood.Message.ReadPropertyError.EmptyKey => .SOOD_READ_PROPERTY_ERROR_EMPTY_KEY, sood.Message.ReadPropertyError.IncompleteKey => .SOOD_READ_PROPERTY_ERROR_KEY_SIZE_MISMATCH, sood.Message.ReadPropertyError.NonUTF8Key => .SOOD_READ_PROPERTY_ERROR_NON_UTF8_KEY, sood.Message.ReadPropertyError.InvalidValueSizeField => .SOOD_READ_PROPERTY_ERROR_VALUE_SIZE_CORRUPTED, sood.Message.ReadPropertyError.NonUTF8Value => .SOOD_READ_PROPERTY_ERROR_NON_UTF8_VALUE, sood.Message.ReadPropertyError.IncompleteValue => .SOOD_READ_PROPERTY_ERROR_VALUE_SIZE_MISMATCH, iter.i += property.parseInto(bytes, &p) catch |err| return switch (err) { sood.Message.PropertyParseError.EmptyKey => .SOOD_READ_PROPERTY_ERROR_EMPTY_KEY, sood.Message.PropertyParseError.IncompleteKey => .SOOD_READ_PROPERTY_ERROR_KEY_SIZE_MISMATCH, sood.Message.PropertyParseError.NonUTF8Key => .SOOD_READ_PROPERTY_ERROR_NON_UTF8_KEY, sood.Message.PropertyParseError.InvalidValueSizeField => .SOOD_READ_PROPERTY_ERROR_VALUE_SIZE_CORRUPTED, sood.Message.PropertyParseError.NonUTF8Value => .SOOD_READ_PROPERTY_ERROR_NON_UTF8_VALUE, sood.Message.PropertyParseError.IncompleteValue => .SOOD_READ_PROPERTY_ERROR_VALUE_SIZE_MISMATCH, }; dst.key_ptr = p.key.ptr;
-
@@ -116,7 +119,7 @@return .SOOD_READ_PROPERTY_OK; } export const sood_discovery_query_prebuilt = sood.discovery.Query.prebuilt.*; export const sood_discovery_query_prebuilt = constants.prebuilt_query.*; export const sood_discovery_multicast_ipv4_address_be = std.mem.readInt(u32, &sood.discovery.multicast_ipv4_address, .big); export const sood_discovery_multicast_ipv4_address = sood.discovery.multicast_ipv4_address; export const sood_discovery_server_udp_port: u16 = sood.discovery.udp_port;
-
@@ -127,7 +130,7 @@try testing.expectEqual( // This MUST be the same value as one in `c.h`. 61, sood_discovery_query_prebuilt.len, constants.prebuilt_query.len, ); }
-
-
src/constants.zig (new)
-
@@ -0,0 +1,22 @@// 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 pub const header_byte_size: usize = magic_string.len + 1; pub const magic_string = "SOOD\x02"; pub const query_service_id_key = "query_service_id"; pub const query_service_id_value = "00720724-5143-4a9b-abac-0e50cba674bb"; pub const prebuilt_query = magic_string ++ "Q" ++ .{query_service_id_key.len} ++ query_service_id_key ++ .{ 0, query_service_id_value.len } ++ query_service_id_value;
-
-
-
@@ -12,6 +12,7 @@ //! This module contains constants and concret type on top of Message struct.const std = @import("std"); const constants = @import("constants.zig"); const Message = @import("Message.zig"); /// Target UDP port to use for sending IP multicast and broadcast.
-
@@ -23,13 +24,11 @@ /// IPv4 address for IP multicast. Send SOOD message to this IP address on `udp_port`.pub const multicast_ipv4_address = [4]u8{ 239, 255, 90, 90 }; pub const Query = struct { pub const header = Message.Header{ .kind = .query }; /// A required property for a discovery query. Node.js API uses hard-coded ID /// thus this library uses the same one. pub const query_service_id = Message.Property{ .key = "query_service_id", .value = "00720724-5143-4a9b-abac-0e50cba674bb", .key = constants.query_service_id_key, .value = constants.query_service_id_value, }; pub const properties = [_]Message.Property{
-
@@ -37,15 +36,11 @@ query_service_id,}; /// Premade bytes for minimum discovery query message. pub const prebuilt = "SOOD\x02Q\x10query_service_id\x00\x2400720724-5143-4a9b-abac-0e50cba674bb"; pub const prebuilt = constants.prebuilt_query; }; test "Query.prebuilt should equal to one built by `write`" { const built = try Message.write( std.testing.allocator, Query.header, &Query.properties, ); const built = try Message.write(std.testing.allocator, .query, &Query.properties); defer std.testing.allocator.free(built); try std.testing.expectEqualSlices(u8, built, Query.prebuilt);
-
@@ -83,11 +78,11 @@ /// Cannot convert property value to desired type.UnexpectedPropertyValue, }; pub const ParseError = SchemaError || Message.Header.ParseError || Message.Properties.ParseError; pub const ParseError = SchemaError || Message.HeaderParseError || Message.PropertyParseError; pub fn parse(bytes: []const u8) ParseError!@This() { const msg = try Message.parse(bytes); if (msg.header.kind != .response) { if (msg.kind != .response) { return SchemaError.NonResponseKindMessage; }
-
@@ -96,7 +91,7 @@ var name: ?[]const u8 = null;var display_version: ?[]const u8 = null; var unique_id: ?[]const u8 = null; var iter = msg.body.iterator(); var iter = msg.iterator(); while (try iter.next()) |property| { if (std.mem.eql(u8, "http_port", property.key)) {
-