Changes
10 changed files (+352/-174)
-
-
@@ -44,19 +44,6 @@ const optimize = b.standardOptimizeOption(.{});const compile_gschema = b.option(bool, "compile-gschema", "Compile gschema XML file for local run") orelse false; const libsood = libsood: { const sood = b.dependency("sood", .{ .target = target, .optimize = optimize, .linkage = .static, }); const lib = sood.artifact("sood"); lib.installHeader(b.path("src/libsood.vapi"), "libsood.vapi"); break :libsood lib; }; // System libraries to link. const system_libraries = [_][]const u8{ "gtk4",
-
@@ -87,12 +74,9 @@ compile.addPackage(lib);} compile.addPackage("posix"); compile.addPackage("libsood"); compile.addGResourceXML(b.path("data/gresource.xml")); compile.addVapi(b.path("src/libsood.vapi")); const step = b.step("valac", "Compile C source code from Vala files for debugging purpose"); step.dependOn(compile.step);
-
@@ -150,8 +134,6 @@ // linker.for (system_libraries) |lib| { exe.linkSystemLibrary(lib); } exe.root_module.linkLibrary(libsood); exe.addCSourceFile(.{ .file = gresouce_c });
-
@@ -260,9 +242,44 @@break :moo_test run; }; const sood_test = sood_test: { var vala = try CompileVala.init(b, .{ .src = b.path("src/Sood"), .includeTestFiles = true, }); vala.addPackage("glib-2.0"); vala.addPackage("gee-0.8"); const test_exe = b.addExecutable(.{ .name = "sood_test", .root_module = b.createModule(.{ .link_libc = true, .target = target, .optimize = optimize, }), }); test_exe.linkSystemLibrary("gee-0.8"); test_exe.linkSystemLibrary("glib-2.0"); test_exe.linkSystemLibrary("gobject-2.0"); for (vala.compiled_c_files) |file| { test_exe.root_module.addCSourceFile(.{ .file = file }); } const run = b.addRunArtifact(test_exe); const step = b.step("sood-test", "Test SOOD message parsing and building"); step.dependOn(&run.step); break :sood_test run; }; // `zig build test` { const step = b.step("test", "Run unit tests"); step.dependOn(&moo_test.step); step.dependOn(&sood_test.step); } }
-
-
-
@@ -18,12 +18,7 @@ .name = .plac_gtk,.version = "0.0.0", .fingerprint = 0xd79b7f79b5956281, .minimum_zig_version = "0.14.0", .dependencies = .{ .sood = .{ .url = "https://git.pocka.jp/libsood.git/archive/8080245c2696cc6404b0628e5c3eb363cb80014f.tar.gz", .hash = "sood-0.0.0-A_jj-7ITAQAPlaaR2AHFXwPvBWzNBCCPTT--OCHnRQ_i", }, }, .dependencies = .{}, .paths = .{ "build.zig", "build.zig.zon",
-
-
-
@@ -42,14 +42,6 @@ inrec { packages = { default = pkgs.callPackage ./nix/package.nix { }; update-deps = pkgs.writeShellApplication { name = "update-deps"; text = '' ${pkgs.zon2nix}/bin/zon2nix > nix/deps.nix ''; }; }; devShell = pkgs.mkShell {
-
-
nix/deps.nix (deleted)
-
@@ -1,17 +0,0 @@# generated by zon2nix (https://github.com/nix-community/zon2nix) { linkFarm, fetchzip, fetchgit, }: linkFarm "zig-packages" [ { name = "sood-0.0.0-A_jj-7ITAQAPlaaR2AHFXwPvBWzNBCCPTT--OCHnRQ_i"; path = fetchzip { url = "https://git.pocka.jp/libsood.git/archive/8080245c2696cc6404b0628e5c3eb363cb80014f.tar.gz"; hash = "sha256-ikHN1tvw/zNsSYgSNWVembjJogK1aSjmeCnN8tRh/dc="; }; } ]
-
-
nix/deps.nix.license (deleted)
-
@@ -1,20 +0,0 @@Copyright 2025 Shota FUJI Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. SPDX-License-Identifier: Apache-2.0 --- "deps.nix" is autogenerated file. Applying same copyright and license for simplicity and consistency.
-
-
-
@@ -114,9 +114,5 @@ # https://nixos.org/manual/nixpkgs/stable/#sec-language-gnomewrapGAppsHook4 ]; zigBuildFlags = [ "--system" (callPackage ./deps.nix { }) "-Dcompile-gschema" ]; zigBuildFlags = [ "-Dcompile-gschema" ]; }
-
-
-
@@ -49,12 +49,13 @@ var socket = new GLib.Socket(IPV4, DATAGRAM, UDP);socket.set_timeout(3); var inet_addr = new GLib.InetAddress.from_bytes(Sood.DISCOVERY_MULTICAST_IPV4_ADDRESS, IPV4); var sock_addr = new GLib.InetSocketAddress(inet_addr, Sood.DISCOVERY_SERVER_UDP_PORT); // These connection address is hard-coded in Roon Node.js SDK. var inet_addr = new GLib.InetAddress.from_bytes({ 239, 255, 90, 90 }, IPV4); var sock_addr = new GLib.InetSocketAddress(inet_addr, 9003); while (true) { try { socket.send_to(sock_addr, (uint8[]) Sood.DISCOVERY_QUERY_PREBUILT, cancellable); socket.send_to(sock_addr, (uint8[]) Sood.discovery_query, cancellable); GLib.log("Plac", LEVEL_DEBUG, "Sent discovery query"); } catch (GLib.Error error) {
-
@@ -83,24 +84,46 @@ GLib.log("Plac", LEVEL_WARNING, "Received discovery response from non-IP socket");continue; } Sood.DiscoveryResponse resp; var result = Sood.DiscoveryResponse.parse(out resp, (char[]) bytes.get_data()); if (result != Sood.Result.OK) { GLib.log("Plac", LEVEL_WARNING, "Received malformed discovery response: %s", result.to_string()); Sood.Message resp; try { resp = new Sood.Message.from_bytes(bytes); } catch (Sood.ParseError error) { GLib.log("Plac", LEVEL_WARNING, "Received malformed discovery response: %s", error.message); continue; } if (resp.properties["unique_id"] == null) { GLib.log("Plac", LEVEL_WARNING, "Discovery response does not contain unique_id property"); continue; } if (resp.properties["display_version"] == null) { GLib.log("Plac", LEVEL_WARNING, "Discovery response does not contain display_version property"); continue; } if (resp.properties["name"] == null) { GLib.log("Plac", LEVEL_WARNING, "Discovery response does not contain name property"); continue; } if (resp.properties["http_port"] == null) { GLib.log("Plac", LEVEL_WARNING, "Discovery response does not contain http_port property"); continue; } var http_port = resp.properties["http_port"].to_int(); if (http_port < 0 || http_port > uint16.MAX) { GLib.log("Plac", LEVEL_WARNING, "Discovery response contains http_port value outside of 16bit uint"); continue; } // String in Vala is null-terminated, without exception. // Using non null-terminated string or casting uint8[] to string // will result in out of bound reads. I found no function, class, // or language builtins for allocating null-terminated string from // non-null one. Slicing is the closest I got. var server = new Server( (GLib.InetSocketAddress) addr, resp.unique_id.slice(0, (long) resp.unique_id_len), resp.display_version.slice(0, (long) resp.display_version_len), resp.name.slice(0, (long) resp.name_len), resp.http_port resp.properties["unique_id"], resp.properties["display_version"], resp.properties["name"], (uint16) http_port ); GLib.Idle.add(() => {
-
-
src/Sood/Sood.test.vala (new)
-
@@ -0,0 +1,129 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 using GLib; void main(string[] args) { Test.init(ref args); Test.add_func("/sood/parse-simple", () => { try { uint8[] input = { 'S', 'O', 'O', 'D', 2, 'R', 3, 'f', 'o', 'o', 0, 3, 'b', 'a', 'r' }; var message = new Sood.Message.from_bytes(new Bytes(input)); assert(message.kind == RESPONSE); assert(message.properties["foo"] == "bar"); } catch (Error error) { assert_no_error(error); } }); Test.add_func("/sood/reject-png-signature", () => { try { uint8[] input = { 137, 'P', 'N', 'G', 13, 10, 26, 10 }; new Sood.Message.from_bytes(new Bytes(input)); message("Expected an error, but parsing succeeded"); assert_not_reached(); } catch (Sood.ParseError error) { if (!(error is Sood.ParseError.SIGNATURE_MISMATCH)) { message( "Expected SIGNATURE_MISMATCH(%d), got (%d)", Sood.ParseError.SIGNATURE_MISMATCH, error.code ); assert_not_reached(); } } }); Test.add_func("/sood/reject-missing-byte-mark", () => { try { uint8[] input = { 'S', 'O', 'O', 'D', 'R', 2, 'f', 'o' }; new Sood.Message.from_bytes(new Bytes(input)); message("Expected an error, but parsing succeeded"); assert_not_reached(); } catch (Sood.ParseError error) { if (!(error is Sood.ParseError.SIGNATURE_MISMATCH)) { message( "Expected SIGNATURE_MISMATCH(%d), got (%d)", Sood.ParseError.SIGNATURE_MISMATCH, error.code ); assert_not_reached(); } } }); Test.add_func("/sood/reject-too-short-message", () => { try { uint8[] input = { 'S', 'O', 'O', 'D', 2 }; new Sood.Message.from_bytes(new Bytes(input)); message("Expected an error, but parsing succeeded"); assert_not_reached(); } catch (Sood.ParseError error) { if (!(error is Sood.ParseError.INVALID_HEADER_SIZE)) { message( "Expected INVALID_HEADER_SIZE(%d), got (%d)", Sood.ParseError.INVALID_HEADER_SIZE, error.code ); assert_not_reached(); } } }); Test.add_func("/sood/unknown-kind-letter", () => { try { uint8[] input = { 'S', 'O', 'O', 'D', 2, 'r' }; var message = new Sood.Message.from_bytes(new Bytes(input)); assert(message.kind == UNKNOWN); } catch (Error error) { assert_no_error(error); } }); Test.add_func("/sood/unknown-kind-number", () => { try { uint8[] input = { 'S', 'O', 'O', 'D', 2, 0 }; var message = new Sood.Message.from_bytes(new Bytes(input)); assert(message.kind == UNKNOWN); } catch (Error error) { assert_no_error(error); } }); Test.add_func("/sood/parse-discovery-query", () => { try { var message = new Sood.Message.from_bytes(new Bytes(Sood.discovery_query)); assert(message.kind == QUERY); assert(message.properties["query_service_id"] == "00720724-5143-4a9b-abac-0e50cba674bb"); } catch (Error error) { assert_no_error(error); } }); Test.run(); }
-
-
src/Sood/Sood.vala (new)
-
@@ -0,0 +1,147 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 using GLib; namespace Sood { public errordomain ParseError { SIGNATURE_MISMATCH, INVALID_HEADER_SIZE, INVALID_PROPERTY_KEY, INVALID_PROPERTY_VALUE, DUPLICATED_PROPERTY_KEY, } public enum Kind { UNKNOWN = 0, QUERY = 1, RESPONSE = 2; internal static Kind from_byte(uint8 byte) { switch (byte) { case 'Q': return QUERY; case 'R': return RESPONSE; default: return UNKNOWN; } } } public const uint8[] discovery_query = { 'S', 'O', 'O', 'D', 2, 'Q', 16, // query_service_id 'q', 'u', 'e', 'r', 'y', '_', 's', 'e', 'r', 'v', 'i', 'c', 'e', '_', 'i', 'd', 0, 36, // 00720724-5143-4a9b-abac-0e50cba674bb '0', '0', '7', '2', '0', '7', '2', '4', '-', '5', '1', '4', '3', '-', '4', 'a', '9', 'b', '-', 'a', 'b', 'a', 'c', '-', '0', 'e', '5', '0', 'c', 'b', 'a', '6', '7', '4', 'b', 'b' }; private const uint8[] signature = { 'S', 'O', 'O', 'D', 2 }; public class Message : Object { public Kind kind { get; set construct; } public Gee.HashMap<string, string> properties { get; construct; } public Message.from_bytes(Bytes bytes) throws ParseError { if (bytes.length < signature.length + 1) { throw new ParseError.INVALID_HEADER_SIZE("Too small header size, unlikely SOOD message."); } var properties = new Gee.HashMap<string, string>(); int i = 0; for (; i < signature.length; i++) { if (bytes[i] != signature[i]) { throw new ParseError.SIGNATURE_MISMATCH("Message does not have valid SOOD signature."); } } var kind = Kind.from_byte(bytes[i]); i += 1; while (i < bytes.length) { var key = parse_property_key(bytes, ref i); if (i >= bytes.length) { throw new ParseError.INVALID_PROPERTY_VALUE("Property value is missing after key field."); } if (properties.has_key(key)) { throw new ParseError.DUPLICATED_PROPERTY_KEY("Found duplicated property."); } var value = parse_property_value(bytes, ref i); properties[key] = value; } Object(kind: kind, properties: properties); } private static string parse_property_key(Bytes bytes, ref int position) throws ParseError { var size = bytes[position]; position += 1; if (size == 0) { throw new ParseError.INVALID_PROPERTY_KEY("Found property key of zero bytes."); } if ((position + size) > bytes.length) { throw new ParseError.INVALID_PROPERTY_KEY("Declared key size overflows byte array."); } var key = bytes.slice(position, position + size); for (int i = 0; i < key.length; i++) { if (key[i] == 0) { throw new ParseError.INVALID_PROPERTY_KEY("Property key contains NUL character."); } } var key_data = Bytes.unref_to_data(key); position += size; return "%.*s".printf(key_data.length, key_data); } private static string parse_property_value(Bytes bytes, ref int position) throws ParseError { // Size field is 16bit and value can be empty, so the minimum byte size is 2. if (position + 2 >= bytes.length) { throw new ParseError.INVALID_PROPERTY_VALUE("Property value size is corrupted."); } var size = uint16.from_big_endian(((uint16) bytes[position]) | ((uint16) (bytes[position + 1] << 8))); position += 2; if ((position + size) > bytes.length) { throw new ParseError.INVALID_PROPERTY_VALUE("Declared value size overflows byte array."); } var value = bytes.slice(position, position + size); for (int i = 0; i < value.length; i++) { if (value[i] == 0) { throw new ParseError.INVALID_PROPERTY_VALUE("Property value contains NUL character."); } } var value_data = Bytes.unref_to_data(value); position += size; return "%.*s".printf(value_data.length, value_data); } } }
-
-
src/libsood.vapi (deleted)
-
@@ -1,84 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 [CCode (cheader_filename = "sood.h")] namespace Sood { [CCode ( cname = "sood_result", cprefix = "SOOD_", has_type_id = false )] public enum Result { OK = 0, ITERATOR_DONE = 1, ERR_PARSE_HEADER_SIZE_MISMATCH = 2, ERR_PARSE_INVALID_SIGNATURE = 3, ERR_PARSE_EMPTY_KEY = 4, ERR_PARSE_KEY_SIZE_MISMATCH = 5, ERR_PARSE_NON_UTF8_KEY = 6, ERR_PARSE_VALUE_SIZE_CORRUPTED = 7, ERR_PARSE_VALUE_SIZE_MISMATCH = 8, ERR_PARSE_NON_UTF8_VALUE = 9, ERR_VALIDATE_MISSING_REQUIRED_PROPERTY = 10, ERR_VALIDATE_UNEXPECTED_VALUE_TYPE = 11, ERR_VALIDATE_UNEXPECTED_MESSAGE_KIND = 12, } [CCode (cname = "sood_discovery_response", has_type_id = false)] public struct DiscoveryResponse { public uint16 http_port; [CCode ( cname = "name_ptr", array_length_cname = "name_len", array_length_type = "size_t", array_null_terminated = false )] public unowned string name; public size_t name_len; [CCode ( cname = "display_version_ptr", array_length_cname = "display_version_len", array_length_type = "size_t", array_null_terminated = false )] public unowned string display_version; public size_t display_version_len; [CCode ( cname = "unique_id_ptr", array_length_cname = "unique_id_len", array_length_type = "size_t", array_null_terminated = false )] public unowned string unique_id; public size_t unique_id_len; [CCode (array_length_type = "size_t")] public static Result parse(out DiscoveryResponse dst, char[] message); } [CCode (cname = "SOOD_DISCOVERY_QUERY_PREBUILT")] public const char[] DISCOVERY_QUERY_PREBUILT; [CCode (cname = "SOOD_DISCOVERY_MULTICAST_IPV4_ADDRESS")] public const uint8[] DISCOVERY_MULTICAST_IPV4_ADDRESS; [CCode (cname = "SOOD_DISCOVERY_SERVER_UDP_PORT")] public const uint16 DISCOVERY_SERVER_UDP_PORT; }
-