# BST-TIMEOUT: 300 # MEASURED: elapsed=83s for 160 cases on an idle host # # (2026-08-17, local functional verify). The 60 s default # # would NOT have held, which is worth knowing given how # # little this suite does: only two process spawns -- the # # probe, which runs every assertion in one module while the # # shell scores its output, and one ndptool that exits on an # # unknown option before it opens anything. Nothing waits on # # a timer, a peer or a descriptor, and the same suite runs # # in 30 ms natively; the 83 s is this guest's cost of # # staging a 2.5 MB wasm module and of the ~150 shell # # forks that score its output. 300 is ~3.6x the measured # # run, which is the headroom the nightly needs on a loaded # # host -- 120 would have been 1.45x and is not a bound, it # # is a coin toss. . "$BST_LIB" # libndp 1.8 -- the IPv6 Neighbour Discovery Protocol library (meta-oe # recipes-connectivity/libndp), RFC 4861's router and neighbour solicitations # and advertisements plus the option types RFC 4191 and RFC 6106 add on top. # What ships is /usr/lib/libndp.a, /usr/lib/pkgconfig/libndp.pc, /usr/include/ # ndp.h and /usr/bin/ndptool. # # --------------------------------------------------------------------------- # THE LIBRARY DIVIDES IN TWO, AND ONLY ONE HALF NEEDS A NETWORK. # # * THE MESSAGE API composes into and parses out of a 1500-byte buffer inside # struct ndp_msg. ndp_msg_new, the per-type accessors, the option walker -- # not one of them touches a descriptor. This is where every case below sits, # which is why this suite opens no raw socket and needs no interface and no # peer. # * THE SOCKET HALF is ndp_open/ndp_msg_send/ndp_sock_recv over a raw # AF_INET6/SOCK_RAW/IPPROTO_ICMPV6 socket. Measured in the S group and # depended on by nothing. # # --------------------------------------------------------------------------- # THE ORACLE IS A NATIVE x86-64 RUN, AND THAT IS THE POINT # # Every composed payload below is pinned to the bytes a native x86-64 build of # libndp_probe.c produced against libndp at this same SRCREV (009ce9cd, v1.8, # with meta-oe's CVE-2024-5564 patch applied so the two builds are the same # code). These are wire formats fixed by RFC, so the architectures MUST agree # byte for byte and a mismatch is a real defect rather than an expected # difference -- the opposite of 79-libuv next door, where the values the port # turns on are REQUIRED to differ from x86-64 and an oracle would have hidden # the finding. # # It is also the assertion a target-only run cannot make. Setting a field and # reading it back through the accessor proves the pair are consistent; it does # not prove they are RIGHT. A port that wrote the router lifetime at the wrong # offset, or in host byte order, would round-trip through its own mistake and # report every field correct. Only the pinned bytes catch that, and they are # hand-checked against RFC 4861 4.2 rather than merely copied: # # A10 = 86 00 0000 40 d8 0708 00007530 000003e8 # ^type 134 ^ck ^curhoplimit 64 # ^flags: managed 0x80 | other 0x40 | pref LOW 3<<3 # ^router lifetime 1800 ^reachable 30000 # ^retransmit 1000 # # --------------------------------------------------------------------------- # WHAT THIS SUITE SHIPS AND CANNOT ASSERT, SAID RATHER THAN PAPERED OVER # # ndp_msg_check_opts() carries meta-oe's CVE-2024-5564 backport -- the RFC 4191 # Route Information length validation. It is static, and called from exactly one # place: ndp_sock_recv, after a real recvfrom on the raw socket. No socket-free # consumer can reach it, so this suite does not claim to test it. What it tests # instead is the mechanism that fix depends on -- the public walker # ndp_msg_next_opt_offset() and its three bounds (X01, X02, X03) -- which is # what actually stops a malformed advertisement from walking off the buffer. The # CVE check decides which options get marked 0xff; X03 asserts that a marked # option is then skipped and X04/X05 are its controls. PROBE=${BST_LIBNDP_PROBE:-/usr/libexec/libndp-tests/libndp-probe} ARCHIVE=/usr/lib/libndp.a PCFILE=/usr/lib/pkgconfig/libndp.pc INC=/usr/include NDPTOOL=/usr/bin/ndptool # The version the recipe pins. Asserted rather than read back from the library, # so a recipe bumped without updating this suite is a red here instead of a # silent change of subject -- and the pinned bytes above are only the oracle for # THIS version. WANT_VERSION=1.8 if [ ! -f "$ARCHIVE" ]; then bst_skip libndp/present "libndp not installed on this image" exit 0 fi bst_ok libndp/present # --- the package is whole ----------------------------------------------------- check libndp/staticlib test -s "$ARCHIVE" check libndp/pkgconfig test -s "$PCFILE" check libndp/hdr-ndp test -s "$INC/ndp.h" # ndptool is the whole of ${PN}: it is why the bare `libndp` package exists here # and libuv's does not. check libndp/ndptool-present test -x "$NDPTOOL" # Shape, not absence: this arm converts to shared libraries per recipe # (BRINTOS-1740), so a .so here is a conversion, not a policy breach. bst_lib_shape libndp/no-shared "/usr/lib/libndp.so" "/usr/lib/libndp.so.2" # The .pc must name the version the recipe pinned, or a consumer resolving # libndp through pkg-config gets a different answer from the one this suite # pinned its bytes against. pcver=$(sed -n 's/^Version: *//p' "$PCFILE" 2>/dev/null | head -n 1) check_eq libndp/pkgconfig-version "$WANT_VERSION" "$pcver" # The archive carries the API. grep rather than nm: the image ships no binutils # nm, and the names are present as plain bytes. -a where the grep supports it -- # on this arm the archive members are LLVM IR bitcode (ThinLTO), so a grep that # suppresses matches in a file containing NULs reports nothing; GNU grep needs # -a, busybox grep as shipped does not. Probing keeps this measuring libndp # rather than measuring the image's grep. if echo x | grep -aq x 2>/dev/null; then _grepq="grep -aq"; else _grepq="grep -q"; fi for sym in ndp_msg_new ndp_msg_destroy ndp_msg_type ndp_msg_payload \ ndp_msg_payload_len ndp_msg_payload_len_set ndp_msg_payload_opts \ ndp_msg_target_set ndp_msg_dest_set ndp_msg_addrto \ ndp_msgra_curhoplimit_set ndp_msgra_router_lifetime_set \ ndp_msgra_reachable_time_set ndp_msgra_retransmit_time_set \ ndp_msgra_route_preference_set ndp_msgna_flag_router_set \ ndp_msg_next_opt_offset ndp_msg_opt_prefix ndp_msg_opt_mtu \ ndp_msg_opt_route_prefix ndp_msg_opt_rdnss_addr \ ndp_msg_opt_dnssl_domain ndp_open ndp_close ndp_msg_send; do if $_grepq "$sym" "$ARCHIVE" 2>/dev/null; then bst_ok "libndp/sym-$sym" else bst_notok "libndp/sym-$sym" "$sym absent from $ARCHIVE" fi done # ndptool is a DAEMON, and this is the whole of what it can be asked without a # network: it must reject an unknown subcommand rather than block. Every real # subcommand opens the raw socket and then blocks in ppoll() waiting for a # router to speak, so none of them can be driven here -- which is the reason the # probe below exists at all. if [ -x "$NDPTOOL" ]; then out=$("$NDPTOOL" --this-is-not-a-subcommand 2>&1 "$pout" 2>&1 prc=$? if [ "$prc" -eq 0 ]; then bst_ok libndp/probe-runs elif bst_execfail "$(head -c 200 "$pout" 2>/dev/null)"; then bst_unrun libndp/probe-runs "$(head -c 200 "$pout" 2>/dev/null)" rm -f "$pout" exit 0 else bst_notok libndp/probe-runs "probe exited $prc: $(_bst_1line "$(head -c 300 "$pout")")" fi if grep -q '^PROBE-END$' "$pout" 2>/dev/null; then bst_ok libndp/probe-complete else bst_notok libndp/probe-complete "probe output has no PROBE-END terminator (last line: $(tail -n 1 "$pout" 2>/dev/null))" fi # val -- the probe's value for one case, empty when the probe stopped # first. libndp writes its own diagnostics to stderr and the probe's output is # captured with 2>&1, so this matches on the ID in field 1 rather than taking # the line by position. val() { awk -v k="$1" '$1==k { $1=""; sub(/^ /,""); print; exit }' "$pout"; } # want -- score one probe value against a pinned answer. # A missing value is reported as the probe having stopped early rather than as a # mismatch against '', which is a different fault and localises to a different # line. want() { _got=$(val "$1") if [ -z "$_got" ]; then bst_notok "libndp/$2" "no value on target (probe stopped before $1); want='$3'" elif [ "$_got" = "$3" ]; then bst_ok "libndp/$2" else bst_notok "libndp/$2" "want:'$3' got:'$_got'" fi } # --- creation, and the type read back out of the composed bytes --------------- # ndp_msg_type() does not read a field the library set beside the buffer -- it # re-derives the type from the ICMPv6 type byte IN the buffer. So each N case is # an assertion about the bytes, and each R case pins the RFC 4861 4.x type # number those bytes must carry. want N01 new-rs "OK" want N02 new-ra "OK" want N03 new-ns "OK" want N04 new-na "OK" want N05 new-r "OK" # NDP_MSG_ALL is the wildcard a receive handler registers with, not a message # that can be built. A library that accepted it would index its type table past # the end. want N06 new-all-refuses "EINVAL" want R01 raw-type-rs "133" want R02 raw-type-ra "134" want R03 raw-type-ns "135" want R04 raw-type-na "136" want R05 raw-type-r "137" # The fixed part of each message, sizeof(struct nd_*) from netinet/icmp6.h. A # wrong size here would let a 40-byte redirect be parsed as a 24-byte # advertisement, so these are pinned rather than derived. want L01 len-rs "8" want L02 len-ra "16" want L03 len-ns "24" want L04 len-na "24" want L05 len-r "40" want L06 payload-maxlen "1500" # A fresh message has an empty options region that starts exactly where the # fixed part ends. want L07 opts-len-fresh "0" want L08 opts-offset-ra "16" # --- the per-type accessor guards --------------------------------------------- # ndp_msgra() and friends return NULL for a message of the wrong type. That is # the library's only defence against a caller reading an RA's router lifetime # out of a 24-byte NS buffer, and half of this group is the negative control for # the other half: without G01-G05 passing, G06-G09 would be satisfied by a # library whose accessors return NULL for everything. want G01 view-rs-on-rs "non-NULL" want G02 view-ra-on-ra "non-NULL" want G03 view-ns-on-ns "non-NULL" want G04 view-na-on-na "non-NULL" want G05 view-r-on-r "non-NULL" want G06 view-ra-on-rs "NULL" want G07 view-ns-on-na "NULL" want G08 view-na-on-ns "NULL" want G09 view-r-on-ra "NULL" # --- compose a Router Advertisement ------------------------------------------- # The ticket's four named fields are A01 (type, via R02/N02 above), A01 # curhoplimit, B03 target address and A02 router lifetime; the rest of what an # RA carries is set and read beside them because leaving a field unset is how a # packing bug survives. want A01 ra-curhoplimit "64" want A02 ra-router-lifetime "1800" want A03 ra-reachable-time "30000" want A04 ra-retransmit-time "1000" want A05 ra-flag-managed "true" want A06 ra-flag-other "true" want A07 ra-flag-home-agent "false" want A08 ra-route-pref "3" want A09 ra-len-after-set "16" # The byte-exact assertion. managed, other, home-agent and the route preference # all live in byte 5, so this is also the only thing that proves setting the # fourth did not disturb the first three. want A10 ra-compose-bytes "8600000040d8070800007530000003e8" # Clearing one flag must clear one bit. A setter that assigned instead of # or-ing would pass every case above and fail these two. want A11 ra-clear-one-flag "true" want A12 ra-pref-survives-flag-clear "3" # --- compose a Neighbour Solicitation ----------------------------------------- want B01 ns-len "24" want B02 ns-compose-bytes "870000000000000020010db8000000000000000000000001" want B03 ns-target "20010db8000000000000000000000001" # RFC 4861 7.2.2: an NS with no destination goes to the solicited-node multicast # address derived from the target's low 24 bits, ff02::1:ff00:1 here. This is # the one place the message API does arithmetic on an address rather than # copying one, so it is pinned as bytes. want B04 ns-solicited-node-multicast "ff0200000000000000000001ff000001" # An explicit destination must win -- a unicast NS is the reachability probe of # a neighbour already known, and deriving the multicast address over it would # send every such probe to the wrong place. want B05 ns-explicit-dest-wins "20010db800000000000000000000abcd" want B06 ns-dest-leaves-payload "payload-unchanged" # ndp_msg_dest_set is documented for NS and NA only; on an RA it must be a # no-op rather than corrupting addrto. want B07 ra-dest-set-ignored "ignored" # --- compose a Neighbour Advertisement ---------------------------------------- want D01 na-flag-router "true" want D02 na-flag-solicited "true" want D03 na-flag-override "false" want D04 na-len "24" want D05 na-compose-bytes "88000000c000000020010db8000000000000000000000002" want D06 na-target "20010db8000000000000000000000002" # Unlike NS, an NA's target must NOT derive a multicast destination: the # advertisement goes back to whoever asked. want D07 na-addrto-untouched "untouched" want D08 na-set-third-flag "true" # --- compose a Router Solicitation and a Redirect ----------------------------- want E01 rs-len "8" want E02 rs-compose-bytes "8500000000000000" want E03 r-len "40" want E04 r-compose-bytes "89000000000000000000000000000000000000000000000000000000000000000000000000000000" # --- parse back: the receive path, minus the socket --------------------------- # ndp_sock_recv turns bytes into a message with exactly four steps -- alloc, # type-by-raw-type on buf[0], init, payload_len_set -- and ndp_msg_new(type) is # alloc + init. So creating a message of the type the first byte names, copying # the bytes in and setting the length reproduces that path through the public # API. The enum values are pinned as integers (RS=0, RA=1, NS=2, NA=3, R=4) # because they are what a handler switches on. want P01 parse-ra-type "1" want P02 parse-ra-curhoplimit "64" want P03 parse-ra-router-lifetime "1800" want P04 parse-ra-reachable "30000" want P05 parse-ra-retransmit "1000" want P06 parse-ra-managed "true" want P07 parse-ra-other "true" want P08 parse-ra-home-agent "false" want P09 parse-ra-route-pref "3" want P10 parse-ra-len "16" # The bytes that came out are the bytes that went in. The field-by-field checks # above would pass a parse that read the right values out of a buffer it had # quietly rewritten. want P11 parse-ra-bytes-intact "match" want P12 parse-ns-type "2" want P13 parse-ns-target "20010db8000000000000000000000001" want P14 parse-ns-bytes-intact "match" # An NS is not an RA. A parse that lost the type would hand back a view onto 16 # bytes that are really an IPv6 address. want P15 parse-ns-not-ra "NULL" want P16 parse-na-type "3" want P17 parse-na-router "true" want P18 parse-na-solicited "true" want P19 parse-na-override "false" want P20 parse-na-target "20010db8000000000000000000000002" want P21 parse-na-bytes-intact "match" want P22 parse-rs-type "0" want P23 parse-rs-len "8" want P24 parse-r-type "4" want P25 parse-r-len "40" # payload_len_set clamps at the buffer size rather than trusting its caller -- # the guard between a 1500-byte buffer and a length field read off the wire. want P26 payload-len-clamps "1500" # --- the option layer --------------------------------------------------------- # Six options built by hand to their own RFCs' layouts (SLLADDR/prefix/MTU from # RFC 4861 4.6, route information RFC 4191 2.3, RDNSS and DNSSL RFC 6106 # 5.1/5.2) and parsed back through the public accessors. Hand-built because # ndp_msg_opt_set is the only option setter libndp has and it needs an ioctl on # a real interface to read a MAC -- which this suite has by construction not # got. want O01 opts-region-len "136" want O02 opt-slladdr-offset "0" want O03 opt-slladdr "001122334455" want O04 opt-slladdr-len "6" want O05 opt-mtu "1500" want O06 opt-prefix "20010db8000100000000000000000000" want O07 opt-prefix-len "64" want O08 opt-prefix-valid "2592000" want O09 opt-prefix-preferred "604800" want O10 opt-prefix-onlink "true" want O11 opt-prefix-auto "true" want O12 opt-prefix-raddr "false" # ndp_msg_opt_route_prefix copies (len-1)<<3 bytes into a static buffer it # zeroes first, so a /48 route with length 3 must come back as the full 16 bytes # with the tail zeroed -- not as 16 bytes of whatever the previous call left. want O13 opt-route-prefix "20010db8000200000000000000000000" want O14 opt-route-prefix-len "48" want O15 opt-route-lifetime "3600" want O16 opt-route-pref "1" want O17 opt-rdnss-lifetime "600" want O18 opt-rdnss-addr0 "20010db8000000000000000000000053" want O19 opt-rdnss-addr1 "20010db8000000000000000000000054" # The list ends where the option's own length says it does. A walker that kept # reading would hand back the DNSSL option's bytes as an address. want O20 opt-rdnss-ends "NULL" want O21 opt-dnssl-lifetime "900" want O22 opt-dnssl-domain0 "example.com" want O23 opt-dnssl-ends "NULL" # The iteration, not just the first hit: an option that is present is found # exactly once, and one that is absent not at all. want O24 opt-iterate-prefix "1" want O25 opt-iterate-absent "0" # Two prefixes, which is the ordinary case for an RA advertising more than one # on-link prefix. Both must come back, in order, with their own flags. want O26 opt-two-prefix-first "64" want O27 opt-two-prefix-second "48" want O28 opt-two-prefix-flags "true" want O29 opt-two-prefix-count "2" # --- the walker's bounds ------------------------------------------------------ # ndp_msg_next_opt_offset is the only public entry into the option region, so # its guards are the whole of the public defence against a malformed # advertisement. X05 and X04 are the negative controls: without them X01-X03 # would be satisfied by a walker that refuses everything. want X01 walk-zero-length-refused "refused" want X02 walk-overrun-refused "refused" # 0xff is what ndp_msg_check_opts writes over an option it has rejected -- # including one rejected by the CVE-2024-5564 length check. The marked option is # skipped and the valid option BEHIND it is still found, which is what makes the # marking a filter rather than a truncation. want X03 walk-skips-marked "skipped" want X04 walk-finds-behind-marked "found-1280" want X05 walk-finds-valid-route "found" want X06 walk-empty-region "none" # --- the socket half, and the controls that localise it ----------------------- # Not one case above needs a descriptor. This group measures the half of libndp # that does, and it is where this port's platform finding lives. # # S04 is the hard assertion and it carries the whole control on its own: an # ordinary AF_INET datagram socket opens here, so nothing below can be read as # "this guest has no networking". S03 was written as its IPv6 twin on the # assumption that it would pass too. It does not, and that is the finding. check_eq libndp/control-inet-dgram "OK" "$(val S04)" # BRINTOS-WORKAROUND: brintos/linux#54 remove -- this kernel is built # `# CONFIG_IPV6 is not set` (arch/wasm32/configs/wasm32_defconfig), so the # ENTIRE AF_INET6 address family is absent and socket(AF_INET6, SOCK_DGRAM, 0) # answers EAFNOSUPPORT. Scored as a red rather than a skip so it flips green by # itself the day IPv6 is enabled, with no change to this file. # # This is the root cause of all three reds in this group, and it is worth being # precise about which fact it is: libndp's socket half is not refused a raw # ICMPv6 socket by a kernel that has IPv6 -- there is no IPv6 here at all. S04 # passing beside it is what makes that a diagnosis rather than an assertion. s03=$(val S03) case "$s03" in OK) bst_ok libndp/control-inet6-dgram ;; EAFNOSUPPORT) bst_notok libndp/control-inet6-dgram "brintos/linux#54: socket(AF_INET6, SOCK_DGRAM, 0) -> EAFNOSUPPORT — the wasm32 kernel is built '# CONFIG_IPV6 is not set', so the address family does not exist. AF_INET works on the same probe (S04), so this is IPv6 specifically and not the socket layer" ;; "") bst_notok libndp/control-inet6-dgram "no value on target (probe stopped before S03)" ;; *) bst_notok libndp/control-inet6-dgram "socket(AF_INET6,SOCK_DGRAM,0) -> $s03, which is neither OK nor the tracked EAFNOSUPPORT" ;; esac # S01/S02 are NOT pinned to a value, and that is deliberate rather than a gap. # Whether this kernel grants an unprivileged-namespace raw ICMPv6 socket is a # platform fact this port neither controls nor depends on; what libndp owes is # that ndp_open() reports the SAME answer as the bare syscall beside it. That # assertion holds on a platform that grants the socket and on one that refuses # it, so neither reading needs this file edited -- and a DISAGREEMENT is the # real defect it is looking for: ndp_open translating, swallowing or inventing # an errno on its way out. s01=$(val S01) s02=$(val S02) if [ -z "$s01" ] || [ -z "$s02" ]; then bst_notok libndp/ndp-open-agrees-with-syscall "no value on target (probe stopped before S01/S02)" elif [ "$s01" = "$s02" ]; then bst_ok libndp/ndp-open-agrees-with-syscall else bst_notok libndp/ndp-open-agrees-with-syscall "ndp_open -> $s01 but socket(PF_INET6,SOCK_RAW,IPPROTO_ICMPV6) -> $s02; libndp is not reporting the errno it was handed" fi # BRINTOS-WORKAROUND: brintos/linux#54 remove -- ndp_open() asks for # PF_INET6/SOCK_RAW/IPPROTO_ICMPV6, and with CONFIG_IPV6 unset the family is # refused before the socket type is ever considered. So the whole send/receive # half of libndp -- and ndptool with it -- cannot run here. The message API this # suite covers is unaffected: it opens no descriptor. # # The exact tracked pair is matched, not merely "not OK", so a DIFFERENT answer # -- EPERM from a kernel that has IPv6 but refuses raw sockets, or EPROTONOSUPPORT # from one that has the family but not ICMPv6 -- stays an untracked red instead of # being absorbed by this attribution. Those would be different defects. case "$s01" in OK) bst_ok libndp/ndp-open ;; EAFNOSUPPORT) bst_notok libndp/ndp-open "brintos/linux#54: ndp_open -> EAFNOSUPPORT — PF_INET6 does not exist on this kernel ('# CONFIG_IPV6 is not set'), so the raw ICMPv6 socket cannot be asked for. The bare syscall answers the same ($s02) and AF_INET opens fine (S04), so this is the missing address family and not libndp" ;; "") bst_notok libndp/ndp-open "no value on target (probe stopped before S01)" ;; *) bst_notok libndp/ndp-open "ndp_open -> $s01, which is neither OK nor the tracked EAFNOSUPPORT; the bare syscall answered $s02" ;; esac # BRINTOS-WORKAROUND: brintos/linux#54 remove -- consequential, not independent: # libndp's eventfd is a field of struct ndp, so with no context there is nothing # to read. Scored rather than skipped for the same reason as the two above. case "$(val S05)" in OK) bst_ok libndp/ndp-eventfd ;; no-context) bst_notok libndp/ndp-eventfd "brintos/linux#54, consequential: ndp_open failed ($s01) so there is no context, and ndp_get_eventfd reads a field of one. Nothing is asserted about the eventfd itself here" ;; "") bst_notok libndp/ndp-eventfd "no value on target (probe stopped before S05)" ;; *) bst_notok libndp/ndp-eventfd "ndp_get_eventfd -> $(val S05)" ;; esac rm -f "$pout"