Skip to content

release: v1.2.0 - add device discovery (subnet TCP sweep + mDNS) - #19

Merged
Shinrai merged 21 commits into
masterfrom
next
Sep 3, 2026
Merged

Shinrai merged 21 commits into
masterfrom
next

Conversation

@cldmv-bot

@cldmv-bot cldmv-bot Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

DroidSock v1.2.0 Changelog

Release Status: Minor release


Overview

v1.2.0 adds device discovery - finding ADB-capable devices on the network without already knowing an address - and closes a shell-injection gap present in every files.* method that shells out to the device.


🐛 Fixed

  • Shell-metacharacter injection in files.mjs - every shell-based file operation (listShell, stat, mkdir, remove, move, copy, chmod, diskUsage, find) interpolated a caller-supplied remote path into a double-quoted shell command with no escaping, letting a path containing $, backticks, or an embedded " break out of the intended command. All nine call sites now go through a shared utils.quoteShellArg() helper using the standard POSIX '\'' single-quote escaping technique. mkdir/chmod's mode and find's maxDepth/type are also now validated before being interpolated, closing a couple of related unvalidated-input gaps found during review.

✨ Added

Device discovery (discover.subnet / discover.mdns)

discover.subnet(cidr, port, options) sweeps a CIDR range for a reachable TCP port - the classic fixed-address adb connect flow - with bounded concurrency and a maxHosts safety ceiling. discover.mdns(options) is a hand-written mDNS client (built directly from RFC 1035/6762/6763, no dependency) that queries for _adb-tls-connect._tcp.local-style wireless-debugging advertisements and correlates PTR/SRV/TXT/A records into {name, host, port, txt} entries. EXPERIMENTAL - both are built from the protocol specs and covered by extensive tests against real loopback TCP/UDP sockets, but neither has been run against a real device or a real multicast network yet. See #1.

📋 Known Limitations

  • discover.subnet/discover.mdns, like the rest of droidsock's protocol work, haven't been validated against real hardware or a real network yet. #1 tracks the real-device session needed to validate the protocol stack they depend on.

← Back to README

👥 Contributors

coverage

Metric Coverage
Statements 59.9%
Branches 57.1%
Functions 55.8%
Lines 60.8%

