This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch release-manager in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 8384030fc7c05caeeabbc98ca66850f5f359ee7e Author: James Bognar <[email protected]> AuthorDate: Tue Aug 18 13:39:18 2026 -0400 Harden GitHub/GPG credential validators; fix the untested-credential status pill (a) GithubTokenValidator/GpgValidator no longer echo gh/gpg's raw subprocess output into the API response on failure -- output is inspected only to classify into a fixed set of reason strings. The success path now checks the returned GitHub login against GitHub's username grammar before displaying it, for the same reason. Closes an unbounded channel from a third-party tool's stderr into interface text, not a demonstrated leak. (b) AccountStore now narrows its accounts file (and directory) to owner-only POSIX permissions after each write. The file holds no secret (availid and GPG key ID are public identifiers) -- this is hygiene against the file landing world-readable under a default umask, not a vulnerability fix. Best-effort; silently skipped on non-POSIX filesystems. (c) Credentials tab: a credential that is stored but never validated used to share .pill.invalid's red styling with an actually-failed one, incorrectly implying the app knew it was bad. Gives that state its own amber .pill.untested treatment. Bundled with (a) and (b) as three lines in the same credentials feature area rather than as a standalone commit. --- .../juneau/releng/credential/AccountStore.java | 38 +++++++ .../releng/credential/GithubTokenValidator.java | 42 +++++++- .../juneau/releng/credential/GpgValidator.java | 44 +++++++- src/main/resources/static/css/chrome.css | 7 ++ src/main/resources/templates/credentials.ftlh | 22 +++- .../juneau/releng/credential/AccountStoreTest.java | 40 +++++++ .../credential/GithubTokenValidatorTest.java | 120 +++++++++++++++++++++ .../juneau/releng/credential/GpgValidatorTest.java | 52 +++++++++ 8 files changed, 355 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/apache/juneau/releng/credential/AccountStore.java b/src/main/java/org/apache/juneau/releng/credential/AccountStore.java index 4ebdecf3a2..af826da7af 100644 --- a/src/main/java/org/apache/juneau/releng/credential/AccountStore.java +++ b/src/main/java/org/apache/juneau/releng/credential/AccountStore.java @@ -22,8 +22,10 @@ import static org.apache.juneau.commons.utils.Shorts.*; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; import java.util.Optional; import java.util.Properties; import org.apache.juneau.commons.secret.SecretStore; @@ -82,8 +84,44 @@ public class AccountStore { try (OutputStream out = Files.newOutputStream(file)) { props.store(out, "Juneau Release Manager - non-secret account identifiers (availid / GPG key ID)"); } + restrictPermissions(); } catch (IOException e) { throw isex(e, "Cannot save accounts file: %s", file); } } + + /** + * Narrows the accounts file and its directory to the owner. + * + * <p>This file holds no secret — an availid and a GPG key ID are public identifiers — so this is + * hygiene rather than a fix for a vulnerability. It is worth doing anyway because the alternative is whatever + * the process umask happens to be, which on a default umask means world-readable: a state directory that + * enumerates which Apache account this machine releases as, readable by every account on the host. Setting it + * explicitly also means the file does not sit at different permissions depending on how the app was launched. + * + * <p>Applied after the write rather than via {@code createFile} attributes so that an existing file created + * before this change is narrowed too, instead of keeping its original mode forever. + * + * <p>Silently skipped where POSIX permissions do not apply. A non-POSIX filesystem is not a reason to fail a + * credential save, and this is a hardening step on a non-secret file rather than a control something depends + * on — nothing here is load-bearing enough to justify refusing to persist the user's availid. + */ + @SuppressWarnings({ + "resource" // FileSystems.getDefault() returns the JVM-wide default filesystem singleton; it must not be closed (its close() throws UnsupportedOperationException), so there is no resource to release. + }) + private void restrictPermissions() { + var dir = file.getParent(); + if (!FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) + return; + trySetPermissions(dir, "rwx------"); + trySetPermissions(file, "rw-------"); + } + + private static void trySetPermissions(Path path, String mode) { + try { + Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(mode)); + } catch (IOException | UnsupportedOperationException e) { + // See restrictPermissions: best-effort by design. + } + } } diff --git a/src/main/java/org/apache/juneau/releng/credential/GithubTokenValidator.java b/src/main/java/org/apache/juneau/releng/credential/GithubTokenValidator.java index c94f8c8d7a..4326292807 100644 --- a/src/main/java/org/apache/juneau/releng/credential/GithubTokenValidator.java +++ b/src/main/java/org/apache/juneau/releng/credential/GithubTokenValidator.java @@ -19,11 +19,22 @@ package org.apache.juneau.releng.credential; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; import org.apache.juneau.releng.util.ProcessRunner; -/** Validates a GitHub token via {@code GH_TOKEN=<token> gh api user}. */ +/** + * Validates a GitHub token via {@code GH_TOKEN=<token> gh api user}. + * + * <p>Neither path carries {@code gh}'s raw output into the message. On failure the output is read only to choose + * among fixed reasons; on success the login is shown, but only after it is checked against GitHub's own username + * rules, so an unexpected line from {@code gh} cannot become interface text. See {@link GpgValidator} for why an + * unbounded channel from a third-party tool's stderr into the UI is worth closing even absent a demonstrated leak. + */ public class GithubTokenValidator implements Validator { + /** GitHub usernames: alphanumerics and single inner hyphens, 39 characters at most. */ + private static final Pattern LOGIN = Pattern.compile("[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}"); + private final ProcessRunner runner; public GithubTokenValidator(ProcessRunner runner) { @@ -33,7 +44,32 @@ public class GithubTokenValidator implements Validator { @Override public ValidationResult validate(String token, String account) { var r = runner.run(List.of("gh", "api", "user", "-q", ".login"), null, Map.of("GH_TOKEN", token)); - return r.ok() ? ValidationResult.ok("GitHub token OK (user " + r.output().strip() + ")") - : ValidationResult.fail("GitHub token rejected: " + r.output().strip()); + if (!r.ok()) + return ValidationResult.fail(reason(r)); + var login = r.output() == null ? "" : r.output().strip(); + return ValidationResult.ok(LOGIN.matcher(login).matches() ? "GitHub token OK (user " + login + ")." + : "GitHub token OK."); + } + + /** + * The enumerated reason the token was not accepted. + * + * <p>{@code gh} reports the HTTP status in its output rather than in its exit code, so the output is inspected + * to classify — and then discarded rather than displayed. + */ + private static String reason(ProcessRunner.ProcResult r) { + var out = r.output() == null ? "" : r.output().toLowerCase(); + if (r.exitCode() == 127 || out.contains("command not found") || out.contains("no such file or directory")) + return "gh is not installed or not on the PATH."; + if (out.contains("bad credentials") || out.contains("401")) + return "GitHub rejected the token (401 — not accepted)."; + if (out.contains("403") || out.contains("insufficient") || out.contains("scope")) + return "The token was accepted but lacks the required scope (403)."; + if (out.contains("404")) + return "GitHub returned 404 for the identity call — the token may be for a different account type."; + if (out.contains("could not resolve") || out.contains("connection refused") || out.contains("timeout") + || out.contains("dial tcp") || out.contains("network is unreachable")) + return "GitHub was unreachable — check the network."; + return "GitHub rejected the token (gh exit code " + r.exitCode() + ")."; } } diff --git a/src/main/java/org/apache/juneau/releng/credential/GpgValidator.java b/src/main/java/org/apache/juneau/releng/credential/GpgValidator.java index cf0bd9ce39..1b9d737d2e 100644 --- a/src/main/java/org/apache/juneau/releng/credential/GpgValidator.java +++ b/src/main/java/org/apache/juneau/releng/credential/GpgValidator.java @@ -20,7 +20,16 @@ package org.apache.juneau.releng.credential; import java.util.List; import org.apache.juneau.releng.util.ProcessRunner; -/** Validates a GPG key + passphrase: key must exist, and a discard test-sign must succeed. */ +/** + * Validates a GPG key + passphrase: key must exist, and a discard test-sign must succeed. + * + * <p>Failure messages are drawn from a fixed set and never include {@code gpg}'s own output. That output is a + * third-party program's stderr heading for a JSON response, the credential card and a table column, and while + * {@code gpg} is not known to echo a passphrase it was given, a rule that secrets stay out of the interface cannot + * rest on another program's discretion about what it prints. The exit code is included because it is a bounded + * integer and is the one detail that distinguishes otherwise identical failures; anyone needing more can run the + * command themselves. + */ public class GpgValidator implements Validator { private final ProcessRunner runner; @@ -33,12 +42,39 @@ public class GpgValidator implements Validator { public ValidationResult validate(String passphrase, String keyId) { var present = runner.run(List.of("gpg", "--list-secret-keys", keyId), null, null); if (!present.ok()) - return ValidationResult.fail("No secret key for " + keyId); + return ValidationResult.fail(missingTool(present) ? "gpg is not installed or not on the PATH." + : "No secret key for " + keyId + "."); // Test-sign a tiny payload; passphrase on stdin, signature discarded. var sign = runner.run(List.of("gpg", "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-fd", "0", "--local-user", keyId, "--sign", "--output", "/dev/null", "-"), passphrase + "\n", null); - return sign.ok() ? ValidationResult.ok("GPG key " + keyId + " signs OK") - : ValidationResult.fail("GPG test-sign failed (bad passphrase?): " + sign.output().strip()); + return sign.ok() ? ValidationResult.ok("GPG key " + keyId + " signs OK.") : ValidationResult.fail(reason(sign)); + } + + /** + * The enumerated reason a test-sign failed. + * + * <p>{@code gpg}'s output is read here to choose among fixed strings, and is not carried into any of them. + */ + private static String reason(ProcessRunner.ProcResult r) { + if (missingTool(r)) + return "gpg is not installed or not on the PATH."; + var out = r.output() == null ? "" : r.output().toLowerCase(); + if (out.contains("bad passphrase") || out.contains("bad session key")) + return "The passphrase was rejected by gpg."; + if (out.contains("no secret key") || out.contains("no such key")) + return "gpg has no secret key for that key ID."; + if (out.contains("pinentry") || out.contains("inappropriate ioctl")) + return "gpg could not read the passphrase non-interactively (pinentry)."; + if (out.contains("expired")) + return "The signing key has expired."; + if (out.contains("revoked")) + return "The signing key has been revoked."; + return "gpg refused to sign (exit code " + r.exitCode() + ")."; + } + + private static boolean missingTool(ProcessRunner.ProcResult r) { + var out = r.output() == null ? "" : r.output().toLowerCase(); + return r.exitCode() == 127 || out.contains("command not found") || out.contains("no such file or directory"); } } diff --git a/src/main/resources/static/css/chrome.css b/src/main/resources/static/css/chrome.css index 3678e78a81..c0e98b27a0 100644 --- a/src/main/resources/static/css/chrome.css +++ b/src/main/resources/static/css/chrome.css @@ -408,3 +408,10 @@ div.dt-container .dt-paging .dt-paging-button:not(.current):not(.disabled):hover .pill.valid { background: var(--jc-tag-green-bg); color: var(--jc-tag-green-text); } .pill.invalid { background: #fdeceb; color: var(--jc-danger); } .pill.unset { background: var(--jc-tag-neutral-bg); color: var(--jc-tag-neutral-text); } + +/* Stored but never tested. Four states need four treatments: this one used to borrow .pill.invalid, which + asserted the credential was bad when the app had no idea. Erring away from green is right -- an untested + credential must never read as a pass -- but claiming a failure is the same error pointed the other way, and it + sends the user to re-enter a password that was probably fine. Amber, matching chrome.css's existing amber + group, is the "unknown" treatment. */ +.pill.untested { background: var(--jc-tag-amber-bg); color: var(--jc-tag-amber-text); } diff --git a/src/main/resources/templates/credentials.ftlh b/src/main/resources/templates/credentials.ftlh index e6543bd3aa..3cd21d906e 100644 --- a/src/main/resources/templates/credentials.ftlh +++ b/src/main/resources/templates/credentials.ftlh @@ -20,10 +20,26 @@ <div class="jc-card"> <#list credentials as c> <div class="cred" data-name="${c.name}"> + <#-- + Four states, four treatments. "Stored but never tested" used to share the red `invalid` class, which + claimed the credential was bad when the app had no idea and sent the user off to re-enter a password + that was probably fine. It must not read as a pass either -- hence amber rather than green. + + lastValid is a boxed Boolean whose null means "never validated", so the untested branch tests for + absence (!c.lastValid??) and must come before the branches that read the value. Validation results + are held in memory only, so a restart correctly returns every credential here. + --> + <#if !c.present> + <#assign pillClass = 'unset'><#assign pillLabel = 'Not set'> + <#elseif !c.lastValid??> + <#assign pillClass = 'untested'><#assign pillLabel = 'Stored — not tested'> + <#elseif c.lastValid> + <#assign pillClass = 'valid'><#assign pillLabel = 'Valid'> + <#else> + <#assign pillClass = 'invalid'><#assign pillLabel = 'Invalid'> + </#if> <h3>${c.label} - <span class="pill ${c.present?string(c.lastValid!false?string('valid','invalid'),'unset')}"> - <#if !c.present>Not set<#elseif c.lastValid??>${c.lastValid?string('Valid','Invalid')}<#else>Set (unvalidated)</#if> - </span> + <span class="pill ${pillClass}">${pillLabel}</span> </h3> <div class="row"> <#if c.name != 'github'> diff --git a/src/test/java/org/apache/juneau/releng/credential/AccountStoreTest.java b/src/test/java/org/apache/juneau/releng/credential/AccountStoreTest.java index 336fac4f80..276042bff0 100644 --- a/src/test/java/org/apache/juneau/releng/credential/AccountStoreTest.java +++ b/src/test/java/org/apache/juneau/releng/credential/AccountStoreTest.java @@ -18,8 +18,12 @@ package org.apache.juneau.releng.credential; import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; class AccountStoreTest { @@ -73,4 +77,40 @@ class AccountStoreTest { store.put(CredentialSpec.APACHE_LDAP, "jbognar"); assertTrue(nested.resolve("accounts.properties").toFile().isFile()); } + + // ----------------------------------------------------------------------------------------------------------- + // Explicit permissions rather than whatever the umask happens to be (finding F4). + // ----------------------------------------------------------------------------------------------------------- + + @Test + @EnabledOnOs({ OS.MAC, OS.LINUX }) + void accountsFileIsOwnerOnly(@TempDir Path dir) throws Exception { + // The file holds no secret, so this is hygiene -- but under a default umask it lands world-readable, and + // it names the Apache account this machine releases as. + new AccountStore(dir).put(CredentialSpec.APACHE_LDAP, "jbognar"); + var perms = Files.getPosixFilePermissions(dir.resolve("accounts.properties")); + assertEquals(PosixFilePermissions.fromString("rw-------"), perms); + } + + @Test + @EnabledOnOs({ OS.MAC, OS.LINUX }) + void stateDirIsOwnerOnly(@TempDir Path dir) throws Exception { + var nested = dir.resolve("nested/state"); + new AccountStore(nested).put(CredentialSpec.GPG, "ABCD1234"); + assertEquals(PosixFilePermissions.fromString("rwx------"), Files.getPosixFilePermissions(nested)); + } + + @Test + @EnabledOnOs({ OS.MAC, OS.LINUX }) + void anExistingWideOpenFileIsNarrowedOnTheNextWrite(@TempDir Path dir) throws Exception { + // A file written before this change keeps its mode unless something narrows it, and the natural mistake is + // to set permissions only at creation time. + var store = new AccountStore(dir); + store.put(CredentialSpec.APACHE_LDAP, "jbognar"); + var file = dir.resolve("accounts.properties"); + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-rw-rw-")); + + store.put(CredentialSpec.GPG, "ABCD1234"); + assertEquals(PosixFilePermissions.fromString("rw-------"), Files.getPosixFilePermissions(file)); + } } diff --git a/src/test/java/org/apache/juneau/releng/credential/GithubTokenValidatorTest.java b/src/test/java/org/apache/juneau/releng/credential/GithubTokenValidatorTest.java new file mode 100644 index 0000000000..d99556a323 --- /dev/null +++ b/src/test/java/org/apache/juneau/releng/credential/GithubTokenValidatorTest.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.juneau.releng.credential; + +import static org.apache.juneau.commons.utils.Shorts.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; + +import org.apache.juneau.releng.util.ProcessRunner; +import org.junit.jupiter.api.Test; + +/** + * {@link GithubTokenValidator}: the token travels by environment variable rather than argv, and {@code gh}'s own + * output does not reach the message on either path (finding F2 of + * {@code .work/specs/2026-08-18-console-credentials-surface.md}). + */ +class GithubTokenValidatorTest { + + static class StubRunner implements ProcessRunner { + final ProcResult result; + List<String> command; + Map<String,String> env; + + StubRunner(ProcResult result) { + this.result = result; + } + + @Override public List<String> runLines(List<String> c) { throw uoex(); } + @Override public String runText(List<String> c) { throw uoex(); } + + @Override + public ProcResult run(List<String> c, String stdin, Map<String,String> e) { + command = c; + env = e; + return result; + } + } + + private static String message(int exitCode, String output) { + return new GithubTokenValidator(new StubRunner(new ProcessRunner.ProcResult(exitCode, output))) + .validate("ghp_s3cret", "token").message(); + } + + @Test + void tokenTravelsByEnvironmentAndNotArgv() { + var runner = new StubRunner(new ProcessRunner.ProcResult(0, "octocat\n")); + assertTrue(new GithubTokenValidator(runner).validate("ghp_s3cret", "token").valid()); + assertEquals("ghp_s3cret", runner.env.get("GH_TOKEN")); + assertFalse(runner.command.contains("ghp_s3cret"), "token must never appear in argv"); + } + + @Test + void successReportsAWellFormedLogin() { + assertEquals("GitHub token OK (user octocat).", message(0, "octocat\n")); + } + + @Test + void successWithUnexpectedOutputOmitsIt() { + // The success path echoed gh's stdout too. It is normally the login, but "normally" is not a rule, so the + // value is shown only when it looks like a GitHub username and dropped otherwise. + assertEquals("GitHub token OK.", message(0, "octocat\nwarning: something unexpected")); + assertEquals("GitHub token OK.", message(0, "not a login: has spaces and punctuation!")); + assertEquals("GitHub token OK.", message(0, "")); + } + + @Test + void subprocessOutputNeverReachesTheFailureMessage() { + var sentinel = "SENTINEL-b41e07-DO-NOT-SURFACE"; + for (var output : List.of(sentinel, "gh: 401 " + sentinel, "HTTP 403: " + sentinel)) + assertFalse(message(1, output).contains(sentinel), + () -> "gh output leaked into the UI message for: " + output); + } + + @Test + void theSuccessPathIsBoundedByGithubsUsernameGrammar() { + // The success path echoed gh's stdout too, and the bound on it is a grammar rather than a blanket refusal: + // a value that GitHub could actually have issued as a username is displayed, because showing which account + // the token belongs to is the useful half of the message. + // + // The grammar is the bound. 39 characters of [A-Za-z0-9-] cannot carry a multi-line stderr dump, a + // passphrase with punctuation, or markup -- and a GitHub token does not fit it either (ghp_ tokens are 40 + // characters and contain an underscore). Anything outside it is dropped entirely rather than truncated, + // since a truncated diagnostic is not worth the channel it travels on. + assertEquals("GitHub token OK.", message(0, "gh: warning: something unexpected happened")); + assertEquals("GitHub token OK.", message(0, "octocat\ngh: extra line")); + assertEquals("GitHub token OK.", message(0, "a".repeat(40))); + assertEquals("GitHub token OK.", message(0, "has spaces and punctuation!")); + assertEquals("GitHub token OK.", message(0, "ghp_0123456789abcdef0123456789abcdef0123")); + assertEquals("GitHub token OK (user octo-cat9).", message(0, "octo-cat9\n")); + } + + @Test + void failureReasonsAreEnumerated() { + assertEquals("GitHub rejected the token (401 \u2014 not accepted).", message(1, "gh: Bad credentials")); + assertEquals("The token was accepted but lacks the required scope (403).", message(1, "HTTP 403: Forbidden")); + assertEquals("GitHub was unreachable \u2014 check the network.", message(1, "dial tcp: connection refused")); + assertEquals("gh is not installed or not on the PATH.", message(127, "gh: command not found")); + } + + @Test + void unrecognizedFailureCarriesTheExitCodeAndNothingElse() { + assertEquals("GitHub rejected the token (gh exit code 9).", message(9, "something entirely unexpected")); + } +} diff --git a/src/test/java/org/apache/juneau/releng/credential/GpgValidatorTest.java b/src/test/java/org/apache/juneau/releng/credential/GpgValidatorTest.java index 1cca23be63..e9e071f171 100644 --- a/src/test/java/org/apache/juneau/releng/credential/GpgValidatorTest.java +++ b/src/test/java/org/apache/juneau/releng/credential/GpgValidatorTest.java @@ -81,4 +81,56 @@ class GpgValidatorTest { var result = new GpgValidator(runner).validate("wrong", "ABCD1234"); assertFalse(result.valid()); } + + // ----------------------------------------------------------------------------------------------------------- + // Bounded failure messages: gpg's own output must not reach the message (finding F2). + // ----------------------------------------------------------------------------------------------------------- + + private static String signFailureMessage(int exitCode, String output) { + var runner = new RecordingRunner( + List.of(new ProcessRunner.ProcResult(0, "sec ...\n"), new ProcessRunner.ProcResult(exitCode, output))); + var r = new GpgValidator(runner).validate("wrong", "ABCD1234"); + assertFalse(r.valid()); + return r.message(); + } + + @Test + void subprocessOutputNeverReachesTheMessage() { + // The regression guard for F2, in the shape SecretsOffArgvTest uses: a sentinel that must not appear. The + // sentinel stands in for anything gpg might print -- and the reason this matters is not that gpg is known + // to echo a passphrase, but that the message is an unbounded channel from another program's stderr into a + // JSON response, the credential card and a table column. + var sentinel = "SENTINEL-a7f3c9-DO-NOT-SURFACE"; + for (var output : List.of(sentinel, "gpg: signing failed: " + sentinel, "bad passphrase\n" + sentinel)) + assertFalse(signFailureMessage(2, output).contains(sentinel), + () -> "gpg output leaked into the UI message for: " + output); + } + + @Test + void failureReasonsAreEnumerated() { + assertEquals("The passphrase was rejected by gpg.", signFailureMessage(2, "gpg: Bad passphrase")); + assertEquals("gpg could not read the passphrase non-interactively (pinentry).", + signFailureMessage(2, "gpg: problem with pinentry")); + assertEquals("The signing key has expired.", signFailureMessage(2, "gpg: key has expired")); + assertEquals("The signing key has been revoked.", signFailureMessage(2, "gpg: key was revoked")); + assertEquals("gpg is not installed or not on the PATH.", signFailureMessage(127, "gpg: command not found")); + } + + @Test + void unrecognizedFailureCarriesTheExitCodeAndNothingElse() { + // The fallback still has to be actionable, and an exit code is a bounded integer rather than free text. + assertEquals("gpg refused to sign (exit code 42).", signFailureMessage(42, "something entirely unexpected")); + } + + @Test + void missingToolIsDistinguishedFromMissingKey() { + // Both fail the first gpg call, and telling the user "no secret key for X" when gpg is not installed sends + // them to generate a key they may already have. + var absent = new RecordingRunner(List.of(new ProcessRunner.ProcResult(127, "gpg: command not found"))); + assertEquals("gpg is not installed or not on the PATH.", + new GpgValidator(absent).validate("s3cret", "ABCD1234").message()); + + var noKey = new RecordingRunner(List.of(new ProcessRunner.ProcResult(2, "gpg: error reading key"))); + assertEquals("No secret key for NOPE.", new GpgValidator(noKey).validate("s3cret", "NOPE").message()); + } }
