diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp index 00b7264f..d79af8ff 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp @@ -457,6 +457,36 @@ namespace MobileGL::MG_Remote::Wire { WireOpName(op), static_cast(blob.Size)); std::abort(); } + // R-2.3's SECOND HALF: "inside SOME segment" IS NOT THE RULE. Contract table 1 gives + // every client->server content blob - groups A, B and C, all nineteen rows - the ONE + // carrier SEG_STAGE, and R-10 sends blobs there whole. Until this arm existed the only + // test was that the run resolved, so `CreateSamplerState.Parameters={Seg=SEG_REPLY,...}` + // was accepted and APPLIED: a server-owned segment, whose reuse is the reply pool's + // business and has nothing to do with stage retirement, carrying bytes the applier + // then read. It also went unpoisoned - NoteResolvedRun skipped every non-stage carrier + // - so rule C's only mechanical control read zero on exactly the record that needed it. + // + // The segment is checked BEFORE the resolve, deliberately: a forged SEG_REPLY run that + // happens to lie inside a mapped reply pool must be refused for naming the wrong + // carrier, not left to pass or fail on whether that pool is mapped at all. + // + // NOT A NEW FATAL FAMILY. The review suggested `Fatal{BlobNotStaged}`; this is + // ProtocolCorruption like every other R-2 honesty arm, because the families are the + // vocabulary the operator and the CI greps share (ProtocolCorruption, AbiMismatch, + // UnmigratedVerb, UnmigratedPipeInput, UnsetCallMask, RingOverrun) and a one-off + // seventh name would be a token nothing else in the tree recognises. The SEGMENT is in + // the message, which is what has to be greppable. + if (blob.Seg != kSegStage) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu " + "size=%llu is not SEG_STAGE(%u); every client->server content blob is " + "staged whole in SEG_STAGE (contract table 1 groups A/B/C, R-10) and no " + "other segment may carry one", + WireOpName(op), static_cast(blob.Seg), + static_cast(blob.Offset), + static_cast(blob.Size), + static_cast(kSegStage)); + std::abort(); + } if (segments.Resolve(blob.Seg, blob.Offset, blob.Size) == nullptr) { MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu size=%llu " "does not lie inside that segment (R-2.3)", @@ -1115,8 +1145,22 @@ namespace MobileGL::MG_Remote::Wire { } void PipeWireDecoder::NoteResolvedRun(MGPWireOp op, const MGPBlobRef& blob) { + // UNREACHABLE NOW, AND LOUD RATHER THAN SILENT. This used to `return`, and that made + // the audit's bookkeeping quietly optional: a record naming a non-SEG_STAGE carrier + // was applied AND recorded nothing, so PoisonedStageBytes() stayed zero and rule C's + // only mechanical control was dark on exactly the record it existed to catch. The one + // caller is ResolveOrFatal, which runs RequireDeclaredBlob first, and that now refuses + // both an undeclared blob and a non-SEG_STAGE one by name. If either ever arrives here + // the audit has stopped covering the carrier, which is the same failure as no audit at + // all - the reason the run-count overflow just below is a Fatal too. if (blob.Size == 0 || blob.Seg != kSegStage) { - return; + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} the audit was asked to record a " + "resolved run with seg=%u size=%llu; only declared SEG_STAGE(%u) runs " + "reach the poison fill (R-2.5)", + WireOpName(op), static_cast(blob.Seg), + static_cast(blob.Size), + static_cast(kSegStage)); + std::abort(); } if (m_resolvedCount >= sizeof(m_resolved) / sizeof(m_resolved[0])) { // LOUD, NOT A SILENT DROP. This array is what the 0xDD fill covers, and a poison diff --git a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp index 200bfdbd..d6a7878a 100644 --- a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp +++ b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp @@ -1500,6 +1500,69 @@ TEST_F(PipeWireCodecTest, ARunThatLeavesItsSegmentIsFatal) { EXPECT_NE(r.Log.find("does not lie inside that segment"), std::string::npos) << r.Log; } +TEST_F(PipeWireCodecTest, AContentBlobCarriedOutsideSegStageIsFatalAtTheDecoder) { + // R-2.3's second half, and the verifier's finding-3 fixture kept as its own case + // (wave1-codex-verify.md ยง3). Contract table 1 row 17 puts CreateSamplerState's bytes in + // SEG_STAGE; here they sit in a mapped SEG_REPLY - the SERVER-owned reply pool, whose + // reuse has nothing to do with stage retirement - and the record names that segment. It + // used to be ACCEPTED and APPLIED, because the only test was that the run resolved + // somewhere: the verifier's probe printed `seg=3 accepted=1 poisoned=0`. + // + // The audit is armed, so the second half of the finding is nailed down too: with the + // poison ON, the record must DIE rather than be applied with PoisonedStageBytes() left at + // zero. NoteResolvedRun used to return silently for any non-stage carrier, which made + // rule C's only mechanical control dark on exactly the record it exists to catch; it is + // now a Fatal of its own and unreachable behind this arm. + // + // I made it red once, by doing X: X = deleting the `blob.Seg != kSegStage` arm in + // CheckBlobIsHonest (PipeWireCodec.cpp). The child then exits 0 instead of aborting and + // this case fails on DiedOfAbort - the verifier's `accepted=1` state. + const ChildResult r = RunInChild([] { + Wire2 wire; + std::vector replyBytes(4096, 0); + SamplerParameters params{}; + params.borderColorForm = BorderColorForm::Int; + std::memcpy(replyBytes.data(), ¶ms, sizeof(params)); + wire.Segments().Install(kSegReply, SegmentView{replyBytes.data(), replyBytes.size()}); + wire.Decoder().SetAuditPoison(true); + + MGPSamplerDesc desc{}; + desc.Cso = MakeHandle(88); + desc.Parameters.Seg = static_cast(kSegReply); + desc.Parameters.Offset = 0; + desc.Parameters.Size = sizeof(SamplerParameters); + ForgeAndDecode(wire, MGPWireOp::CreateSamplerState, &desc, sizeof(desc), nullptr, 0); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("is not SEG_STAGE"), std::string::npos) << r.Log; + EXPECT_NE(r.Log.find("CreateSamplerState.blob"), std::string::npos) << r.Log; + EXPECT_NE(r.Log.find("seg=3"), std::string::npos) << r.Log; +} + +TEST_F(PipeWireCodecTest, TheEncoderRefusesTheNonStageCarrierTheDecoderCallsFatal) { + // THE ENCODER MUST NOT ACCEPT A RECORD THE DECODER FATALS ON - the same symmetry + // TheEncoderRefusesThePerStageSpirvRunTheDecoderCallsFatal states one arm over. Under + // `inproc` a SEG_REPLY pointer resolves, so an emitter that staged into the reply pool + // would get a valid seq here and a Fatal on a peer, which is the asymmetry EncodeRecord's + // own honesty loop exists to prevent. + // + // I made it red once, by doing X: X = deleting the `blob.Seg != kSegStage` arm in + // CheckBlobIsHonest. EncodeRecord then returns a real seq and the child exits 0. + const ChildResult r = RunInChild([] { + Wire2 wire; + std::vector replyBytes(4096, 0); + wire.Segments().Install(kSegReply, SegmentView{replyBytes.data(), replyBytes.size()}); + MGPSamplerDesc desc{}; + desc.Cso = MakeHandle(88); + desc.Parameters.Seg = static_cast(kSegReply); + desc.Parameters.Offset = 0; + desc.Parameters.Size = sizeof(SamplerParameters); + (void)wire.Encoder().EncodeRecord(MGPWireOp::CreateSamplerState, &desc, sizeof(desc)); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("is not SEG_STAGE"), std::string::npos) << r.Log; +} + TEST_F(PipeWireCodecTest, AHalfDeclaredBlobIsFatalRatherThanReadAsAbsent) { // The shape a MONOLITH emitter produces - Seg None, Offset a host address, Size 0. Reading // it as "absent" would silently drop the bytes of every record an unconverted emitter sent.