diff --git a/Formula/getconf.rb b/Formula/getconf.rb new file mode 100644 index 0000000..950a93e --- /dev/null +++ b/Formula/getconf.rb @@ -0,0 +1,212 @@ +require (Tap.fetch("automattic", "kandelo-homebrew").path/"Kandelo/formula_support/kandelo_formula_support").to_s + +class Getconf < Formula + include KandeloFormulaSupport + + desc "Query POSIX system and pathname configuration for Kandelo" + homepage "https://man.openbsd.org/getconf.1" + url "https://raw.githubusercontent.com/openbsd/src/d7259957e8a5d4370d76bfccd4a30d5d1fe80f38/usr.bin/getconf/getconf.c" + version "1.23" + sha256 "e1c8be153cc3cfefa1a24bcaf62fe74d4d78eeadba660f320b09931e29d95c65" + license "BSD-4-Clause" + + depends_on "binaryen" => :build + depends_on "wabt" => :build + + skip_clean "bin/getconf" + + resource "manpage" do + url "https://raw.githubusercontent.com/openbsd/src/d7259957e8a5d4370d76bfccd4a30d5d1fe80f38/usr.bin/getconf/getconf.1" + sha256 "0acea5eed79da7b0dd04ad39a4e7b811a92da5311a8f321e8bb04339ed8ad328" + end + + def install + kandelo_require_arch!("wasm32") + artifact = buildpath/"getconf.wasm" + compat = buildpath/"kandelo-openbsd-compat.h" + compat.write <<~HEADER + #ifndef KANDELO_OPENBSD_GETCONF_COMPAT_H + #define KANDELO_OPENBSD_GETCONF_COMPAT_H + + #ifndef __dead + #define __dead __attribute__((__noreturn__)) + #endif + + static inline int pledge(const char *promises, const char *execpromises) { + (void)promises; + (void)execpromises; + return 0; + } + + static inline int unveil(const char *path, const char *permissions) { + (void)path; + (void)permissions; + return 0; + } + + #endif + HEADER + + kandelo_wasm_build do |root| + stable_source = "/usr/src/openbsd-getconf-#{version}" + prefix_maps = { + buildpath.to_s => stable_source, + root.to_s => "/usr/src/kandelo", + "/nix/store" => "/usr/src/toolchain", + }.flat_map do |from, to| + [ + "-ffile-prefix-map=#{from}=#{to}", + "-fdebug-prefix-map=#{from}=#{to}", + "-fmacro-prefix-map=#{from}=#{to}", + ] + end + + # pledge(2) and unveil(2) are OpenBSD host-sandbox boundaries. Kandelo's + # process remains confined by its VFS and host runtime; every reported + # value still comes from the target's sysconf, pathconf, or confstr API. + system kandelo_cc, + "-std=c17", "-O2", "-gline-tables-only", "-D_POSIX_C_SOURCE=200809L", + "-include", compat.basename, + "-fdebug-compilation-dir=#{stable_source}", *prefix_maps, + buildpath/"getconf.c", "-o", artifact + kandelo_validate_wasm_artifact( + artifact, + fork: :forbidden, + forbidden_paths: [buildpath.to_s], + ) + end + + kandelo_install_bin(buildpath, artifact.basename, "getconf") + resource("manpage").stage { man1.install "getconf.1" } + end + + test do + assert_path_exists man1/"getconf.1" + + node = lambda do |argv, **options| + kandelo_run_wasm(bin/"getconf", argv, argv0: "/usr/local/bin/getconf", **options) + end + chromium = lambda do |argv, **options| + kandelo_run_browser_wasm(bin/"getconf", argv, argv0: "getconf", **options) + end + + { + ["PAGESIZE"] => "65536\n", + ["OPEN_MAX"] => "1024\n", + ["NPROCESSORS_ONLN"] => "1\n", + ["PATH"] => "/bin:/usr/bin\n", + ["_POSIX_VERSION"] => "200809\n", + ["_POSIX_V7_ILP32_OFFBIG"] => "1\n", + ["_POSIX_V7_LP64_OFF64"] => "undefined\n", + }.each do |argv, expected| + assert_equal expected, node.call(argv) + assert_equal expected, chromium.call(argv) + end + + v7_flags = {} + %w[CFLAGS LDFLAGS LIBS].each do |kind| + variable = "POSIX_V7_ILP32_OFFBIG_#{kind}" + argv = ["-v", "POSIX_V7_ILP32_OFFBIG", variable] + v7_flags[kind] = node.call(argv).chomp + assert_equal v7_flags[kind] + "\n", chromium.call(argv) + end + + workspace = testpath/"workspace" + workspace.mkpath + sample = workspace/"sample.txt" + sample.write "sample\n" + node_mount = { "/work" => workspace } + browser_files = { "/work/sample.txt" => sample } + + assert_equal "255\n", node.call( + ["NAME_MAX", "/work/sample.txt"], writable_host_directories: node_mount + ) + assert_equal "255\n", chromium.call( + ["NAME_MAX", "/work/sample.txt"], guest_files: browser_files + ) + assert_equal "4096\n", node.call( + ["PATH_MAX", "/work"], writable_host_directories: node_mount + ) + assert_equal "4096\n", chromium.call( + ["PATH_MAX", "/work"], guest_files: browser_files + ) + + unknown_expected = "/usr/local/bin/getconf: NOT_A_VARIABLE: unknown variable\n" + assert_equal unknown_expected, node.call( + ["NOT_A_VARIABLE"], merge_stderr: true, expected_status: 1 + ) + assert_equal unknown_expected, chromium.call( + ["NOT_A_VARIABLE"], merge_stderr: true, expected_status: 1 + ) + + missing_expected = "/usr/local/bin/getconf: /work/missing: No such file or directory\n" + assert_equal missing_expected, node.call( + ["NAME_MAX", "/work/missing"], + merge_stderr: true, expected_status: 1, writable_host_directories: node_mount, + ) + assert_equal missing_expected, chromium.call( + ["NAME_MAX", "/work/missing"], + guest_files: browser_files, merge_stderr: true, expected_status: 1, + ) + + unsupported_env = [ + "-v", "POSIX_V7_LP64_OFF64", "POSIX_V7_LP64_OFF64_CFLAGS" + ] + unsupported_expected = "/usr/local/bin/getconf: POSIX_V7_LP64_OFF64: unknown specification\n" + assert_equal unsupported_expected, node.call( + unsupported_env, merge_stderr: true, expected_status: 1 + ) + assert_equal unsupported_expected, chromium.call( + unsupported_env, merge_stderr: true, expected_status: 1 + ) + + kandelo_activate_sdk! + kandelo_activate_sysroot! + smoke_c = testpath/"v7-flags-smoke.c" + smoke_wasm = testpath/"v7-flags-smoke.wasm" + smoke_c.write <<~C + #include + int main(void) { + puts("v7-flags-ok"); + return 0; + } + C + system kandelo_cc, + *Shellwords.split(v7_flags.fetch("CFLAGS")), smoke_c, + *Shellwords.split(v7_flags.fetch("LDFLAGS")), + *Shellwords.split(v7_flags.fetch("LIBS")), "-o", smoke_wasm + assert_equal "v7-flags-ok\n", kandelo_run_wasm(smoke_wasm, []) + assert_equal "v7-flags-ok\n", kandelo_run_browser_wasm(smoke_wasm, []) + + interleave_c = testpath/"interleaved-output.c" + interleave_wasm = testpath/"interleaved-output.wasm" + interleave_c.write <<~C + #include + #include + + static int write_all(int fd, const char *data, size_t length) { + while (length > 0) { + ssize_t written = write(fd, data, length); + if (written <= 0) return -1; + data += written; + length -= (size_t)written; + } + return 0; + } + + int main(void) { + if (write_all(STDOUT_FILENO, "stdout-1\\n", 9) != 0) return 2; + if (write_all(STDERR_FILENO, "stderr-1\\n", 9) != 0) return 2; + if (write_all(STDOUT_FILENO, "stdout-2\\n", 9) != 0) return 2; + if (write_all(STDERR_FILENO, "stderr-2\\n", 9) != 0) return 2; + return 0; + } + C + system kandelo_cc, interleave_c, "-o", interleave_wasm + interleaved = "stdout-1\nstderr-1\nstdout-2\nstderr-2\n" + assert_equal interleaved, kandelo_run_wasm(interleave_wasm, [], merge_stderr: true) + assert_equal interleaved, kandelo_run_browser_wasm( + interleave_wasm, [], argv0: "interleaved-output", merge_stderr: true + ) + end +end diff --git a/Kandelo/formula_support/browser-smoke-page.ts b/Kandelo/formula_support/browser-smoke-page.ts index 3800e6e..daade1b 100644 --- a/Kandelo/formula_support/browser-smoke-page.ts +++ b/Kandelo/formula_support/browser-smoke-page.ts @@ -14,6 +14,7 @@ interface BrowserSmokeResult { exitCode: number; stdout: string; stderr: string; + mergedOutput: string; } let activeKernel: BrowserKernel | null = null; @@ -40,14 +41,21 @@ async function run(request: BrowserSmokeRequest): Promise { const stdoutDecoder = new TextDecoder(); const stderrDecoder = new TextDecoder(); + const mergedChunks: Uint8Array[] = []; let stdout = ""; let stderr = ""; const kernel = new BrowserKernel({ kernelOwnedFs: true, maxWorkers: 6, maxMemoryPages: 16_384, - onStdout: (data) => { stdout += stdoutDecoder.decode(data, { stream: true }); }, - onStderr: (data) => { stderr += stderrDecoder.decode(data, { stream: true }); }, + onStdout: (data) => { + stdout += stdoutDecoder.decode(data, { stream: true }); + mergedChunks.push(data.slice()); + }, + onStderr: (data) => { + stderr += stderrDecoder.decode(data, { stream: true }); + mergedChunks.push(data.slice()); + }, }); activeKernel = kernel; @@ -89,7 +97,20 @@ async function run(request: BrowserSmokeRequest): Promise { ); stdout += stdoutDecoder.decode(); stderr += stderrDecoder.decode(); - return { exitCode, stdout, stderr }; + const mergedBytes = new Uint8Array( + mergedChunks.reduce((total, chunk) => total + chunk.byteLength, 0), + ); + let offset = 0; + for (const chunk of mergedChunks) { + mergedBytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { + exitCode, + stdout, + stderr, + mergedOutput: new TextDecoder().decode(mergedBytes), + }; } finally { await kernel.destroy().catch(() => {}); activeKernel = null; diff --git a/Kandelo/formula_support/kandelo_formula_support.rb b/Kandelo/formula_support/kandelo_formula_support.rb index ef6c5ea..bfce67f 100644 --- a/Kandelo/formula_support/kandelo_formula_support.rb +++ b/Kandelo/formula_support/kandelo_formula_support.rb @@ -740,12 +740,16 @@ def kandelo_run_kms_browser_wasm(bin_path, argv: [], min_page_flips: 2, timeout_ # runtimes whose behavior depends on argv[0]. `exec_programs:` stages # executable Wasm programs for spawn/exec behavior, while immutable # `guest_files:` use the same absolute-path and bounded-rootfs contract as - # Node formula tests. + # Node formula tests. `expected_status:` and `merge_stderr:` permit exact + # negative-path checks without converting a guest failure into a + # browser-runner failure. def kandelo_run_browser_wasm( bin_path, argv, argv0: nil, env: {}, exec_programs: {}, guest_files: {}, - timeout_ms: 120_000, allow_stderr: false + timeout_ms: 120_000, allow_stderr: false, merge_stderr: false, expected_status: 0 ) root = kandelo_require_root! + valid_status = expected_status.is_a?(Integer) && expected_status.between?(0, 255) + odie "expected browser status must be an integer from 0 through 255" unless valid_status if (node = ENV.fetch("HOMEBREW_KANDELO_NODE", nil)).to_s != "" ENV.prepend_path "PATH", File.dirname(node) end @@ -757,11 +761,13 @@ def kandelo_run_browser_wasm( odie "invalid browser guest command name: #{command_name}" if invalid_command_name config = JSON.generate({ - argv: argv.map(&:to_s), - argv0: command_name, - env: env.transform_values(&:to_s), - timeoutMs: timeout_ms, - allowStderr: allow_stderr, + argv: argv.map(&:to_s), + argv0: command_name, + env: env.transform_values(&:to_s), + timeoutMs: timeout_ms, + allowStderr: allow_stderr, + mergeStderr: merge_stderr, + expectedStatus: expected_status, }) guest_files_manifest = testpath/"#{wasm_path.basename}.browser-guest-files.json" File.binwrite( diff --git a/Kandelo/formula_support/run-browser-wasm.ts b/Kandelo/formula_support/run-browser-wasm.ts index 41cba17..8b60906 100644 --- a/Kandelo/formula_support/run-browser-wasm.ts +++ b/Kandelo/formula_support/run-browser-wasm.ts @@ -23,12 +23,15 @@ interface RunnerConfig { env: Record; timeoutMs: number; allowStderr: boolean; + mergeStderr: boolean; + expectedStatus: number; } interface BrowserSmokeResult { exitCode: number; stdout: string; stderr: string; + mergedOutput: string; } interface PageRunnerConfig extends RunnerConfig { @@ -98,6 +101,16 @@ function parseConfig(text: string): RunnerConfig { if (typeof value.allowStderr !== "boolean") { throw new Error("formula browser allowStderr must be boolean"); } + if (typeof value.mergeStderr !== "boolean") { + throw new Error("formula browser mergeStderr must be boolean"); + } + if ( + !Number.isSafeInteger(value.expectedStatus) || + (value.expectedStatus ?? -1) < 0 || + (value.expectedStatus ?? -1) > 255 + ) { + throw new Error(`invalid formula browser expected status: ${String(value.expectedStatus)}`); + } return value as RunnerConfig; } @@ -391,10 +404,15 @@ async function main(): Promise { }).__runKandeloFormulaBrowserSmoke(request), pageConfig, ); - if (result.exitCode !== 0 || (!config.allowStderr && result.stderr.length > 0) || pageErrors.length > 0) { + const unexpectedStderr = !config.allowStderr && !config.mergeStderr && result.stderr.length > 0; + if ( + result.exitCode !== config.expectedStatus || + unexpectedStderr || + pageErrors.length > 0 + ) { throw new Error(`formula browser smoke failed: ${JSON.stringify({ ...result, pageErrors })}`); } - process.stdout.write(result.stdout); + process.stdout.write(config.mergeStderr ? result.mergedOutput : result.stdout); await page.evaluate(() => (window as unknown as { __cleanupKandeloFormulaBrowserSmoke?: () => Promise }) .__cleanupKandeloFormulaBrowserSmoke?.(), diff --git a/Kandelo/formula_support/test/kandelo_formula_support_test.rb b/Kandelo/formula_support/test/kandelo_formula_support_test.rb index e29c9e4..c7ece34 100644 --- a/Kandelo/formula_support/test/kandelo_formula_support_test.rb +++ b/Kandelo/formula_support/test/kandelo_formula_support_test.rb @@ -1071,6 +1071,8 @@ def test_browser_execution_uses_focused_chromium_runner_and_removes_stale_host_d assert_includes harness.command, command.to_s assert_includes harness.command, "console.log" assert_includes harness.command, "allowStderr" + assert_includes harness.command, "expectedStatus" + assert_includes harness.command, "mergeStderr" assert_includes harness.command, "node" manifest = harness.test_path/"node.browser-guest-files.json" assert_equal({ "/opt/formula/format.dat" => guest_file.to_s }, JSON.parse(manifest.read)) @@ -1087,6 +1089,34 @@ def test_browser_execution_uses_focused_chromium_runner_and_removes_stale_host_d end end + def test_browser_execution_accepts_expected_nonzero_status_and_merged_stderr + Dir.mktmpdir("kandelo-formula-support") do |dir| + harness = Harness.new + harness.root_path = Pathname(dir)/"kandelo root" + harness.test_path = Pathname(dir)/"formula test" + harness.test_path.mkpath + command = Pathname(dir)/"getconf" + command.binwrite("\0asm") + + output = harness.kandelo_run_browser_wasm( + command, ["NOT_A_VARIABLE"], + argv0: "getconf", expected_status: 1, merge_stderr: true + ) + + assert_equal "runtime-ok\n", output + assert_includes harness.command, 'expectedStatus\":1' + assert_includes harness.command, 'mergeStderr\":true' + end + end + + def test_browser_execution_rejects_invalid_expected_status + error = assert_raises(RuntimeError) do + Harness.new.kandelo_run_browser_wasm("program.wasm", [], expected_status: 256) + end + + assert_equal "expected browser status must be an integer from 0 through 255", error.message + end + def test_browser_execution_accepts_posix_multicall_bracket_name Dir.mktmpdir("kandelo-formula-support") do |dir| harness = Harness.new diff --git a/README.md b/README.md index 51032a0..60601ac 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ Current migration controls and pilots include: Kandelo's musl `catopen` and `catgets` implementation. - `procps`, the upstream `ps` process reporter backed by Kandelo's truthful cross-process procfs state. +- `getconf`, OpenBSD's POSIX configuration frontend over Kandelo's real + `sysconf`, `pathconf`, and `confstr` interfaces. - `ctags`, Universal Ctags' maintained tag generator, `readtags` query client, and optscript interpreter with complete C and C++ workflows. - `tar`, the GNU archive creation and extraction CLI.