diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index e4135b6..90c24fd 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -61,6 +61,10 @@ internal static class SelfTest RunStep(results, "Updater tag filtering (client vs server releases, version order)", UpdaterTagFiltering); RunStep(results, "CGNAT range detection (RFC 6598)", CgnatDetection); RunStep(results, "Network QoS attach (no throw on a bound socket)", NetworkPrioritySmoke); + RunStep(results, "PCM frame reassembly (order, loss, discard counters)", PcmReassembly); + RunStep(results, "Receiver allow-list gate (stranger rejected, no session)", ReceiverAllowGate); + RunStep(results, "Receiver decryptor: wrong key yields silence, not noise", DecryptorWrongKey); + RunStep(results, "Heartbeat wire round-trip + ping→pong echo", HeartbeatEcho); RunStep(results, "App settings save and reload", SettingsRoundTrip); RunStep(results, "Per-peer shaping DSP", PeerShapingDsp); RunStep(results, "Multi-output fan-out (both lanes)", FanOutToBothOutputs); @@ -951,6 +955,112 @@ internal static class SelfTest return "follower flagged + sentinel shared with the app; service resolves it to the live default render endpoint"; } + /// Multi-part PCM reassembly: on a lossy network a mis-assembly means audible corruption + /// while packets still flow. Pins byte-exact in-order assembly, rejection of out-of-order/oversize/ + /// zero-part input, discard-counting of half-finished frames, and recovery after every failure. + private static string? PcmReassembly() + { + var asm = new RemSound.Receiver.PcmFrameAssembler(); + var part0 = new byte[1440]; for (var i = 0; i < part0.Length; i++) part0[i] = (byte)i; + var part1 = new byte[1440]; for (var i = 0; i < part1.Length; i++) part1[i] = (byte)(i + 7); + + Check(!asm.TryAssemble(part0, frameId: 1, partIndex: 0, totalParts: 2, out _), "part 0 alone must not complete a 2-part frame"); + Check(asm.TryAssemble(part1, 1, 1, 2, out var assembled), "part 1 must complete it"); + Check(assembled.Length == 2880 && assembled[..1440].SequenceEqual(part0) && assembled[1440..].SequenceEqual(part1), + "the assembled frame must be both parts, byte-exact, in order"); + + Check(!asm.TryAssemble(part1, 2, 1, 2, out _), "a part 1 whose part 0 was lost must be rejected"); + Check(asm.RejectionCount == 1, $"the lost-start rejection must be counted (got {asm.RejectionCount})"); + + Check(!asm.TryAssemble(part0, 3, 0, 2, out _), "start frame 3"); + Check(!asm.TryAssemble(part0, 4, 0, 2, out _), "frame 4 starting before frame 3 finished must begin fresh"); + Check(asm.DiscardedPartialCount == 1, $"the half-finished frame 3 must be counted as discarded (got {asm.DiscardedPartialCount})"); + Check(asm.TryAssemble(part1, 4, 1, 2, out var after) && after.Length == 2880, "frame 4 must still assemble cleanly after the discard"); + + Check(!asm.TryAssemble(part0, 5, 0, 0, out _), "totalParts=0 must be rejected"); + var huge = new byte[5000]; + Check(!asm.TryAssemble(huge, 6, 0, 2, out _), "start an oversize frame"); + Check(!asm.TryAssemble(huge, 6, 1, 2, out _), "a frame overflowing the assembly buffer must be rejected, not overrun"); + Check(asm.RejectionCount == 3, $"zero-part + overflow must be counted (got {asm.RejectionCount})"); + return "in-order byte-exact; loss/oversize/zero-part rejected + counted; recovers after every failure"; + } + + /// The receiver's allow-list is a SECURITY gate: a Format packet from a sender the user + /// hasn't ticked must never open a session. Nothing previously asserted the gate end-to-end. + private static string? ReceiverAllowGate() + { + using var receiver = new RemSound.Receiver.AudioReceiver(); + try { receiver.Start(FreeUdpPort()); } + catch (Exception ex) { return Skip($"could not start receiver: {ex.Message}"); } + receiver.SetOutputDevices(Array.Empty()); // decode only — never render + receiver.SetPlaybackEnabled(true); // the gate sits behind the playback switch; enable it so the gate is what's tested + receiver.SetAllowedSenders(new[] { new IPEndPoint(IPAddress.Parse("10.9.8.7"), 47830) }); + + var fmt = new AudioFormatInfo(48000, 2, 24, 1, 6, 48000 * 6); + var packet = new byte[RemPacket.HeaderSize + 64]; + RemPacket.WriteHeader(packet, RemPacketType.Format, 1, 1); + var payloadLen = RemPacket.WriteFormatPayload(packet.AsSpan(RemPacket.HeaderSize), fmt); + var len = RemPacket.HeaderSize + payloadLen; + + receiver.InjectExternalPacket(packet, len, new IPEndPoint(IPAddress.Parse("10.1.2.3"), 5555)); + Check(receiver.PacketsRejectedNotAllowed == 1, + "a Format packet from a non-allowed sender must be rejected and counted — never open a session"); + + // The allow-list matches by ADDRESS (source ports vary); an allowed sender must pass. + receiver.InjectExternalPacket(packet, len, new IPEndPoint(IPAddress.Parse("10.9.8.7"), 61234)); + Check(receiver.PacketsRejectedNotAllowed == 1, "the allowed sender must not be counted as rejected"); + return "stranger rejected + counted before any session; allowed address passes at any source port"; + } + + /// The receiver-side decrypt gate: audio encrypted with the WRONG password must come out as + /// nothing (empty span → silence), never as garbage bytes that would play as noise. Also pins the + /// no-key case (encryption is mandatory — keyless means everything drops). + private static string? DecryptorWrongKey() + { + var keyA = RemSoundCrypto.DeriveKey("password-a"); + var keyB = RemSoundCrypto.DeriveKey("password-b"); + var clear = new byte[480]; + new Random(42).NextBytes(clear); + var cipher = RemSoundCrypto.Encrypt(keyA, clear); + + using var dec = new RemSound.Receiver.AudioDecryptor(); + Check(!dec.HasKey && dec.TryDecrypt(cipher).IsEmpty, "with no key set, everything must drop (encryption is mandatory)"); + dec.EnsureKey(keyB); + Check(dec.TryDecrypt(cipher).IsEmpty, "wrong-password ciphertext must yield EMPTY (silence), never garbage"); + dec.EnsureKey(keyA); + Check(dec.TryDecrypt(cipher).SequenceEqual(clear), "the right key must round-trip the audio exactly"); + return "no key → drop; wrong key → silence not noise; right key → byte-exact"; + } + + /// The heartbeat is what decides Healthy/Unreachable for every peer — app AND service stop + /// sending to peers it calls dead. Pins the payload wire format and the ping→pong echo: the pong must + /// return to the SOURCE endpoint carrying the originator's tick unchanged (RTT is computed from it). + private static string? HeartbeatEcho() + { + Span pay = stackalloc byte[RemPacket.HeartbeatPayloadSize]; + RemPacket.WriteHeartbeatPayload(pay, HeartbeatKind.Ping, 123_456_789L); + Check(RemPacket.TryReadHeartbeat(pay, out var k, out var t) && k == HeartbeatKind.Ping && t == 123_456_789L, + "the heartbeat payload must round-trip kind + originator tick"); + + using var hb = new HeartbeatService(); + byte[]? sent = null; + IPEndPoint? sentTo = null; + hb.SendTransport = (buf, len, ep) => { sent = buf.AsSpan(0, len).ToArray(); sentTo = ep; return true; }; + + var ping = new byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize]; + RemPacket.WriteHeader(ping, RemPacketType.Heartbeat, 0xFFFF, 9); + RemPacket.WriteHeartbeatPayload(ping.AsSpan(RemPacket.HeaderSize), HeartbeatKind.Ping, 42_000L); + var from = new IPEndPoint(IPAddress.Loopback, 51515); + hb.HandleInjectedPacket(ping, ping.Length, from); + + Check(sent is not null && Equals(sentTo, from), "a ping must produce a pong back to the SOURCE endpoint"); + Check(RemPacket.TryReadHeader(sent, out var ht, out _, out _) && ht == RemPacketType.Heartbeat, "the reply must be a heartbeat packet"); + Check(RemPacket.TryReadHeartbeat(sent.AsSpan(RemPacket.HeaderSize), out var hk, out var ot) + && hk == HeartbeatKind.Pong && ot == 42_000L, + "the pong must echo the originator's tick UNCHANGED — RTT (and so peer health) is computed from it"); + return "payload round-trips; ping → pong to source with originator tick intact"; + } + /// The SPSC ring buffer is the heart of every audio path (capture mix + playout), and until /// now nothing asserted its BYTES were right — only that packets flowed. Pins: wrap-around /// correctness, overflow dropping the OLDEST bytes, trim/drop semantics, silence-fill on underrun, diff --git a/src/RemSound.Receiver/PcmFrameAssembler.cs b/src/RemSound.Receiver/PcmFrameAssembler.cs index 4f8ede1..470dd2d 100644 --- a/src/RemSound.Receiver/PcmFrameAssembler.cs +++ b/src/RemSound.Receiver/PcmFrameAssembler.cs @@ -1,5 +1,9 @@ using RemSound.Core; +// Lets the in-app self-test (--selftest, in RemSound.exe) exercise the internal reassembly and +// decryptor paths directly — same pattern as the Sender and Core assemblies. +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("RemSound")] + namespace RemSound.Receiver; ///