Avg: 58.4% · 144011b · Node lts/*

Shinrai and others added 6 commits September 2, 2026 16:07
files.mjs's shell-based file operations (listShell, stat, mkdir, remove,
move, copy, chmod, diskUsage, find) interpolated caller-supplied paths
into double-quoted shell commands with no escaping, letting $, backticks,
or an embedded double-quote break out of the command. Adds
utils.quoteShellArg() (the standard POSIX '\'' single-quote technique)
and applies it at all nine call sites.

Closes #13.
Adds src/api/discover.mjs per the two decided mechanisms on issue #5:

- subnet(cidr, port, options): a CIDR-bounded TCP-connect sweep for
  classic fixed-port devices, with bounded concurrency and a maxHosts
  safety ceiling.
- mdns(options): a hand-written mDNS client (no dependency, per the
  issue's evaluation of multicast-dns) for wireless-debugging-advertised
  devices - builds a PTR query, parses PTR/SRV/TXT/A answers including
  DNS name-compression-pointer decoding, and correlates them into
  {name, host, port, txt} entries. address/port are overridable so
  callers (and tests) can target a specific responder instead of the
  real multicast group.

Real-device validation is out of scope here, same posture as the rest
of droidsock's protocol work - tracked by #1.

Closes #5.
Copilot review on PR #17 flagged that mkdir/chmod's mode and find's
maxDepth/type were still interpolated into shell commands unvalidated -
mode.toString(8) silently skips the octal conversion for a non-number,
letting unvalidated input reach the shell. Adds assertValidMode() and
inline validation for maxDepth/type, rejecting anything outside a safe
range/whitelist instead of interpolating it. Also fixes maxDepth's
truthy check, which dropped a valid maxDepth: 0, and corrects
quoteShellArg's JSDoc type to match its actual String() coercion.
Three issues from Copilot review on PR #18:

- ingestDnsRecords() created a device entry from a lone SRV/TXT record
  even without a matching PTR - since joining the multicast group means
  this socket also receives every other service's mDNS traffic on the
  segment, that turned unrelated announcements into false-positive
  "devices". SRV/TXT now only update an entry a PTR record introduced.
- mdns() always bound its socket to an ephemeral port. A compliant
  responder replies via multicast back to address:port, not to the
  querier's source port, so the real multicast path now binds to that
  port; a direct-unicast target (tests, or a caller pointing at a
  specific responder) still uses an ephemeral bind to avoid a same-host
  port clash.
- tests/discover.test.vitest.mjs's afterEach() closed UDP sockets
  without awaiting completion, a source of flakiness across runs since
  dgram.Socket.close() is async - now awaited like the TCP servers
  right above it.

Adds a regression test for the SRV/TXT-without-PTR case.
@cldmv-bot cldmv-bot Bot added ! release → master v4 flow: persistent next → master release PR (carries the next feature release) release Marks a pull request as a pending release — merge to publish a new version semver: patch This release contains only backwards-compatible bug fixes type: bug Something is broken or not behaving as expected area: core Touches core library / runtime source code area: tests Touches test files, fixtures, or test infrastructure type: dependencies Relates to dependency updates, version bumps, or package management labels Sep 3, 2026
@cldmv-bot

cldmv-bot Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor Author

🔒 Dependency Review

  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ✅ 0 package(s) with unknown licenses
  • ✅ 0 denied package(s)
  • ✅ 0 package(s) with OpenSSF Scorecard score < 3

Full job summary

Shinrai and others added 11 commits September 2, 2026 18:29
Follow-up Copilot review on PR #18 (1 new thread + 2 previously-suppressed
findings against the original commit):

- isMulticastAddress() now checks the whole 224.0.0.0-239.255.255.255
  range instead of just the "224." and "239." boundary prefixes -
  225.x-238.x addresses were being misdetected as unicast and bound to
  an ephemeral port, missing real responses.
- TXT record parsing now skips a zero-length entry (RFC 6763's "no TXT
  data" marker) instead of recording it as a bogus {"": true}, and stops
  at a truncated entry (a length byte claiming more bytes than remain in
  the record) instead of reading past the record's bounds.
- parseCidr() accepts /0 as valid CIDR syntax (computed directly, since
  JS's `<<` treats a shift of 32 as a no-op) rather than rejecting it as
  an invalid prefix - maxHosts already guards against actually sweeping
  it.

Adds regression tests for the /0 and TXT-parsing cases.
…plit

Third Copilot review on PR #18 (2 new threads + 3 suppressed findings, 2
of which are the same underlying issue the first new thread already
cross-referenced at two other locations):

- TXT key/value objects (readDnsRecord's parsing, ingestDnsRecords' entry
  creation) are now Object.create(null) instead of {} - a plain object
  lets a TXT key like "__proto__" interact with Object.prototype's
  accessor. The TXT merge step now uses Object.assign() to mutate the
  existing null-proto object in place instead of `{...entry.txt,
  ...record.data}`, since spread always produces a normal
  Object.prototype-based object regardless of the source's prototype -
  reintroducing the exact risk the null-proto objects exist to avoid.
- parseCidr() now validates the "/"-split has exactly 2 parts and the
  prefix is a plain digit string, rejecting inputs like
  "1.2.3.4/24/extra" (extra segments silently dropped by destructuring)
  and "1.2.3.4/" (empty prefix coerced to 0 via Number("")).
- subnet() now validates port/timeoutMs/concurrency/maxHosts up front -
  concurrency <= 0 previously spawned zero sweep workers and silently
  resolved to [] even when hosts were reachable.

Adds regression tests for all three.
…daries

Fourth Copilot review on PR #18 (1 new thread + 2 suppressed findings,
the latter two being the same underlying issue):

- mdns() accepted invalid port/timeoutMs with no validation, unlike
  subnet(). Now validates both up front, matching subnet()'s behavior.
  Also marks mdns() itself async so a validation throw becomes a proper
  rejected promise for non-await callers, rather than a synchronous
  throw out of a function that otherwise always returns a Promise.
- ingestDnsRecords() rebuilt its A-record address cache fresh on every
  UDP message, so an SRV record could never resolve to a host if its A
  record arrived in an earlier, separate message (a real possibility
  once a response is split across multiple packets). The cache is now
  owned by mdns() and persists for the lifetime of one discovery call,
  passed into ingestDnsRecords() instead of being rebuilt per-message.

Adds regression tests for both.
Sixth Copilot review on PR #18 (1 new thread + 4 suppressed findings,
2 of which duplicate the new thread):

- readDnsRecord() joins whatever bytes an A record's rdata holds
  regardless of rdlength, so a malformed/hostile record (rdlength != 4)
  could produce a wrong-shaped host string and, worse, silently
  overwrite a previously-cached valid address for the same hostname.
  The address cache now only accepts A data that passes isValidIP().
- tests/discover.test.vitest.mjs's null-prototype TXT assertions
  (3 locations) used toEqual() against a plain-object literal - verified
  empirically that toEqual doesn't compare prototypes, so a regression
  back to a plain {} would still pass silently. Added an explicit
  Object.getPrototypeOf(...) === null check alongside each, which is
  what actually catches that regression (the review's literal suggestion
  of just using a null-proto expected value wouldn't have).

Adds regression tests for the malformed-A-record case, including that
it can't clobber an already-resolved valid address.
Seventh Copilot review on PR #18 (2 suppressed findings, no new threads):

- mdns() now calls socket.setMulticastTTL(255) before sending its query,
  per RFC 6762 section 11 - a querier not sending TTL 255 risks being
  ignored by strict responders. Node's platform default multicast TTL
  is typically 1. Harmless on the direct-unicast test path, since it
  only governs multicast-destined packets.
- tests/discover.test.vitest.mjs's encodeName() test helper length-
  prefixed each DNS label with the source string's .length (UTF-16 code
  units) instead of its UTF-8 byte length, diverging from discover.mjs's
  own (correct) encodeDnsName and producing a malformed encoding for any
  non-ASCII label. Fixed to match; added a regression test with a
  non-ASCII instance name round-tripping through mdns().
…st helper

Eighth Copilot review on PR #18 (2 suppressed findings, no new threads):

- encodeDnsName() wrote a label's length byte straight from its byte
  count with no bound - a label's length byte top two bits are reserved
  for compression-pointer signaling (RFC 1035 3.1), so only 0-63 is a
  valid literal-label length, and Buffer.from([n]) silently truncates
  n>255 to a byte. A caller-controlled serviceType exceeding either
  limit would corrupt the encoded message in a way no receiver (our own
  decodeDnsName included) could recover from. Both limits are now
  enforced with a clear thrown error.
- That throw needed to happen somewhere an async caller can catch it:
  moved the buildPtrQuery() call out of the bind callback (dgram's
  event-emission machinery, outside any try/catch - a throw there could
  crash the process) to the top of mdns(), alongside the existing
  port/timeoutMs validation, so the async function wrapper turns it into
  a normal rejection.
- tests/discover.test.vitest.mjs's buildMdnsResponse() had the same
  UTF-16-vs-UTF-8 length bug as encodeName() (fixed last round) in its
  TXT-entry encoding. Fixed to match.

Adds regression tests for all three.
Ninth Copilot review on PR #18 (2 suppressed findings, no new threads,
same root cause at two test cases).

Both cross-message mDNS tests sent their two UDP packets synchronously
back-to-back, relying on delivery order UDP doesn't actually guarantee -
a CI-flakiness risk on runners/kernels where that happens to matter.
Both now send the second packet from the first send()'s own completion
callback instead, so the enqueue order is deterministic rather than
assumed.
…rity PRs are signed

The dependabot-security redirect path in workflow-hotfix-redirector.yml@v4
cherry-picks onto `hotfixes` and needs the bot GPG key to sign the commit, or
the redirected PR is silently blocked by required-signatures. wire the CLDMV_BOT_* signing secrets into the caller.

See CLDMV/.github#257.
Tenth Copilot review on PR #18 (1 suppressed finding, no new threads).

listenOn() never rejected or resolved if net.Server emitted an "error"
event during listen() (e.g. EADDRNOTAVAIL/EACCES) - the promise would
hang until Vitest's own timeout. It also pushed the server into
openServers before confirming a successful bind, so teardown could try
to close a server that never actually started.

Now rejects on the server's "error" event, and only registers the
server for teardown after listen() succeeds.
Eleventh Copilot review on PR #18 (1 suppressed finding, no new threads).

readDnsRecord() decoded PTR/SRV rdata without checking that the decoded
name stayed within rdataEnd. decodeDnsName() has no notion of rdlength,
so a truncated/malformed record could make it walk straight into the
next record's bytes on the wire and fabricate an incorrect name (or, for
SRV, priority/weight/port off a too-short rdata) instead of the parser
treating the record as invalid.

PTR now nulls its data when the decoded name's end exceeds rdataEnd;
SRV nulls its data outright when rdlength < 6 (too small to hold
priority/weight/port), and also when its target name overruns. Both
follow the same "null data, caller skips it" pattern already used for
malformed A records. ingestDnsRecords() now guards both consumption
sites against a null record.data.
@cldmv-bot cldmv-bot Bot added the type: ci Changes to CI workflows, actions, or build pipelines label Sep 3, 2026
@cldmv-bot cldmv-bot Bot added the type: config Changes to repository or project configuration files label Sep 3, 2026
Shinrai
Shinrai previously approved these changes Sep 3, 2026
@cldmv-bot cldmv-bot Bot changed the title release: v1.1.2 - single-quote-escape remote paths in files.mjs shell… release: v1.2.0 - add device discovery (subnet TCP sweep + mDNS) Sep 3, 2026
Shinrai and others added 2 commits September 3, 2026 06:42
…shell-fix

Committed per-version changelog file so the next-release refresh uses it
directly as the release-PR body instead of the auto-generated commit
summary (CLDMV/.github v4 release flow, docs/changelog/v1/{version}.md
lookup) - works around CLDMV/.github#262 for this release, since the
generator finds this file before it ever needs the commit-range that bug
report is about.
@cldmv-bot cldmv-bot Bot added type: documentation Relates to docs, README updates, guides, or inline code comments semver: minor This release adds new functionality in a backwards-compatible way type: feature Implements new functionality — a PR or issue that adds a feature and removed semver: patch This release contains only backwards-compatible bug fixes labels Sep 3, 2026
@Shinrai
Shinrai merged commit e98e03f into master Sep 3, 2026
34 checks passed
@cldmv-bot
cldmv-bot Bot deleted the next branch September 3, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core Touches core library / runtime source code area: tests Touches test files, fixtures, or test infrastructure ! release → master v4 flow: persistent next → master release PR (carries the next feature release) release Marks a pull request as a pending release — merge to publish a new version semver: minor This release adds new functionality in a backwards-compatible way type: bug Something is broken or not behaving as expected type: ci Changes to CI workflows, actions, or build pipelines type: config Changes to repository or project configuration files type: dependencies Relates to dependency updates, version bumps, or package management type: documentation Relates to docs, README updates, guides, or inline code comments type: feature Implements new functionality — a PR or issue that adds a feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant