diff --git a/Formula/netcat.rb b/Formula/netcat.rb new file mode 100644 index 0000000..3e5a4e5 --- /dev/null +++ b/Formula/netcat.rb @@ -0,0 +1,132 @@ +require (Tap.fetch("automattic", "kandelo-homebrew").path/"Kandelo/formula_support/kandelo_formula_support").to_s + +class Netcat < Formula + include KandeloFormulaSupport + + desc "GNU TCP and UDP networking utility for Kandelo" + homepage "https://netcat.sourceforge.net/" + url "https://downloads.sourceforge.net/project/netcat/netcat/0.7.1/netcat-0.7.1.tar.gz" + sha256 "30719c9a4ffbcf15676b8f528233ccc54ee6cba96cb4590975f5fd60c68a066f" + license "GPL-2.0-or-later" + + depends_on "binaryen" => :build + depends_on "wabt" => :build + + skip_clean "bin/netcat" + + patch :DATA + + def install + kandelo_require_arch!("wasm32") + + kandelo_wasm_build do |root| + prefix_maps = [ + "-ffile-prefix-map=#{buildpath}=/usr/src/netcat", + "-fdebug-prefix-map=#{buildpath}=/usr/src/netcat", + "-fmacro-prefix-map=#{buildpath}=/usr/src/netcat", + "-ffile-prefix-map=#{root}=/usr/src/kandelo", + "-fdebug-prefix-map=#{root}=/usr/src/kandelo", + "-fmacro-prefix-map=#{root}=/usr/src/kandelo", + ] + ENV["CFLAGS"] = ["-O2", "-gline-tables-only", "-fdebug-compilation-dir=.", *prefix_maps].join(" ") + # The SDK site owns target function facts. GNU Netcat's optional + # resolver-library probes are package-specific and libresolv is absent. + ENV["ac_cv_header_resolv_h"] = "no" + ENV["ac_cv_lib_resolv_main"] = "no" + + configure_args = kandelo_std_configure_args + # Upstream's 2004 config.sub predates arm64 Darwin. This identifies the + # build machine only; the SDK wrapper supplies the Wasm target triplet. + configure_args << "--build=arm-apple-darwin" if OS.mac? && Hardware::CPU.arm? + system kandelo_configure, *configure_args, + "--disable-nls", + "--without-included-gettext" + system "make", "-j#{ENV.make_jobs}" + kandelo_validate_wasm_artifact(buildpath/"src/netcat", fork: :forbidden) + end + + kandelo_install_bin(buildpath/"src", "netcat", "netcat") + bin.install_symlink "netcat" => "nc" + man1.install "doc/netcat.1" + end + + test do + version_output = kandelo_run_wasm(bin/"netcat", ["--version"]) + assert_match(/netcat \(The GNU Netcat\) 0\.7\.1/, version_output) + + help_output = kandelo_run_wasm(bin/"nc", ["--help"], preserve_argv0: true) + assert_includes help_output, "-l, --listen" + assert_includes help_output, "-u, --udp" + assert_includes help_output, "-c, --close" + + pair_output = kandelo_run_virtual_network_pairs( + bin/"netcat", + [ + { + name: "tcp", + transport: "tcp", + serverArgs: %w[nc -n -l -p 25125 -w 3], + clientArgs: %w[nc -n -c 10.88.0.2 25125], + serverStdin: "", + clientStdin: "from-tcp\n", + expectedServerStdout: "from-tcp\n", + }, + { + name: "udp", + transport: "udp", + serverArgs: %w[nc -n -c -u -l -p 25126 -w 3], + clientArgs: %w[nc -n -u -c 10.88.0.2 25126], + serverStdin: "", + clientStdin: "from-udp\n", + expectedServerStdout: "from-udp\n", + }, + ], + ) + assert_includes pair_output, '"tcp"' + assert_includes pair_output, '"udp"' + + binary = File.binread(bin/"netcat") + refute_includes binary, prefix.to_s + refute_match %r{/Users/[^/]+/}, binary + end +end + +__END__ +diff --git a/src/netcat.c b/src/netcat.c +index 8fd6b51..3c19d64 100644 +--- a/src/netcat.c ++++ b/src/netcat.c +@@ -494,2 +494,5 @@ int main(int argc, char *argv[]) + if (netcat_mode == NETCAT_LISTEN) { ++ /* A completed listen loop is successful; upstream leaves glob_ret at ++ its EXIT_FAILURE initializer. */ ++ glob_ret = EXIT_SUCCESS; + if (opt_exec) { +diff --git a/src/core.c b/src/core.c +index 7e6f3dd..158720a 100644 +--- a/src/core.c ++++ b/src/core.c +@@ -81,7 +81,11 @@ static int core_udp_listen(nc_sock_t *ncsock) + static int core_udp_listen(nc_sock_t *ncsock) + { + int ret, *sockbuf, sock, sock_max, timeout = ncsock->timeout; +- bool need_udphelper = TRUE; ++ /* GNU netcat's fallback enumerates interfaces with SIOCGIFCONF and binds ++ one socket per address. Kandelo exposes ordinary INADDR_ANY UDP bind, ++ but not that non-POSIX interface-enumeration ioctl. Select upstream's ++ single-socket path. */ ++ bool need_udphelper = FALSE; + #ifdef USE_PKTINFO + int sockopt = 1; + #endif +diff --git a/src/netcat.h b/src/netcat.h +index 88a2974..3ee95f0 100644 +--- a/src/netcat.h ++++ b/src/netcat.h +@@ -94 +94,5 @@ +-# define USE_PKTINFO ++/* Kandelo's current POSIX socket layer exposes the constants via libc headers, ++ but does not implement IP_PKTINFO ancillary data. Keep GNU netcat on its ++ portable UDP path until the kernel supports the sockopt and recvmsg control ++ messages. */ ++/* # define USE_PKTINFO */ diff --git a/Kandelo/formula_support/kandelo_formula_support.rb b/Kandelo/formula_support/kandelo_formula_support.rb index 234278b..760a62a 100644 --- a/Kandelo/formula_support/kandelo_formula_support.rb +++ b/Kandelo/formula_support/kandelo_formula_support.rb @@ -712,6 +712,40 @@ def kandelo_run_framebuffer_wasm( shell_output("cd #{Shellwords.escape(root)} && #{command} < /dev/null") end + # Run paired programs on isolated Kandelo machines joined by the host's + # LocalVirtualNetwork. This exercises bind/listen/connect and datagram paths + # that a single-process formula runner cannot cover. + def kandelo_run_virtual_network_pairs(bin_path, cases) + root = kandelo_require_root! + if (node = ENV.fetch("HOMEBREW_KANDELO_NODE", nil)).to_s != "" + ENV.prepend_path "PATH", File.dirname(node) + end + + wasm_path = Pathname(bin_path) + if wasm_path.extname != ".wasm" + staged_wasm = testpath/"#{wasm_path.basename}.wasm" + File.binwrite(staged_wasm, File.binread(wasm_path)) + wasm_path = staged_wasm + end + + config = JSON.generate({ cases: cases }) + # Compiled host output shadows TypeScript source under tsx. Network-pair + # tests must exercise the checkout supplied by HOMEBREW_KANDELO_ROOT. + FileUtils.rm_rf(Pathname(root)/"host/dist") + + runner = Pathname(__dir__)/"run-virtual-network-pairs.ts" + command = "cd #{Shellwords.escape(root)} && " + command << "KANDELO_FORMULA_VIRTUAL_PAIRS_JSON=#{Shellwords.escape(config)} " + command << "node --experimental-wasm-exnref --import tsx/esm " + command << "#{Shellwords.escape(runner.to_s)} #{Shellwords.escape(root)} " + command << Shellwords.escape(wasm_path.to_s) + command << " 2>&1" + + output = shell_output(command) + kandelo_record_node_execution!(wasm_path, [], launcher: "kandelo_run_virtual_network_pairs") + output + end + def kandelo_record_node_execution!(wasm_path, argv, launcher: "kandelo_run_wasm") receipt = ENV.fetch("HOMEBREW_KANDELO_NODE_RECEIPT_PATH", nil) return if receipt.to_s.empty? diff --git a/Kandelo/formula_support/run-virtual-network-pairs.ts b/Kandelo/formula_support/run-virtual-network-pairs.ts new file mode 100644 index 0000000..a7a45c3 --- /dev/null +++ b/Kandelo/formula_support/run-virtual-network-pairs.ts @@ -0,0 +1,140 @@ +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +interface PairCase { + name: string; + transport: "tcp" | "udp"; + serverArgs: string[]; + clientArgs: string[]; + serverStdin: string; + clientStdin: string; + expectedServerStdout: string; + timeoutMs?: number; +} + +interface PairConfig { + cases: PairCase[]; +} + +async function main(): Promise { + const [root, programPath] = process.argv.slice(2); + if (!root || !programPath) { + throw new Error("usage: run-virtual-network-pairs.ts KANDELO_ROOT PROGRAM"); + } + + const config = JSON.parse( + process.env.KANDELO_FORMULA_VIRTUAL_PAIRS_JSON ?? "{}", + ) as PairConfig; + if (!Array.isArray(config.cases) || config.cases.length === 0) { + throw new Error("KANDELO_FORMULA_VIRTUAL_PAIRS_JSON must contain cases"); + } + + const moduleUrl = (path: string) => pathToFileURL(join(root, path)).href; + const [ + { LocalVirtualNetwork }, + { NodePlatformIO }, + { runCentralizedProgram }, + ] = await Promise.all([ + import(moduleUrl("host/src/networking/virtual-network.ts")), + import(moduleUrl("host/src/platform/node.ts")), + import(moduleUrl("host/test/centralized-test-helper.ts")), + ]); + + const summaries: Record = {}; + for (const pair of config.cases) { + const network = new LocalVirtualNetwork(); + const serverIO = new NodePlatformIO(); + const clientIO = new NodePlatformIO(); + serverIO.network = network.attachMachine({ + id: `${pair.name}-server`, + address: [10, 88, 0, 2], + hostnames: [`${pair.name}-server`], + }); + clientIO.network = network.attachMachine({ + id: `${pair.name}-client`, + address: [10, 88, 0, 3], + hostnames: [`${pair.name}-client`], + }); + + let resolveServerReady!: () => void; + const serverReady = new Promise((resolve) => { + resolveServerReady = resolve; + }); + if (pair.transport === "tcp") { + const listenTcp = serverIO.network.listenTcp?.bind(serverIO.network); + if (!listenTcp) + throw new Error("virtual network has no TCP listener support"); + serverIO.network.listenTcp = (listenerId, addr, port, target) => { + const status = listenTcp(listenerId, addr, port, target); + if (status === 0) resolveServerReady(); + return status; + }; + } else if (pair.transport === "udp") { + const bindUdp = serverIO.network.bindUdp?.bind(serverIO.network); + if (!bindUdp) throw new Error("virtual network has no UDP bind support"); + serverIO.network.bindUdp = (endpointId, addr, port, target) => { + const status = bindUdp(endpointId, addr, port, target); + if (status === 0) resolveServerReady(); + return status; + }; + } else { + throw new Error( + `${pair.name} has unsupported transport ${String(pair.transport)}`, + ); + } + + const timeout = pair.timeoutMs ?? 10_000; + const serverRun = runCentralizedProgram({ + programPath, + argv: pair.serverArgs, + io: serverIO, + stdin: pair.serverStdin, + timeout, + }); + await Promise.race([ + serverReady, + serverRun.then((result) => { + throw new Error( + `${pair.name} server exited before ${pair.transport} readiness: ` + + JSON.stringify({ + status: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }), + ); + }), + ]); + const clientRun = runCentralizedProgram({ + programPath, + argv: pair.clientArgs, + io: clientIO, + stdin: pair.clientStdin, + timeout, + }); + const [server, client] = await Promise.all([serverRun, clientRun]); + const summary = { + serverStatus: server.exitCode, + clientStatus: client.exitCode, + serverStdout: server.stdout, + serverStderr: server.stderr, + clientStderr: client.stderr, + }; + if ( + server.exitCode !== 0 || + client.exitCode !== 0 || + server.stdout !== pair.expectedServerStdout || + server.stderr !== "" || + client.stderr !== "" + ) { + throw new Error(`${pair.name} failed: ${JSON.stringify(summary)}`); + } + summaries[pair.name] = summary; + } + + process.stdout.write(`${JSON.stringify(summaries)}\n`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/Kandelo/formula_support/test/kandelo_formula_support_test.rb b/Kandelo/formula_support/test/kandelo_formula_support_test.rb index 38f07a2..566da60 100644 --- a/Kandelo/formula_support/test/kandelo_formula_support_test.rb +++ b/Kandelo/formula_support/test/kandelo_formula_support_test.rb @@ -801,6 +801,32 @@ def test_pty_execution_rejects_an_empty_guest_argv0 assert_includes error.message, "guest argv0 must be a nonempty normalized absolute path" end + def test_virtual_network_pairs_use_tap_owned_runner_and_remove_stale_host_dist + Dir.mktmpdir("kandelo-formula-support") do |dir| + root = Pathname(dir)/"kandelo root" + host_dist = root/"host/dist" + host_dist.mkpath + (host_dist/"stale.js").binwrite("stale") + + harness = Harness.new + harness.root_path = root.to_s + output = harness.kandelo_run_virtual_network_pairs( + "program.wasm", + [{ name: "tcp", transport: "tcp", serverArgs: ["nc", "-l"], clientArgs: ["nc", "host"] }], + ) + + assert_equal "runtime-ok\n", output + assert_includes harness.command, "run-virtual-network-pairs.ts" + assert_includes harness.command, "KANDELO_FORMULA_VIRTUAL_PAIRS_JSON=" + assert_includes harness.command, "serverArgs" + assert_includes harness.command, "clientArgs" + assert_includes harness.command, "transport" + assert_includes harness.command, "program.wasm" + assert_equal "kandelo_run_virtual_network_pairs", harness.recorded_launcher + refute_path_exists host_dist + end + end + private def artifact_validation_harness(dir, harness_class = Harness) diff --git a/README.md b/README.md index 1d2c102..c4b50c6 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Current migration controls and pilots include: Kandelo's musl `catopen` and `catgets` implementation. - `ctags`, Universal Ctags' maintained tag generator, `readtags` query client, and optscript interpreter with complete C and C++ workflows. +- `netcat`, GNU TCP and UDP client/listener workflows across virtual Kandelo machines. The SDK is not yet a Homebrew dependency. Trusted builds supply an `HOMEBREW_KANDELO_ROOT` checkout containing the SDK, sysroot, kernel, and Node