diff --git a/.gitea/workflows/build-linux.yml b/.gitea/workflows/build-linux.yml index f59b1e8c..ccc57767 100644 --- a/.gitea/workflows/build-linux.yml +++ b/.gitea/workflows/build-linux.yml @@ -11,7 +11,16 @@ name: linux appimage (gitea self-hosted) # # Dispatch-only. The runner is capacity 1 and shared with the fleet gates. -on: [workflow_dispatch] +on: + workflow_dispatch: + inputs: + expected_sha: + description: Exact reviewed Builds commit + required: true + type: string + +env: + EXPECTED_SHA: ${{ inputs.expected_sha }} jobs: appimage: @@ -39,6 +48,10 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + ref: ${{ github.sha }} + + - name: Verify exact reviewed source + run: python3 -B contrib/release/check_dispatch.py - name: fetch wownerod to embed run: | @@ -154,41 +167,3 @@ jobs: name: wowlet-x86_64-linux-AppImage path: wowlet-x86_64.AppImage if-no-files-found: error - - - name: publish the AppImage to the tag's release - # The artifact above uploads fine but this Gitea's REST listing reports - # zero artifacts for every run, so a tag build's output cannot be fetched - # back through the API. Release assets can, so publish there as well. - # Fail-safe by design: with no usable token this says why and exits 0, - # it never turns a green build red. - if: startsWith(github.ref, 'refs/tags/') - env: - RELEASE_TOKEN: ${{ github.token }} - run: | - set -eu - FILE=wowlet-x86_64.AppImage - TAG="${GITHUB_REF#refs/tags/}" - API="${GITHUB_SERVER_URL:-https://git.such.software}/api/v1/repos/$GITHUB_REPOSITORY" - if [ -z "${RELEASE_TOKEN:-}" ]; then - echo "no token available to this runner, skipping the release upload" >&2 - exit 0 - fi - H="Authorization: token $RELEASE_TOKEN" - # Single-line python on purpose: a continuation line at column 0 would - # end this block scalar and break the workflow's YAML. `|| true` keeps - # a malformed response from aborting the step under `set -e`. - rid=$(curl -sS -H "$H" "$API/releases/tags/$TAG" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) - if [ -z "$rid" ]; then - rid=$(curl -sS -H "$H" -H "Content-Type: application/json" -X POST \ - -d "{\"tag_name\":\"$TAG\",\"name\":\"wowlet $TAG\"}" "$API/releases" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) - fi - if [ -z "$rid" ]; then - echo "could not resolve or create a release for $TAG, skipping the upload" >&2 - exit 0 - fi - code=$(curl -sS -H "$H" -o /dev/null -w '%{http_code}' -X POST \ - -F "attachment=@$FILE" "$API/releases/$rid/assets?name=$(basename "$FILE")") - echo "release asset upload returned HTTP $code" - if [ "$code" != "201" ]; then - echo "the release upload did not return 201; the run artifact still holds the build" >&2 - fi diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index 6b8b9c52..260eebac 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -14,7 +14,16 @@ name: macos build (gitea self-hosted) # APPLE_API_ISSUER, APPLE_API_KEY, APPLE_API_KEY_P8_BASE64 (notarytool). # If APPLE_CERTIFICATE is unset the build ships unsigned instead of failing. -on: [workflow_dispatch] +on: + workflow_dispatch: + inputs: + expected_sha: + description: Exact reviewed Builds commit + required: true + type: string + +env: + EXPECTED_SHA: ${{ inputs.expected_sha }} jobs: build-macos: @@ -31,13 +40,21 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + ref: ${{ github.sha }} - - name: install dependencies (brew) + - name: Verify exact reviewed source + run: python3 -B contrib/release/check_dispatch.py + + - name: Verify Fleet-managed build dependencies run: | + set -e export PATH="/opt/homebrew/bin:$PATH" - set +o pipefail # yes(1) takes SIGPIPE when brew has nothing to ask - export NONINTERACTIVE=1 HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_INSTALL_UPGRADE=1 - yes | brew install --quiet qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf pkgconfig zxing-cpp + for formula in qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf zxing-cpp; do + brew --prefix "$formula" >/dev/null || { + echo "Missing Fleet-managed build dependency: $formula" >&2 + exit 1 + } + done - name: fetch wownerod to embed run: | @@ -184,47 +201,8 @@ jobs: ls -la "$FW" | grep -iE "webp|sharpyuv|brotli" || true ls -la dist/ - - name: sign and notarize (skipped when secrets absent) - run: | - set -e - cd build - if [ -z "$APPLE_CERTIFICATE" ]; then - echo "APPLE_CERTIFICATE not set — shipping UNSIGNED build" - exit 0 - fi - KC=/tmp/wowlet-ci.keychain-db - KCPW=$(/usr/bin/uuidgen) - echo "$KCPW" > /tmp/wowlet-ci-kcpw # for the cleanup step only - security create-keychain -p "$KCPW" "$KC" - security set-keychain-settings -lut 3600 "$KC" - security unlock-keychain -p "$KCPW" "$KC" - printf '%s' "$APPLE_CERTIFICATE" | base64 -d > /tmp/dev_id.p12 - security import /tmp/dev_id.p12 -k "$KC" -P "$APPLE_CERTIFICATE_PASSWORD" \ - -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -s -k "$KCPW" "$KC" >/dev/null - security list-keychains -d user -s "$KC" login.keychain-db - - # Sign inside-out: every Mach-O NOT inside a .framework (embedded - # wownerod/tor, the bundled webp/sharpyuv/brotli dylibs, and the - # image-format plugins whose signatures install_name_tool just - # invalidated — keyed on `file`, not exec bit, since plugins/dylibs - # aren't +x), then each Qt framework as a unit, then the bundle. - SIGN() { codesign --force --options runtime --timestamp --keychain "$KC" \ - --sign "$APPLE_SIGNING_IDENTITY" "$@"; } - find dist/Wowlet.app -type f ! -path "*/*.framework/*" \ - | while read -r f; do file "$f" | grep -q Mach-O && SIGN "$f" || true; done - find dist/Wowlet.app/Contents/Frameworks -maxdepth 1 -name "*.framework" 2>/dev/null \ - | while read -r fw; do SIGN "$fw"; done - SIGN dist/Wowlet.app - codesign --verify --deep --strict dist/Wowlet.app - - printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 -d > /tmp/notary.p8 - ditto -c -k --keepParent dist/Wowlet.app /tmp/wowlet-notarize.zip - xcrun notarytool submit /tmp/wowlet-notarize.zip \ - --key /tmp/notary.p8 --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \ - --wait --timeout 30m - xcrun stapler staple dist/Wowlet.app - echo "signed + notarized + stapled" + - name: Sign and notarize the private candidate + run: python3 -B contrib/release/sign_macos.py - name: zip artifact run: | @@ -233,14 +211,6 @@ jobs: ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).zip ls -la ../wowlet-macos-*.zip - - name: cleanup signing material - if: always() - run: | - if [ -f /tmp/wowlet-ci-kcpw ]; then - security delete-keychain /tmp/wowlet-ci.keychain-db || true - fi - rm -f /tmp/dev_id.p12 /tmp/notary.p8 /tmp/wowlet-ci-kcpw /tmp/wowlet-notarize.zip - - name: upload artifact uses: actions/upload-artifact@v3 with: @@ -250,44 +220,3 @@ jobs: # Without this an empty upload passes silently, which is how a signed # build can appear to publish while shipping nothing. if-no-files-found: error - - - name: publish the signed zip to the tag's release - # Same reason as the Linux job: run artifacts upload but this Gitea's - # REST listing returns zero for every run, so the notarized zip cannot - # be fetched back through the API. Release assets can be. Fail-safe: - # with no usable token this says why and exits 0. - if: startsWith(github.ref, 'refs/tags/') - env: - RELEASE_TOKEN: ${{ github.token }} - run: | - set -eu - FILE=$(ls build/wowlet-macos-*.zip 2>/dev/null | head -1) - if [ -z "$FILE" ]; then - echo "no macOS zip to publish; the packaging step should have failed first" >&2 - exit 0 - fi - TAG="${GITHUB_REF#refs/tags/}" - API="${GITHUB_SERVER_URL:-https://git.such.software}/api/v1/repos/$GITHUB_REPOSITORY" - if [ -z "${RELEASE_TOKEN:-}" ]; then - echo "no token available to this runner, skipping the release upload" >&2 - exit 0 - fi - H="Authorization: token $RELEASE_TOKEN" - # Single-line python on purpose: a continuation line at column 0 would - # end this block scalar and break the workflow's YAML. `|| true` keeps - # a malformed response from aborting the step under `set -e`. - rid=$(curl -sS -H "$H" "$API/releases/tags/$TAG" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) - if [ -z "$rid" ]; then - rid=$(curl -sS -H "$H" -H "Content-Type: application/json" -X POST \ - -d "{\"tag_name\":\"$TAG\",\"name\":\"wowlet $TAG\"}" "$API/releases" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) - fi - if [ -z "$rid" ]; then - echo "could not resolve or create a release for $TAG, skipping the upload" >&2 - exit 0 - fi - code=$(curl -sS -H "$H" -o /dev/null -w '%{http_code}' -X POST \ - -F "attachment=@$FILE" "$API/releases/$rid/assets?name=$(basename "$FILE")") - echo "release asset upload returned HTTP $code" - if [ "$code" != "201" ]; then - echo "the release upload did not return 201; the run artifact still holds the build" >&2 - fi diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml index 655590e4..4639ec2f 100644 --- a/.gitea/workflows/build-windows.yml +++ b/.gitea/workflows/build-windows.yml @@ -20,9 +20,16 @@ name: windows build + sign (gitea self-hosted) # # Dispatch-only. Both runners are capacity 1 and shared with other lanes. -on: [workflow_dispatch] +on: + workflow_dispatch: + inputs: + expected_sha: + description: Exact reviewed Builds commit + required: true + type: string env: + EXPECTED_SHA: ${{ inputs.expected_sha }} HOST: x86_64-w64-mingw32 jobs: @@ -52,6 +59,10 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + ref: ${{ github.sha }} + + - name: Verify exact reviewed source + run: python3 -B contrib/release/check_dispatch.py # depends is the slow part (static Qt6/boost/openssl/tor from source). # Cache the source tarballs and the compiled packages so only a cold run @@ -270,47 +281,3 @@ jobs: wowlet-x86_64-windows.zip wowlet-x86_64-windows-unsigned.zip if-no-files-found: error - - - name: publish the Windows zips to the tag's release - # Same reason as the Linux and macOS jobs: artifacts upload fine, but - # this Gitea's REST listing reports zero for every run, so the signed exe - # cannot be fetched back through the API. Release assets can be. - # - # This runs after the Authenticode, timestamp and signer checks above, so - # an unsigned or untimestamped binary never reaches a release. curl.exe, - # not Invoke-WebRequest: `shell: powershell` is Windows PowerShell 5.1, - # whose Invoke-WebRequest has no -Form for multipart uploads. - # - # Fail-safe: a build that produced a correctly signed binary must not go - # red because publishing failed, so every bad path reports and exits 0. - if: startsWith(github.ref, 'refs/tags/') - shell: powershell - env: - RELEASE_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference = 'Continue' - if (-not $env:RELEASE_TOKEN) { - Write-Host "no token available to this runner, skipping the release upload" - exit 0 - } - $tag = $env:GITHUB_REF -replace '^refs/tags/', '' - $server = if ($env:GITHUB_SERVER_URL) { $env:GITHUB_SERVER_URL } else { 'https://git.such.software' } - $api = "$server/api/v1/repos/$env:GITHUB_REPOSITORY" - $auth = "Authorization: token $env:RELEASE_TOKEN" - $rid = '' - try { $rid = ((curl.exe -sS -H $auth "$api/releases/tags/$tag") | ConvertFrom-Json).id } catch { $rid = '' } - if (-not $rid) { - try { - $body = "{""tag_name"":""$tag"",""name"":""wowlet $tag""}" - $rid = ((curl.exe -sS -H $auth -H "Content-Type: application/json" -X POST -d $body "$api/releases") | ConvertFrom-Json).id - } catch { $rid = '' } - } - if (-not $rid) { - Write-Host "could not resolve or create a release for $tag, skipping the upload" - exit 0 - } - foreach ($f in @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) { - if (-not (Test-Path $f)) { Write-Host "missing $f, nothing to publish for it"; continue } - $code = curl.exe -sS -o NUL -w '%{http_code}' -H $auth -F "attachment=@$f" "$api/releases/$rid/assets?name=$f" - Write-Host "release asset upload for $f returned HTTP $code" - } diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 00000000..1926043d --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,15 @@ +name: Wowlet verify + +on: + push: + branches: [master] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Check support guides and release source admission + run: make check diff --git a/Makefile b/Makefile index 24613684..570b554d 100644 --- a/Makefile +++ b/Makefile @@ -15,5 +15,6 @@ default: build check: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/docs/tests -v + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/release/tests -v .PHONY: default build attest verify clean check diff --git a/contrib/release/check_dispatch.py b/contrib/release/check_dispatch.py new file mode 100644 index 00000000..669e3aff --- /dev/null +++ b/contrib/release/check_dispatch.py @@ -0,0 +1,22 @@ +"""Refuse a build whose resolved source differs from its reviewed request.""" +import os +import re +import subprocess + + +def check(expected, reported, checked_out): + if not re.fullmatch(r"[0-9a-f]{40}", expected or ""): + raise ValueError("expected_sha must identify the exact reviewed commit") + if reported != expected: + raise ValueError("provider run head differs from expected_sha") + if checked_out != expected: + raise ValueError("checked-out source differs from expected_sha") + + +if __name__ == "__main__": + head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + try: + check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head) + except ValueError as error: + raise SystemExit(str(error)) from None + print("Exact requested build source verified") diff --git a/contrib/release/sign_macos.py b/contrib/release/sign_macos.py new file mode 100644 index 00000000..9a651c4a --- /dev/null +++ b/contrib/release/sign_macos.py @@ -0,0 +1,150 @@ +"""Sign one private candidate without placing passwords in process arguments. + +Apple SecurityTool reads one quoted operation from stdin in interactive mode: +https://github.com/apple-oss-distributions/SecurityTool/blob/main/security.c +Output stays captured because native diagnostics may repeat supplied input. +""" +import base64 +import json +import os +from pathlib import Path +import secrets +import subprocess +import tempfile + + +FIELDS = ( + "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY", + "APPLE_API_ISSUER", "APPLE_API_KEY", "APPLE_API_KEY_P8_BASE64", +) + + +def argument(value): + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError("Keychain input contains an unsupported control character") + return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' + + +def child_environment(): + return {key: os.environ[key] for key in ("HOME", "PATH", "TMPDIR", "LANG", "DEVELOPER_DIR") if key in os.environ} + + +def security(operation, *values): + if operation not in { + "create-keychain", "set-keychain-settings", "unlock-keychain", "import", + "set-key-partition-list", "delete-keychain", + }: + raise ValueError("Keychain operation is outside candidate signing") + command = " ".join(argument(value) for value in (operation, *values)) + "\n" + if len(command.encode()) > 1024: + raise ValueError("Keychain input exceeds the bounded operation size") + result = subprocess.run( + ["/usr/bin/security", "-i", "-q"], input=command, + text=True, capture_output=True, env=child_environment(), timeout=90, + ) + if result.returncode: + # Never forward diagnostics or operation data. Report recognized causes. + causes = { + "MAC verification failed": "PKCS12 encoding or password verification failed", + "User interaction is not allowed": "keychain requires interactive access", + "already exists": "ephemeral keychain or imported identity already exists", + "could not be found": "requested keychain item was not found", + } + cause = next((cause for marker, cause in causes.items() if marker in result.stderr), + "native keychain operation returned an error") + raise RuntimeError(f"{operation}: {cause} (exit {result.returncode}); raw diagnostics withheld") + + +def run(arguments, *, timeout=120): + result = subprocess.run(arguments, text=True, capture_output=True, + env=child_environment(), timeout=timeout) + if result.returncode: + causes = { + "no identity found": "expected signing identity was not found", + "User interaction is not allowed": "signing key requires interactive access", + "errSecInternalComponent": "keychain denied access to the signing identity", + "not signed at all": "candidate contains an unsigned component", + } + cause = next((cause for marker, cause in causes.items() if marker in result.stderr), + "native signing operation returned an error") + raise RuntimeError(f"{Path(arguments[0]).name}: {cause} (exit {result.returncode}); raw diagnostics withheld") + return result.stdout + + +def private_file(path, content): + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as stream: + stream.write(content) + + +def main(): + credentials = {name: os.environ.get(name, "") for name in FIELDS} + missing = [name for name, value in credentials.items() if not value] + if missing: + raise ValueError("Signing input is missing: " + ", ".join(missing)) + if "Such Software" not in credentials["APPLE_SIGNING_IDENTITY"]: + raise ValueError("Developer ID identity does not name the expected publisher") + app = Path("build/dist/Wowlet.app").resolve() + if not app.is_dir(): + raise ValueError("Packaged Wowlet.app is missing") + old_umask = os.umask(0o077) + try: + with tempfile.TemporaryDirectory(prefix=".wowlet-signing-", dir=app.parent) as temporary: + workspace = Path(temporary) + keychain = workspace / "candidate.keychain-db" + certificate = workspace / "identity.p12" + notary_key = workspace / "notary.p8" + private_file(certificate, base64.b64decode(credentials["APPLE_CERTIFICATE"])) + private_file(notary_key, base64.b64decode(credentials["APPLE_API_KEY_P8_BASE64"])) + password = secrets.token_hex(32) + created = False + try: + security("create-keychain", "-p", password, str(keychain)) + created = True + security("set-keychain-settings", "-lut", "3600", str(keychain)) + security("unlock-keychain", "-p", password, str(keychain)) + security("import", str(certificate), "-k", str(keychain), "-P", + credentials["APPLE_CERTIFICATE_PASSWORD"], "-T", "/usr/bin/codesign") + security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s", + "-k", password, str(keychain)) + + def sign(path): + run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp", + "--keychain", str(keychain), "--sign", + credentials["APPLE_SIGNING_IDENTITY"], str(path)]) + + for path in sorted(app.rglob("*")): + if path.is_file() and not any(part.endswith(".framework") for part in path.parts): + if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]): + sign(path) + frameworks = app / "Contents/Frameworks" + for path in sorted(frameworks.glob("*.framework")): + sign(path) + sign(app) + run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)]) + archive = workspace / "candidate.zip" + run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)]) + raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive), + "--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"], + "--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m", + "--output-format", "json"], timeout=1900) + response = json.loads(raw) + if response.get("status") != "Accepted": + raise RuntimeError("Apple notarization did not return Accepted") + run(["/usr/bin/xcrun", "stapler", "staple", str(app)]) + run(["/usr/bin/xcrun", "stapler", "validate", str(app)]) + print("Developer ID signature verified; notarization accepted; staple validated") + finally: + if created: + security("delete-keychain", str(keychain)) + finally: + os.umask(old_umask) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error: + # These exceptions contain logical fields, native result classes or + # public file paths. Captured child output is never included. + raise SystemExit(str(error)) from None diff --git a/contrib/release/tests/test_dispatch.py b/contrib/release/tests/test_dispatch.py new file mode 100644 index 00000000..77053708 --- /dev/null +++ b/contrib/release/tests/test_dispatch.py @@ -0,0 +1,20 @@ +import importlib.util +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location("dispatch", Path(__file__).parents[1] / "check_dispatch.py") +dispatch = importlib.util.module_from_spec(spec) +spec.loader.exec_module(dispatch) + + +class DispatchTests(unittest.TestCase): + def test_exact_source_is_required_before_building(self): + sha = "a" * 40 + dispatch.check(sha, sha, sha) + for expected, reported, checked_out in ( + (None, sha, sha), ("master", sha, sha), + (sha, "b" * 40, sha), (sha, sha, "b" * 40), + ): + with self.subTest(expected=expected, reported=reported, checked_out=checked_out): + with self.assertRaises(ValueError): + dispatch.check(expected, reported, checked_out) diff --git a/contrib/release/tests/test_sign_macos.py b/contrib/release/tests/test_sign_macos.py new file mode 100644 index 00000000..6d8511ce --- /dev/null +++ b/contrib/release/tests/test_sign_macos.py @@ -0,0 +1,39 @@ +import importlib.util +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("sign_macos", Path(__file__).parents[1] / "sign_macos.py") +signing = importlib.util.module_from_spec(spec) +spec.loader.exec_module(signing) + + +class MacSigningTests(unittest.TestCase): + def test_password_uses_private_input_and_diagnostics_never_echo_it(self): + synthetic = 'synthetic "quoted" value \\ with spaces' + captured = [] + + def execute(arguments, **options): + captured.append((arguments, options)) + return SimpleNamespace(returncode=1, stdout=synthetic, + stderr="MAC verification failed " + synthetic) + + with patch.object(signing.subprocess, "run", execute): + with self.assertRaises(RuntimeError) as caught: + signing.security("import", "candidate.p12", "-P", synthetic) + self.assertNotIn(synthetic, str(caught.exception)) + arguments, options = captured[0] + self.assertNotIn(synthetic, " ".join(arguments)) + self.assertIn("quoted", options["input"]) + self.assertTrue(options["capture_output"]) + self.assertNotIn("APPLE_CERTIFICATE_PASSWORD", options["env"]) + + def test_control_characters_and_unrelated_keychain_operations_refuse(self): + with patch.object(signing.subprocess, "run") as execute: + for value in ("line\nbreak", "nul\x00byte", "carriage\rreturn"): + with self.assertRaises(ValueError): + signing.security("unlock-keychain", "-p", value, "candidate.keychain") + with self.assertRaises(ValueError): + signing.security("dump-keychain", "login.keychain") + execute.assert_not_called()