blob: 88ac2cba0e5c5ee7d889f0fd8269ea6453f2d2be [file]
/*
*
* Copyright (c) 2026 Project CHIP Authors
* All rights reserved.
*
* 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.
*/
/**
* @file
* Seeded/typed FuzzTest harness for the DNS-SD IncrementalResolver
* *orchestration* layer — the code that drives an mDNS discovery record
* set (SRV -> TXT -> A/AAAA) into the fixed-size NodeData buffers.
*
* Coverage gap closed: FuzzPacketParsingPW stops at raw record parsing,
* and FuzzTxtFieldsPW exercises FillNodeDataFromTxt() in isolation with a
* single (key,value) pair. Neither drives the full resolver lifecycle:
* InitializeParsing(srv) -> OnRecord(TXT) -> OnRecord(AAAA) -> Take()
* which is what wires the SRV server/instance name, the multi-pair TXT
* delegate (TxtParser -> FillNodeDataFromTxt), and the IP accumulation
* together into CommonResolutionData / CommissionNodeData. The copies that
* land in deviceName[33], pairingInstruction[129], rotatingId[50],
* instanceName[] and hostName[] are only reached through this path.
*
* Construction strategy mirrors TestIncrementalResolve.cpp: build typed
* ResourceRecords (Srv/Txt/IP) with a fuzzer-chosen QName, serialize them
* to wire bytes, re-parse into a ResourceData, and feed that to the real
* resolver, so the same decode the receive path performs is exercised here.
*
* Oracle (beyond no-crash under ASan/UBSan): whenever the resolver hands
* back a NodeData, every fixed-size character buffer it produced must be
* NUL-terminated strictly within bounds (strnlen < sizeof), and the
* rotating-id length must never exceed its backing array.
*/
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include <fuzztest/fuzztest_core.h>
#include <gtest/gtest.h>
#include <lib/dnssd/IncrementalResolve.h>
#include <lib/dnssd/Resolver.h>
#include <lib/dnssd/Types.h>
#include <lib/dnssd/wire/Parser.h>
#include <lib/dnssd/wire/QName.h>
#include <lib/dnssd/wire/RecordData.h>
#include <lib/dnssd/wire/RecordWriter.h>
#include <lib/dnssd/wire/records/IP.h>
#include <lib/dnssd/wire/records/Ptr.h>
#include <lib/dnssd/wire/records/ResourceRecord.h>
#include <lib/dnssd/wire/records/Srv.h>
#include <lib/dnssd/wire/records/Txt.h>
#include <lib/support/CHIPMem.h>
#include <lib/support/CodeUtils.h>
#include <lib/support/StringBuilder.h>
namespace {
using namespace chip;
using namespace chip::Dnssd;
using namespace fuzztest;
void EnsureInitialized()
{
static const bool sInitialized = [] {
VerifyOrDie(Platform::MemoryInit() == CHIP_NO_ERROR);
return true;
}();
(void) sInitialized;
}
// Service flavor selects the Matter suffix appended to the fuzzer-controlled
// instance label, so InitializeParsing classifies the name and selects the
// operational vs. commission specific-data variant.
enum class ServiceFlavor : uint8_t
{
kOperational, // <instance>._matter._tcp.local
kCommissionable, // <instance>._matterc._udp.local
kCommissioner, // <instance>._matterd._udp.local
kOther, // <instance>._http._tcp.local -- not a Matter service
};
// A name that is not a Matter service is rejected during initialization, so the
// resolver stops before any of the TXT or address handling. Those inputs are
// worth having but must stay a small share, hence the weighting here rather than
// an even split across the flavors.
ServiceFlavor PickServiceFlavor(uint8_t sel)
{
const uint8_t bucket = sel % 16;
return bucket == 15 ? ServiceFlavor::kOther : static_cast<ServiceFlavor>(bucket % 3);
}
// Holds the QName-part strings stably so the QNamePart (const char *) array we
// hand to FullQName stays valid for the record's lifetime.
struct QNameHolder
{
std::vector<std::string> storage;
std::vector<QNamePart> parts;
FullQName Full()
{
parts.clear();
parts.reserve(storage.size());
for (auto & s : storage)
{
parts.push_back(s.c_str());
}
FullQName q;
q.names = parts.data();
q.nameCount = parts.size();
return q;
}
};
QNameHolder MakeServiceName(ServiceFlavor flavor, const std::string & instance)
{
QNameHolder holder;
holder.storage.push_back(instance);
switch (flavor)
{
case ServiceFlavor::kOperational:
holder.storage.push_back("_matter");
holder.storage.push_back("_tcp");
break;
case ServiceFlavor::kCommissionable:
holder.storage.push_back("_matterc");
holder.storage.push_back("_udp");
break;
case ServiceFlavor::kCommissioner:
holder.storage.push_back("_matterd");
holder.storage.push_back("_udp");
break;
case ServiceFlavor::kOther:
holder.storage.push_back("_http");
holder.storage.push_back("_tcp");
break;
}
holder.storage.push_back("local");
return holder;
}
QNameHolder MakeHostName(const std::string & host)
{
QNameHolder holder;
holder.storage.push_back(host);
holder.storage.push_back("local");
return holder;
}
// A record whose payload is supplied verbatim. TxtResourceRecord always emits
// well-formed length-prefixed entries, so it cannot produce a TXT body that
// ParseTxtRecord rejects; this can.
class RawResourceRecord : public ResourceRecord
{
public:
RawResourceRecord(QType type, const FullQName & name, const uint8_t * data, size_t dataLen) :
ResourceRecord(type, name), mData(data), mDataLen(dataLen)
{}
protected:
bool WriteData(RecordWriter & out) const override
{
out.Put(BytesRange(mData, mData + mDataLen));
return out.Fit();
}
private:
const uint8_t * mData;
const size_t mDataLen;
};
// Distinct interface ids so the resolver's "addresses arrived on more than one
// interface" handling is reachable. Fuzz targets build for host linux/mac only,
// where the platform interface type is integral.
Inet::InterfaceId MakeInterfaceId(unsigned n)
{
return Inet::InterfaceId(static_cast<Inet::InterfaceId::PlatformType>(n));
}
// Serializes a ResourceRecord to wire bytes and re-parses it into a
// ResourceData, exactly as the network receive path does. Owns the byte
// buffer so the parsed ResourceData / QName iterators remain valid.
class WireRecord
{
public:
bool Build(const ResourceRecord & record)
{
uint8_t headerBuffer[HeaderRef::kSizeBytes] = {};
HeaderRef dummyHeader(headerBuffer);
chip::Encoding::BigEndian::BufferWriter output(mBuffer.data(), mBuffer.size());
RecordWriter writer(&output);
if (!record.Append(dummyHeader, ResourceType::kAnswer, writer) || !writer.Fit())
{
return false;
}
const uint8_t * ptr = mBuffer.data();
BytesRange packet(mBuffer.data(), mBuffer.data() + mBuffer.size());
return mResource.Parse(packet, &ptr);
}
const ResourceData & Resource() const { return mResource; }
BytesRange Packet() const { return BytesRange(mBuffer.data(), mBuffer.data() + mBuffer.size()); }
private:
std::array<uint8_t, 1024> mBuffer = {};
ResourceData mResource;
};
// After the resolver yields a NodeData, the fixed-size buffers it populated must
// be NUL-terminated within bounds, so a later strlen stays inside the array, and
// the rotating-id length must fit its backing array.
void CheckCommonBuffersInBounds(const CommonResolutionData & data)
{
ASSERT_LT(strnlen(data.hostName, sizeof(data.hostName)), sizeof(data.hostName));
}
void CheckCommissionBuffersInBounds(const CommissionNodeData & data)
{
CheckCommonBuffersInBounds(data);
ASSERT_LE(data.rotatingIdLen, sizeof(data.rotatingId));
ASSERT_LT(strnlen(data.instanceName, sizeof(data.instanceName)), sizeof(data.instanceName));
ASSERT_LT(strnlen(data.deviceName, sizeof(data.deviceName)), sizeof(data.deviceName));
ASSERT_LT(strnlen(data.pairingInstruction, sizeof(data.pairingInstruction)), sizeof(data.pairingInstruction));
}
// Number of A/AAAA records a single input may feed. CommonResolutionData holds
// kMaxIPAddresses of them, so allowing more than that lets the accumulation run
// past the end of the array and exercise the full/refusal path.
constexpr unsigned kMaxIpRecordsPerInput = CommonResolutionData::kMaxIPAddresses + 3;
// Drive the full IncrementalResolver lifecycle from fuzzer-controlled inputs.
void ResolverLifecycleNoCorruption(uint8_t flavorSel, const std::string & instance, const std::string & host, uint16_t port,
uint64_t ttl, const std::vector<std::string> & txtEntries, uint8_t ipRecordCount,
const std::array<uint8_t, 16> & ipBytes, uint8_t addressing, bool addPtr,
const std::vector<uint8_t> & rawTxtPayload, uint8_t interfaceSel)
{
EnsureInitialized();
const ServiceFlavor flavor = PickServiceFlavor(flavorSel);
// Send a record to the wrong name on roughly one input in four. A 50/50 split
// starves the correctly-addressed path, which is the one that reaches the TXT
// and IP handling behind the name check.
const bool misaddressTxt = (addressing & 0x03) == 0;
const bool misaddressIp = (addressing & 0x0C) == 0;
// Remaining bits select the less common record sequences.
const bool refeedSrv = (addressing & 0x10) != 0;
const bool recordAfterTake = (addressing & 0x20) != 0;
const bool useIPv4 = (addressing & 0x40) != 0;
const bool malformedIpBody = (addressing & 0x80) != 0;
// ---- Build + parse the SRV record (the first record the lifecycle needs) ----
QNameHolder serviceName = MakeServiceName(flavor, instance);
QNameHolder hostName = MakeHostName(host);
SrvResourceRecord srvBuilder(serviceName.Full(), hostName.Full(), port);
WireRecord srvWire;
if (!srvWire.Build(srvBuilder))
{
return; // record too large to serialize into the wire buffer; not interesting
}
SrvRecord srv;
if (!srv.Parse(srvWire.Resource().GetData(), srvWire.Packet()))
{
return;
}
IncrementalResolver resolver;
// Queried before any SRV has been accepted, so the "no service data yet"
// answer is produced as well as the post-SRV one further down.
(void) resolver.GetMissingRequiredInformation();
CHIP_ERROR err = resolver.InitializeParsing(srvWire.Resource().GetName(), ttl, srv);
if (err != CHIP_NO_ERROR || !resolver.IsActive())
{
// Non-Matter / unparseable name (e.g. instance too long for storage).
return;
}
const Inet::InterfaceId interface = Inet::InterfaceId::Null();
// ---- Feed a TXT record carrying the fuzzer's key=value pairs ----
// A TXT is only consumed when addressed to the record (service) name;
// misaddressTxt sends it to the host name instead so the name-mismatch
// rejection in OnRecord is exercised as well.
{
std::vector<const char *> entryPtrs;
entryPtrs.reserve(txtEntries.size());
bool tooLong = false;
for (const auto & e : txtEntries)
{
// The Txt builder rejects entries > 63 bytes (returns false from
// WriteData); skip building rather than waste the input.
if (e.size() > 63)
{
tooLong = true;
break;
}
entryPtrs.push_back(e.c_str());
}
if (!tooLong && !entryPtrs.empty())
{
TxtResourceRecord txtBuilder(misaddressTxt ? hostName.Full() : serviceName.Full(), entryPtrs.data(), entryPtrs.size());
WireRecord txtWire;
if (txtWire.Build(txtBuilder))
{
(void) resolver.OnRecord(interface, txtWire.Resource(), txtWire.Packet());
}
}
}
// ---- Feed a TXT record whose body is raw fuzzer bytes ----
// Addressed to the record name so it passes the name check and reaches
// OnTxtRecord, where a body that is not well-formed length-prefixed entries
// drives the parse-rejection path.
if (!rawTxtPayload.empty())
{
RawResourceRecord rawTxtBuilder(QType::TXT, serviceName.Full(), rawTxtPayload.data(), rawTxtPayload.size());
WireRecord rawTxtWire;
if (rawTxtWire.Build(rawTxtBuilder))
{
(void) resolver.OnRecord(interface, rawTxtWire.Resource(), rawTxtWire.Packet());
}
}
// ---- Re-feed the SRV record as additional data ----
// SRV is consumed when the resolver is initialized; one arriving later in the
// same packet takes the already-handled path instead.
if (refeedSrv)
{
(void) resolver.OnRecord(interface, srvWire.Resource(), srvWire.Packet());
}
// ---- Feed an address record whose body is the wrong length ----
// IPResourceRecord always writes a correctly sized address, so a body the
// address parser rejects has to be supplied directly.
// A three-byte body is the wrong length for both record types, so one pass
// covers the IPv6 and the IPv4 address parser.
if (malformedIpBody)
{
for (QType type : { QType::AAAA, QType::A })
{
RawResourceRecord shortAddr(type, hostName.Full(), ipBytes.data(), 3);
WireRecord shortAddrWire;
if (shortAddrWire.Build(shortAddr))
{
(void) resolver.OnRecord(interface, shortAddrWire.Resource(), shortAddrWire.Packet());
}
}
}
// ---- Feed a PTR record ----
// PTR is not one of the types the resolver consumes, so this drives the
// ignored-record arm of the OnRecord type switch.
if (addPtr)
{
PtrResourceRecord ptrBuilder(serviceName.Full(), hostName.Full());
WireRecord ptrWire;
if (ptrWire.Build(ptrBuilder))
{
(void) resolver.OnRecord(interface, ptrWire.Resource(), ptrWire.Packet());
}
}
// ---- Feed A/AAAA records for the host so the resolver can complete ----
// Each record gets a distinct address so they accumulate; feeding more than
// kMaxIPAddresses reaches the point where the resolver stops accepting them.
// misaddressIp addresses them to the service name instead of the host name,
// exercising the name-mismatch rejection.
for (unsigned n = 0; n < ipRecordCount % (kMaxIpRecordsPerInput + 1); ++n)
{
std::array<uint8_t, 16> bytes = ipBytes;
bytes[15] = static_cast<uint8_t>(bytes[15] ^ n);
// IPResourceRecord emits an AAAA record for an IPv6 address and an A
// record for an IPv4 one, so the address family chooses which arm of the
// resolver's type switch is taken.
Inet::IPAddress addr;
if (useIPv4)
{
StringBuilder<16> dotted;
dotted.AddFormat("%u.%u.%u.%u", bytes[0], bytes[1], bytes[2], bytes[3]);
if (!Inet::IPAddress::FromString(dotted.c_str(), addr))
{
continue;
}
}
else
{
memcpy(addr.Addr, bytes.data(), sizeof(addr.Addr));
}
// Interface handling: keep the unset id, use one real id throughout, or
// switch ids partway so addresses arrive on two different interfaces.
Inet::InterfaceId ipInterface = interface;
switch (interfaceSel % 3)
{
case 1:
ipInterface = MakeInterfaceId(1);
break;
case 2:
ipInterface = MakeInterfaceId(n == 0 ? 1 : 2);
break;
default:
break;
}
IPResourceRecord ipBuilder(misaddressIp ? serviceName.Full() : hostName.Full(), addr);
WireRecord ipWire;
if (ipWire.Build(ipBuilder))
{
(void) resolver.OnRecord(ipInterface, ipWire.Resource(), ipWire.Packet());
}
}
// Whatever arrived, the resolver must be able to report what it still needs
// without tripping over the partially populated state.
(void) resolver.GetMissingRequiredInformation();
// ---- Take whatever was accumulated and validate the fill-sink buffers ----
if (resolver.IsActiveOperationalParse())
{
ResolvedNodeData nodeData;
if (resolver.Take(nodeData) == CHIP_NO_ERROR)
{
CheckCommonBuffersInBounds(nodeData.resolutionData);
}
}
else if (resolver.IsActiveCommissionParse())
{
DiscoveredNodeData nodeData;
if (resolver.Take(nodeData) == CHIP_NO_ERROR && nodeData.Is<CommissionNodeData>())
{
CheckCommissionBuffersInBounds(nodeData.Get<CommissionNodeData>());
}
}
// A record arriving after the resolver has handed its data over must be
// dropped rather than written into the released state.
if (recordAfterTake)
{
(void) resolver.OnRecord(interface, srvWire.Resource(), srvWire.Packet());
}
}
// Instance labels: real Matter forms (operational <fabric>-<node>, commission
// 16-hex instance) plus boundary/oversize labels that stress the SRV name
// storage and instanceName[] copy.
std::vector<std::string> InstanceSeeds()
{
return {
"1234567898765432-ABCDEFEDCBAABCDE", // operational instance
"C5038835313B8B98", // commissionable/commissioner instance
"0000000000000000-0000000000000000",
std::string(33, 'I'), // one over kInstanceNameMaxLength(32)
std::string(63, 'I'), // max DNS label
std::string(64, 'I'), // over the StoredServerName 64-byte budget
"abcd",
};
}
std::vector<std::string> HostSeeds()
{
return {
"B75AFB458ECD", // typical 802.15.4 / MAC hex host
"0011223344556677",
std::string(16, 'h'), // kHostNameMaxLength
std::string(17, 'h'), // one over
std::string(63, 'h'), // max DNS label
"abcd",
};
}
// Whole TXT records (multiple key=value pairs), seeded with real discovery
// payloads and with boundary values for the fixed-size destination fields.
std::vector<std::vector<std::string>> TxtSeeds()
{
return {
// Realistic operational TXT.
{ "SII=23", "SAI=321", "T=1" },
// Realistic commissionable TXT.
{ "D=22345", "VP=321+654", "CM=2", "DT=22", "DN=mytest", "PI=hello", "RI=00112233445566778899AABBCCDDEEFF" },
// deviceName at / one over / well over kMaxDeviceNameLen(32).
{ std::string("DN=") + std::string(32, 'n') },
{ std::string("DN=") + std::string(33, 'n') },
{ std::string("DN=") + std::string(200, 'n') },
// pairingInstruction at / one over / well over kMaxPairingInstructionLen(128).
// Single TXT entry must stay <= 63 bytes on the wire, so split the long
// PI across the limit-relevant boundary the builder allows.
{ std::string("PI=") + std::string(60, 'p') },
// rotatingId hex at / one over / well over kMaxRotatingIdLen(50) bytes
// (each byte = 2 hex chars; 50 bytes = 100 chars, capped by the 63-byte
// TXT entry limit, so this stresses the cap logic up to the wire limit).
{ std::string("RI=") + std::string(60, 'A') },
{ std::string("RI=") + std::string(50, 'F') },
{ "RI=ZZ", "RI=0" }, // malformed / odd-length hex
// discriminator / numeric boundaries.
{ "D=4095", "D=4096", "CM=255", "CM=256", "DT=65535" },
// empty value forms.
{ "DN=", "PI=", "RI=", "D=" },
// duplicate keys (later overwrites earlier in the fill).
{ "DN=first", "DN=second" },
};
}
// Raw TXT record bodies. A TXT body is a run of length-prefixed "key=value"
// strings that must land exactly on the end of the record; these cover both
// bodies that satisfy that and bodies that do not, so the parser's accept and
// reject paths are both driven.
std::vector<std::vector<uint8_t>> RawTxtSeeds()
{
return {
// Well formed, consumes the body exactly.
{ 0x04, 'D', '=', '8', '0' },
{ 0x05, 'S', 'I', 'I', '=', '5' },
{ 0x03, 'T', '=', '1', 0x04, 'C', 'M', '=', '2' },
// Zero-length entries (the empty-key forms).
{ 0x00 },
{ 0x00, 0x00, 0x00 },
// A key with no '=' and a bare '=' value.
{ 0x02, 'a', 'b' },
{ 0x01, '=' },
// Length prefix runs past the end of the body.
{ 0x10, 'a' },
{ 0xFF },
{ 0x7F, 'x', 'y' },
// First entry fits, second overruns.
{ 0x02, 'a', 'b', 0x03, 'c' },
// Entries overshoot the end by one byte.
{ 0x02, 'a', 'b', 0x02, 'c' },
};
}
FUZZ_TEST(MdnsResolverPW, ResolverLifecycleNoCorruption)
.WithDomains(
/* flavorSel */ Arbitrary<uint8_t>(),
/* instance */ Arbitrary<std::string>().WithSeeds(InstanceSeeds()).WithMaxSize(80),
/* host */ Arbitrary<std::string>().WithSeeds(HostSeeds()).WithMaxSize(80),
/* port */ Arbitrary<uint16_t>(),
/* ttl */ Arbitrary<uint64_t>(),
// Inner max comfortably exceeds the largest seed entry (203 bytes); the
// harness itself enforces the 63-byte on-wire TXT-entry limit before
// building, so oversize entries exercise that skip path rather than
// being rejected as invalid seeds.
/* txtEntries */ VectorOf(Arbitrary<std::string>().WithMaxSize(256)).WithSeeds(TxtSeeds()).WithMaxSize(16),
// Number of A/AAAA records fed; taken modulo kMaxIpRecordsPerInput + 1 so
// some inputs overrun the resolver's address array.
/* ipRecordCount */ Arbitrary<uint8_t>(),
/* ipBytes */ Arbitrary<std::array<uint8_t, 16>>(),
// Bit pairs select whether the TXT / IP records are addressed to the
// wrong name; see the derivation in the fuzz function.
/* addressing */ Arbitrary<uint8_t>(),
/* addPtr */ Arbitrary<bool>(),
// Raw TXT bodies, seeded with both well-formed and malformed
// length-prefixed forms so the TXT parse-rejection path is reachable.
/* rawTxtPayload */ Arbitrary<std::vector<uint8_t>>().WithSeeds(RawTxtSeeds()).WithMaxSize(256),
/* interfaceSel */ Arbitrary<uint8_t>());
} // namespace