From 1d317b1c8a21dc5eb45366d23a115d1d5a12ce19 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 18 Aug 2026 15:07:04 +0200 Subject: [PATCH 1/4] Let system() and command-pipe children inherit the standard input The children of system(cmd) and of a command input pipe ("cmd" | getline) now inherit the JVM's standard input when Jawk reads the real standard input of the process (CLI runs), as POSIX requires and as gawk, mawk, and BWK awk behave. Stdin filters and terminal-aware commands such as "stty size" | getline now work. Embedded executions bound to a custom Java input stream keep the closed-stdin behavior: a Java stream cannot be lent to another OS process, and handing over the host JVM's standard input would leak input the embedder never gave to Jawk. Output pipes are unchanged: the pipe itself remains the child's standard input. Fixes #575 Co-Authored-By: Claude Fable 5 --- src/main/java/io/jawk/jrt/JRT.java | 73 ++++++---- src/site/markdown/behavior-changes.md | 10 ++ src/site/markdown/java-output.md | 8 ++ .../java/io/jawk/SpawnedProcessStdinTest.java | 128 ++++++++++++++++++ 4 files changed, 195 insertions(+), 24 deletions(-) create mode 100644 src/test/java/io/jawk/SpawnedProcessStdinTest.java diff --git a/src/main/java/io/jawk/jrt/JRT.java b/src/main/java/io/jawk/jrt/JRT.java index 3a146300..1187218a 100644 --- a/src/main/java/io/jawk/jrt/JRT.java +++ b/src/main/java/io/jawk/jrt/JRT.java @@ -3167,20 +3167,34 @@ public boolean jrtConsumeFileInput(String fileNameParam) throws IOException { return true; } - private static Process spawnProcess(String cmd) throws IOException { - Process p; - - if (IS_WINDOWS) { - // spawn the process using the Windows shell - ProcessBuilder pb = new ProcessBuilder("cmd.exe", "/c", cmd); - p = pb.start(); - } else { - // spawn the process using the default POSIX shell - ProcessBuilder pb = new ProcessBuilder("/bin/sh", "-c", cmd); - p = pb.start(); - } - - return p; + private static Process spawnProcess(String cmd, boolean inheritStandardInput) throws IOException { + ProcessBuilder pb = IS_WINDOWS + // spawn the process using the Windows shell + ? new ProcessBuilder("cmd.exe", "/c", cmd) + // spawn the process using the default POSIX shell + : new ProcessBuilder("/bin/sh", "-c", cmd); + if (inheritStandardInput) { + pb.redirectInput(ProcessBuilder.Redirect.INHERIT); + } + return pb.start(); + } + + /** + * Tells whether processes spawned on behalf of the script share the + * standard input of this JVM. POSIX gives the children of {@code system()} + * and of a command pipe the same standard input as awk itself, which is how + * terminal-aware commands like {@code "stty size" | getline} find the + * controlling terminal. That is only faithful when Jawk reads the real + * standard input of the process: an embedded execution bound to a custom + * stream cannot lend that stream to another OS process, and handing over + * the host JVM's standard input instead would leak input the embedder never + * gave to Jawk, so there the child's standard input stays closed. + * + * @return {@code true} when spawned processes inherit the JVM's standard + * input + */ + private boolean spawnedProcessInheritsStandardInput() { + return standardInput == System.in; } /** @@ -3261,8 +3275,13 @@ private CommandInputState createCommandInputState(String cmd) throws IOException Process process = null; Thread errorPump = null; try { - process = spawnProcess(cmd); - process.getOutputStream().close(); + // POSIX: the child shares awk's standard input; when that is not + // possible (embedded execution on a custom stream) it stays closed + boolean inheritStandardInput = spawnedProcessInheritsStandardInput(); + process = spawnProcess(cmd, inheritStandardInput); + if (!inheritStandardInput) { + process.getOutputStream().close(); + } errorPump = DataPump.dumpAndReturnThread(cmd + " stderr", process.getErrorStream(), error); PartitioningReader reader = new PartitioningReader( new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8), @@ -3294,7 +3313,8 @@ private ProcessOutputState createProcessOutputState(String cmd) { PrintStream processOutput = null; try { processOutput = awkSink.getPrintStream(); - process = spawnProcess(cmd); + // the pipe itself is the child's standard input + process = spawnProcess(cmd, false); stderrPump = DataPump.dumpAndReturnThread(cmd + " stderr", process.getErrorStream(), error); stdoutPump = DataPump.dumpAndReturnThread(cmd + " stdout", process.getInputStream(), processOutput); PrintStream processInput = new PrintStream(process.getOutputStream(), true, StandardCharsets.UTF_8.name()); @@ -3502,10 +3522,11 @@ private boolean jrtCloseCommandReader(String cmd) { * Executes the command specified by cmd and waits * for termination, returning an Integer object * containing the return code. - * stdin to this process is closed while - * threads are created to shuttle stdout and - * stderr of the command to stdout/stderr - * of the calling process. + * The command inherits the standard input of the JVM when Jawk reads the + * real standard input (CLI runs), as POSIX requires of {@code system()}; + * otherwise its standard input is closed. Threads are created to shuttle + * stdout and stderr of the command to stdout/stderr of the calling + * process. * * @param cmd The command to execute. * @return Integer(return_code) of the created @@ -3514,9 +3535,13 @@ private boolean jrtCloseCommandReader(String cmd) { public Integer jrtSystem(String cmd) { try { PrintStream processOutput = awkSink.getPrintStream(); - Process p = spawnProcess(cmd); - // no input to this process! - p.getOutputStream().close(); + // POSIX: the child shares awk's standard input; when that is not + // possible (embedded execution on a custom stream) it stays closed + boolean inheritStandardInput = spawnedProcessInheritsStandardInput(); + Process p = spawnProcess(cmd, inheritStandardInput); + if (!inheritStandardInput) { + p.getOutputStream().close(); + } Thread errorPump = DataPump.dumpAndReturnThread(cmd + " stderr", p.getErrorStream(), error); Thread outputPump = DataPump.dumpAndReturnThread(cmd + " stdout", p.getInputStream(), processOutput); boolean interrupted = false; diff --git a/src/site/markdown/behavior-changes.md b/src/site/markdown/behavior-changes.md index a9a33687..8496b010 100644 --- a/src/site/markdown/behavior-changes.md +++ b/src/site/markdown/behavior-changes.md @@ -20,6 +20,16 @@ released version automatically via .github/scripts/stamp-behavior-changes.sh. ## Unreleased +- The children of `system(cmd)` and of a command input pipe (`"cmd" | getline`) now inherit + Jawk's standard input when Jawk reads the standard input of the JVM (every CLI run), as POSIX + requires and as gawk, mawk, and BWK awk behave: `echo hi | jawk 'BEGIN { "sort" | getline l; + print l }'` prints `hi`, and terminal-aware commands such as `"stty size" | getline` can reach + the controlling terminal. Previously the child's standard input was always closed, so stdin + filters read nothing and `stty` failed with `Inappropriate ioctl for device`. Output pipes + (`print | "cmd"`) are unchanged — the pipe itself remains the child's standard input — and + embedded executions bound to a custom Java input stream keep the closed-stdin behavior, since + a Java stream cannot be lent to another OS process + ([#575](https://github.com/jawkio/jawk/issues/575)). - An input-derived value whose text is a number surrounded by blanks — a record like `" 12 "`, a `getline var` result read from padded input, a `split()` piece under a non-default separator — is now recognized as a POSIX numeric string, so `$0 == 12` is true for the record diff --git a/src/site/markdown/java-output.md b/src/site/markdown/java-output.md index 1967209a..5ad4b0f3 100644 --- a/src/site/markdown/java-output.md +++ b/src/site/markdown/java-output.md @@ -228,6 +228,14 @@ awk.script("BEGIN { system(\"mycommand\") }") The CLI uses `.errorStream(System.err)` so that command errors appear on the console rather than mixing with normal output. +Subprocess **stdin** follows POSIX in CLI runs: the children of `system("...")` and of a +command input pipe (`"cmd" | getline`) inherit the standard input of the JVM, so stdin +filters and terminal-aware commands (`"stty size" | getline`) work as they do under gawk. +In embedded runs that bind a custom input stream via `input(...)`, the child's standard +input is closed instead: a Java stream cannot be lent to another OS process, and the +host's real standard input is never handed to the script's children. The child of an +output pipe (`print ... | "cmd"`) always reads the pipe itself as its standard input. + ## See Also - [Java Quickstart](java.html) diff --git a/src/test/java/io/jawk/SpawnedProcessStdinTest.java b/src/test/java/io/jawk/SpawnedProcessStdinTest.java new file mode 100644 index 00000000..8e847d14 --- /dev/null +++ b/src/test/java/io/jawk/SpawnedProcessStdinTest.java @@ -0,0 +1,128 @@ +package io.jawk; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * Jawk + * ჻჻჻჻჻჻ + * Copyright (C) 2006 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Lesser Public License for more details. + * + * You should have received a copy of the GNU General Lesser Public + * License along with this program. If not, see + * . + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.TimeUnit; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Verifies that the children of {@code system()} and of a command input pipe + * ({@code "cmd" | getline}) inherit Jawk's standard input, as POSIX requires, + * when Jawk reads the standard input of the JVM — and that embedded executions + * bound to a custom stream keep the child's standard input closed. + *

+ * The inheritance can only be observed across a real process boundary, which + * the in-process builders of {@link AwkTestSupport} cannot provide, so the + * inheriting cases spawn the CLI in a fresh JVM whose standard input is + * redirected from a file. + */ +public class SpawnedProcessStdinTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void commandInputPipeChildReadsJawkStandardInput() throws Exception { + String output = runCliJvmWithStdin( + "BEGIN { \"sort\" | getline line; print \"[\" line \"]\" }", + "hello\n"); + assertEquals("[hello]\n", output); + } + + @Test + public void systemChildReadsJawkStandardInput() throws Exception { + String output = runCliJvmWithStdin( + "BEGIN { system(\"sort\") }", + "zulu\nalpha\n"); + assertEquals("alpha\nzulu\n", output); + } + + @Test + public void embeddedExecutionKeepsChildStandardInputClosed() throws Exception { + // A custom input stream cannot be lent to another OS process, so the + // child sees end of input at once and getline returns 0 + AwkTestSupport + .cliTest("embedded cmd|getline child gets no standard input") + .script("BEGIN { n = (\"sort\" | getline line); print n \"[\" line \"]\" }") + .stdin("never seen by the child\n") + .expectLines("0[]") + .runAndAssert(); + } + + /** + * Runs the CLI in a fresh JVM with its standard input redirected from a + * file holding the given content, and returns the standard output with + * platform line separators normalized to {@code \n}. The script is passed + * through a file: an inline argument would not survive the Windows + * command-line round trip, which mangles embedded double quotes. + */ + private String runCliJvmWithStdin(String script, String stdinContent) throws Exception { + File stdinFile = tempFolder.newFile("stdin.txt"); + Files.write(stdinFile.toPath(), stdinContent.getBytes(StandardCharsets.UTF_8)); + File scriptFile = tempFolder.newFile("script.awk"); + Files.write(scriptFile.toPath(), script.getBytes(StandardCharsets.UTF_8)); + + String javaBinary = new File(new File(System.getProperty("java.home"), "bin"), "java").getAbsolutePath(); + File classes = new File(Cli.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + ProcessBuilder pb = new ProcessBuilder( + javaBinary, + "-cp", + classes.getAbsolutePath(), + Cli.class.getName(), + "-f", + scriptFile.getAbsolutePath()); + pb.redirectInput(stdinFile); + + Process process = pb.start(); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + try (InputStream out = process.getInputStream(); InputStream err = process.getErrorStream()) { + copy(out, stdout); + copy(err, stderr); + } + assertTrue("CLI JVM did not terminate", process.waitFor(30, TimeUnit.SECONDS)); + assertEquals( + "CLI JVM failed: " + stderr.toString("UTF-8"), + 0, + process.exitValue()); + return stdout.toString("UTF-8").replace("\r\n", "\n"); + } + + private static void copy(InputStream in, ByteArrayOutputStream sink) throws Exception { + byte[] buffer = new byte[8192]; + int n; + while ((n = in.read(buffer)) >= 0) { + sink.write(buffer, 0, n); + } + } +} From eb3a2393b7fa592dad3b67b8d7d3175cf1636b53 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 18 Aug 2026 15:35:48 +0200 Subject: [PATCH 2/4] Address review: harden stdin-inheritance guard and test plumbing - Capture the JVM's standard input when JRT is initialized and compare the bound input against that, so a stream installed later with System.setIn never qualifies for inheritance and the child's standard input fails closed instead of exposing the process's real descriptor 0. - Move the fresh-JVM CLI runner into AwkTestSupport, where every script-running test helper lives. - Redirect the child's standard output and error to files so no pipe can stall either side, enforce the timeout with waitFor alone, and kill the child when it expires. Co-Authored-By: Claude Fable 5 --- src/main/java/io/jawk/jrt/JRT.java | 25 ++++-- src/test/java/io/jawk/AwkTestSupport.java | 58 +++++++++++++ .../java/io/jawk/SpawnedProcessStdinTest.java | 84 ++++--------------- 3 files changed, 93 insertions(+), 74 deletions(-) diff --git a/src/main/java/io/jawk/jrt/JRT.java b/src/main/java/io/jawk/jrt/JRT.java index 1187218a..db8cb86c 100644 --- a/src/main/java/io/jawk/jrt/JRT.java +++ b/src/main/java/io/jawk/jrt/JRT.java @@ -130,6 +130,18 @@ public class JRT { * configured for the run, so that {@code getline < "/dev/stdin"} reads the * same data as the main input loop does when no operand is given. */ + /** + * The stream that was {@code System.in} when this class was initialized. In + * a CLI launch that is the standard input of the JVM process, the one thing + * {@code ProcessBuilder.Redirect.INHERIT} can lend to a child process. An + * embedder that replaces {@code System.in} via {@code System.setIn} before + * running Jawk installs a Java stream that no child can inherit, and + * comparing against the captured original makes that case fail closed: the + * replacement never matches, so the child's standard input stays closed + * instead of silently exposing the host's real descriptor 0. + */ + private static final InputStream PROCESS_STANDARD_INPUT = System.in; + private InputStream standardInput = System.in; /** * Sink writing to the standard error of the process, used by the @@ -3185,16 +3197,19 @@ private static Process spawnProcess(String cmd, boolean inheritStandardInput) th * and of a command pipe the same standard input as awk itself, which is how * terminal-aware commands like {@code "stty size" | getline} find the * controlling terminal. That is only faithful when Jawk reads the real - * standard input of the process: an embedded execution bound to a custom - * stream cannot lend that stream to another OS process, and handing over - * the host JVM's standard input instead would leak input the embedder never - * gave to Jawk, so there the child's standard input stays closed. + * standard input of the process — the captured + * {@link #PROCESS_STANDARD_INPUT}, not whatever {@code System.in} currently + * returns, so a stream installed with {@code System.setIn} never qualifies. + * An embedded execution bound to a custom stream cannot lend that stream to + * another OS process, and handing over the host JVM's standard input + * instead would leak input the embedder never gave to Jawk, so there the + * child's standard input stays closed. * * @return {@code true} when spawned processes inherit the JVM's standard * input */ private boolean spawnedProcessInheritsStandardInput() { - return standardInput == System.in; + return standardInput == PROCESS_STANDARD_INPUT; } /** diff --git a/src/test/java/io/jawk/AwkTestSupport.java b/src/test/java/io/jawk/AwkTestSupport.java index de6fda55..3ab4ea64 100644 --- a/src/test/java/io/jawk/AwkTestSupport.java +++ b/src/test/java/io/jawk/AwkTestSupport.java @@ -50,6 +50,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -138,6 +139,63 @@ public static Path sharedTempDirectory() { return SHARED_TEMP_DIR; } + /** + * Runs a script through the CLI in a freshly spawned JVM whose standard + * input is redirected from a file holding the given content, asserts that + * the run succeeds, and returns its standard output with platform line + * separators normalized to {@code \n}. + *

+ * The in-process builders cannot observe behavior that only exists across a + * real process boundary, such as the file-descriptor inheritance of spawned + * children; this helper exists for exactly those tests. The script travels + * through a file because Windows mangles embedded double quotes in inline + * command-line arguments, and the child's standard output and error are + * redirected to files so no pipe can fill up and stall either side: the + * timeout is enforced by {@code waitFor} alone, and an expired child is + * killed. + * + * @param description human readable description used in assertion messages + * @param script the AWK program to run + * @param stdinContent the bytes offered to the JVM as its standard input + * @return the standard output of the run, with {@code \r\n} normalized to + * {@code \n} + * @throws Exception when the JVM cannot be spawned or its output read + */ + public static String runCliInFreshJvm(String description, String script, String stdinContent) throws Exception { + Path directory = Files.createTempDirectory(SHARED_TEMP_DIR, "cli-jvm"); + Path stdinFile = directory.resolve("stdin.txt"); + Files.write(stdinFile, stdinContent.getBytes(StandardCharsets.UTF_8)); + Path scriptFile = directory.resolve("script.awk"); + Files.write(scriptFile, script.getBytes(StandardCharsets.UTF_8)); + Path stdoutFile = directory.resolve("stdout.txt"); + Path stderrFile = directory.resolve("stderr.txt"); + + String javaBinary = new File(new File(System.getProperty("java.home"), "bin"), "java").getAbsolutePath(); + File classes = new File(Cli.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + ProcessBuilder pb = new ProcessBuilder( + javaBinary, + "-cp", + classes.getAbsolutePath(), + Cli.class.getName(), + "-f", + scriptFile.toAbsolutePath().toString()); + pb.redirectInput(stdinFile.toFile()); + pb.redirectOutput(stdoutFile.toFile()); + pb.redirectError(stderrFile.toFile()); + + Process process = pb.start(); + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + process.waitFor(); + } + String stderr = new String(Files.readAllBytes(stderrFile), StandardCharsets.UTF_8); + assertEquals(description + ": CLI JVM timed out; stderr: " + stderr, Boolean.TRUE, Boolean.valueOf(finished)); + assertEquals(description + ": CLI JVM failed; stderr: " + stderr, 0, process.exitValue()); + String stdout = new String(Files.readAllBytes(stdoutFile), StandardCharsets.UTF_8); + return stdout.replace("\r\n", "\n"); + } + /** * Represents a fully configured test case produced by one of the builders. * Implementations know how to prepare the execution environment, run the diff --git a/src/test/java/io/jawk/SpawnedProcessStdinTest.java b/src/test/java/io/jawk/SpawnedProcessStdinTest.java index 8e847d14..40e806ef 100644 --- a/src/test/java/io/jawk/SpawnedProcessStdinTest.java +++ b/src/test/java/io/jawk/SpawnedProcessStdinTest.java @@ -23,17 +23,8 @@ */ import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.concurrent.TimeUnit; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; /** * Verifies that the children of {@code system()} and of a command input pipe @@ -41,29 +32,31 @@ * when Jawk reads the standard input of the JVM — and that embedded executions * bound to a custom stream keep the child's standard input closed. *

- * The inheritance can only be observed across a real process boundary, which - * the in-process builders of {@link AwkTestSupport} cannot provide, so the - * inheriting cases spawn the CLI in a fresh JVM whose standard input is - * redirected from a file. + * The inheritance can only be observed across a real process boundary, so the + * inheriting cases run through + * {@link AwkTestSupport#runCliInFreshJvm(String, String, String)}, which + * spawns the CLI in a fresh JVM whose standard input is redirected from a + * file. */ public class SpawnedProcessStdinTest { - @Rule - public TemporaryFolder tempFolder = new TemporaryFolder(); - @Test public void commandInputPipeChildReadsJawkStandardInput() throws Exception { - String output = runCliJvmWithStdin( - "BEGIN { \"sort\" | getline line; print \"[\" line \"]\" }", - "hello\n"); + String output = AwkTestSupport + .runCliInFreshJvm( + "cmd|getline child reads Jawk stdin", + "BEGIN { \"sort\" | getline line; print \"[\" line \"]\" }", + "hello\n"); assertEquals("[hello]\n", output); } @Test public void systemChildReadsJawkStandardInput() throws Exception { - String output = runCliJvmWithStdin( - "BEGIN { system(\"sort\") }", - "zulu\nalpha\n"); + String output = AwkTestSupport + .runCliInFreshJvm( + "system() child reads Jawk stdin", + "BEGIN { system(\"sort\") }", + "zulu\nalpha\n"); assertEquals("alpha\nzulu\n", output); } @@ -78,51 +71,4 @@ public void embeddedExecutionKeepsChildStandardInputClosed() throws Exception { .expectLines("0[]") .runAndAssert(); } - - /** - * Runs the CLI in a fresh JVM with its standard input redirected from a - * file holding the given content, and returns the standard output with - * platform line separators normalized to {@code \n}. The script is passed - * through a file: an inline argument would not survive the Windows - * command-line round trip, which mangles embedded double quotes. - */ - private String runCliJvmWithStdin(String script, String stdinContent) throws Exception { - File stdinFile = tempFolder.newFile("stdin.txt"); - Files.write(stdinFile.toPath(), stdinContent.getBytes(StandardCharsets.UTF_8)); - File scriptFile = tempFolder.newFile("script.awk"); - Files.write(scriptFile.toPath(), script.getBytes(StandardCharsets.UTF_8)); - - String javaBinary = new File(new File(System.getProperty("java.home"), "bin"), "java").getAbsolutePath(); - File classes = new File(Cli.class.getProtectionDomain().getCodeSource().getLocation().toURI()); - ProcessBuilder pb = new ProcessBuilder( - javaBinary, - "-cp", - classes.getAbsolutePath(), - Cli.class.getName(), - "-f", - scriptFile.getAbsolutePath()); - pb.redirectInput(stdinFile); - - Process process = pb.start(); - ByteArrayOutputStream stdout = new ByteArrayOutputStream(); - ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - try (InputStream out = process.getInputStream(); InputStream err = process.getErrorStream()) { - copy(out, stdout); - copy(err, stderr); - } - assertTrue("CLI JVM did not terminate", process.waitFor(30, TimeUnit.SECONDS)); - assertEquals( - "CLI JVM failed: " + stderr.toString("UTF-8"), - 0, - process.exitValue()); - return stdout.toString("UTF-8").replace("\r\n", "\n"); - } - - private static void copy(InputStream in, ByteArrayOutputStream sink) throws Exception { - byte[] buffer = new byte[8192]; - int n; - while ((n = in.read(buffer)) >= 0) { - sink.write(buffer, 0, n); - } - } } From 2dd51dc371155e8fc0609941f538eb8fa20fdf44 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 18 Aug 2026 15:49:33 +0200 Subject: [PATCH 3/4] Address review: assert stdin eligibility from the process entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No capture of System.in can establish that Jawk reads the standard input of the JVM process: an embedder may replace the stream with System.setIn at any point, including before JRT initializes. The eligibility is now carried explicitly from Cli.main, the one caller that can vouch for it, through a JRT setter. Every other path — embedded API runs, tests, JSR-223 — leaves the flag unset, so the children of system() and of command pipes keep a closed standard input there. Co-Authored-By: Claude Fable 5 --- src/main/java/io/jawk/Cli.java | 7 +++++ src/main/java/io/jawk/jrt/JRT.java | 46 ++++++++++++++++-------------- src/site/markdown/java-output.md | 9 +++--- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/main/java/io/jawk/Cli.java b/src/main/java/io/jawk/Cli.java index 7399b92e..43d4e01e 100644 --- a/src/main/java/io/jawk/Cli.java +++ b/src/main/java/io/jawk/Cli.java @@ -92,6 +92,7 @@ public final class Cli { private File compileOutputFile; private boolean printUsage; private boolean sandbox; + private boolean processStandardInput; private boolean disableOptimize; private boolean profiling; private File profilingOutputFile; @@ -503,6 +504,7 @@ private void executeProgram(Awk awk, AwkProgram program, File memoryFile) throws avm.setAwkSink(sink); avm.setErrorStream(err); avm.setWarningStream(err); + avm.getJrt().setSpawnedProcessesInheritStandardInput(processStandardInput); if (memoryFile != null) { restorePersistentMemoryIfPresent(avm, memoryFile); } @@ -719,6 +721,11 @@ public static Cli create(String[] args, InputStream is, PrintStream os, PrintStr public static void main(String[] args) { try { Cli cli = new Cli(); + // Only the process entry point can vouch that the standard input + // this CLI reads is the standard input of the JVM itself, the one + // stream a spawned child can inherit; embedders and tests reach + // run() through other paths and never set this. + cli.processStandardInput = true; cli.parse(args); cli.run(); } catch (ExitException e) { diff --git a/src/main/java/io/jawk/jrt/JRT.java b/src/main/java/io/jawk/jrt/JRT.java index db8cb86c..73cb333d 100644 --- a/src/main/java/io/jawk/jrt/JRT.java +++ b/src/main/java/io/jawk/jrt/JRT.java @@ -130,19 +130,9 @@ public class JRT { * configured for the run, so that {@code getline < "/dev/stdin"} reads the * same data as the main input loop does when no operand is given. */ - /** - * The stream that was {@code System.in} when this class was initialized. In - * a CLI launch that is the standard input of the JVM process, the one thing - * {@code ProcessBuilder.Redirect.INHERIT} can lend to a child process. An - * embedder that replaces {@code System.in} via {@code System.setIn} before - * running Jawk installs a Java stream that no child can inherit, and - * comparing against the captured original makes that case fail closed: the - * replacement never matches, so the child's standard input stays closed - * instead of silently exposing the host's real descriptor 0. - */ - private static final InputStream PROCESS_STANDARD_INPUT = System.in; - private InputStream standardInput = System.in; + + private boolean spawnedProcessesInheritStandardInput; /** * Sink writing to the standard error of the process, used by the * {@code /dev/stderr} special filename; created on first use and discarded @@ -3192,24 +3182,38 @@ private static Process spawnProcess(String cmd, boolean inheritStandardInput) th } /** - * Tells whether processes spawned on behalf of the script share the + * Declares whether processes spawned on behalf of the script share the * standard input of this JVM. POSIX gives the children of {@code system()} * and of a command pipe the same standard input as awk itself, which is how * terminal-aware commands like {@code "stty size" | getline} find the * controlling terminal. That is only faithful when Jawk reads the real - * standard input of the process — the captured - * {@link #PROCESS_STANDARD_INPUT}, not whatever {@code System.in} currently - * returns, so a stream installed with {@code System.setIn} never qualifies. - * An embedded execution bound to a custom stream cannot lend that stream to - * another OS process, and handing over the host JVM's standard input - * instead would leak input the embedder never gave to Jawk, so there the - * child's standard input stays closed. + * standard input of the process, which no capture of {@code System.in} can + * establish — an embedder may have replaced the stream with + * {@code System.setIn} at any point, including before this class + * initializes — so eligibility is asserted explicitly by the one caller + * that can vouch for it: the command-line entry point of the process. + * Everywhere else the flag stays {@code false} and the child's standard + * input is closed, since a Java stream cannot be lent to another OS + * process, and exposing the host JVM's real descriptor 0 instead would + * leak input the embedder never gave to Jawk. + * + * @param inherit {@code true} when the standard input this run reads is + * the standard input of the JVM process itself + */ + public void setSpawnedProcessesInheritStandardInput(boolean inherit) { + this.spawnedProcessesInheritStandardInput = inherit; + } + + /** + * Tells whether processes spawned on behalf of the script share the + * standard input of this JVM, as declared through + * {@link #setSpawnedProcessesInheritStandardInput(boolean)}. * * @return {@code true} when spawned processes inherit the JVM's standard * input */ private boolean spawnedProcessInheritsStandardInput() { - return standardInput == PROCESS_STANDARD_INPUT; + return spawnedProcessesInheritStandardInput; } /** diff --git a/src/site/markdown/java-output.md b/src/site/markdown/java-output.md index 5ad4b0f3..634f2f3f 100644 --- a/src/site/markdown/java-output.md +++ b/src/site/markdown/java-output.md @@ -231,10 +231,11 @@ console rather than mixing with normal output. Subprocess **stdin** follows POSIX in CLI runs: the children of `system("...")` and of a command input pipe (`"cmd" | getline`) inherit the standard input of the JVM, so stdin filters and terminal-aware commands (`"stty size" | getline`) work as they do under gawk. -In embedded runs that bind a custom input stream via `input(...)`, the child's standard -input is closed instead: a Java stream cannot be lent to another OS process, and the -host's real standard input is never handed to the script's children. The child of an -output pipe (`print ... | "cmd"`) always reads the pipe itself as its standard input. +In embedded runs — any execution not started through the `jawk` command line — the +child's standard input is closed instead: a Java stream cannot be lent to another OS +process, and the host's real standard input is never handed to the script's children. +The child of an output pipe (`print ... | "cmd"`) always reads the pipe itself as its +standard input. ## See Also From 786cd0cf93339eaa26b1c1e9d00de47cf969e248 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 18 Aug 2026 16:00:04 +0200 Subject: [PATCH 4/4] Address review: make main's process-entry contract explicit There is no Java-side check that can distinguish the launcher-installed System.in from a replacement installed with System.setIn, so the standard-input inheritance declared by Cli.main is documented as part of its contract: main carries process semantics, and code that replaces System.in must use create(), the constructors, or the Awk API, where spawned children always get a closed standard input. The one programmatic Cli.main call in the test suite now goes through create() accordingly. Co-Authored-By: Claude Fable 5 --- src/main/java/io/jawk/Cli.java | 13 +++++++++++++ src/test/java/io/jawk/AwkTest.java | 10 +++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/jawk/Cli.java b/src/main/java/io/jawk/Cli.java index 43d4e01e..2c235f3e 100644 --- a/src/main/java/io/jawk/Cli.java +++ b/src/main/java/io/jawk/Cli.java @@ -715,6 +715,19 @@ public static Cli create(String[] args, InputStream is, PrintStream os, PrintStr /** * Entry point for the command-line interface. + *

+ * This method carries process semantics: because it is what the + * {@code jawk} launch of the JVM runs, it declares that the standard input + * the CLI reads is the standard input of the process, and the children of + * {@code system()} and of command pipes may inherit it, as POSIX requires. + * No Java-side check can distinguish the launcher-installed + * {@code System.in} from a replacement installed with + * {@code System.setIn}, so that declaration is part of this method's + * contract: code that replaces {@code System.in} must not call + * {@code main} programmatically — embedders and tests go through + * {@link #create(String[], InputStream, PrintStream, PrintStream)}, the + * constructors, or the {@link Awk} API, where spawned children always get + * a closed standard input instead. * * @param args command-line arguments */ diff --git a/src/test/java/io/jawk/AwkTest.java b/src/test/java/io/jawk/AwkTest.java index 999e6bf1..b4762708 100644 --- a/src/test/java/io/jawk/AwkTest.java +++ b/src/test/java/io/jawk/AwkTest.java @@ -1594,7 +1594,15 @@ public void loadSerializedProgram() throws Exception { @Test public void compileTuplesViaCLI() throws Exception { File tmp = File.createTempFile("jawk", ".tpl"); - Cli.main(new String[] { "-K", tmp.getAbsolutePath(), "{ print toupper($0) }" }); + // Cli.main is reserved for the process launch (its children may inherit + // the JVM's standard input); programmatic callers go through create() + Cli + .create( + new String[] + { "-K", tmp.getAbsolutePath(), "{ print toupper($0) }" }, + new ByteArrayInputStream(new byte[0]), + System.out, + System.err); Cli cli = Cli.parseCommandLineArguments(new String[] { "-L", tmp.getAbsolutePath() });