You've already forked goreleaser
mirror of
https://github.com/goreleaser/goreleaser.git
synced 2026-06-19 23:24:39 +02:00
fix(nodesea): adhoc sign with quill
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
This commit is contained in:
@@ -5,10 +5,9 @@
|
||||
// once per host into the user cache) and invokes `node --build-sea
|
||||
// sea-config.json` against the per-target Node binary GoReleaser
|
||||
// fetches from https://nodejs.org/dist (verifying SHA-256). On macOS
|
||||
// targets the produced Mach-O is ad-hoc signed via codesign(1); when
|
||||
// codesign is unavailable (cross-compile from non-darwin hosts) the
|
||||
// binary is left unsigned and must be re-signed via the `signs:` pipe
|
||||
// before it will execute on macOS.
|
||||
// targets the produced Mach-O is ad-hoc signed via quill (pure-Go) so
|
||||
// it loads on Apple Silicon out of the box; users with a Developer ID
|
||||
// can layer real signing on top via the signs: pipe.
|
||||
//
|
||||
// Concurrent builds are enabled — each target runs --build-sea against
|
||||
// its own scratch directory and outputs to its own path; nothing is
|
||||
|
||||
@@ -3,7 +3,6 @@ package nodesea
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -65,7 +64,7 @@ type BuildOptions struct {
|
||||
SEAConfig SEAConfig
|
||||
|
||||
// CodeSignID is the ad-hoc CMS signing identifier applied to
|
||||
// darwin outputs. Empty → derived from filepath.Base(OutPath).
|
||||
// darwin outputs by quill. Empty → derived from filepath.Base(OutPath).
|
||||
CodeSignID string
|
||||
}
|
||||
|
||||
@@ -75,11 +74,11 @@ type BuildOptions struct {
|
||||
// cached per-target Node binary downloaded for opts.Version+opts.Target.
|
||||
//
|
||||
// On darwin targets the resulting Mach-O is ad-hoc CMS-signed via
|
||||
// codesign(1) before it lands at OutPath. When codesign(1) is not on
|
||||
// PATH (typical when cross-compiling for darwin from non-darwin hosts),
|
||||
// the binary is left unsigned: it is well-formed but the macOS kernel
|
||||
// will refuse to exec it until it is re-signed (e.g. via goreleaser's
|
||||
// signs: pipe on a darwin runner).
|
||||
// quill (pure-Go) before it lands at OutPath, so the macOS kernel will
|
||||
// exec the binary on Apple Silicon without further action. Real
|
||||
// Developer ID signing and notarization are layered on top via the
|
||||
// signs: and notarize: pipes — quill strips the ad-hoc signature
|
||||
// before re-signing.
|
||||
//
|
||||
// OutPath is written atomically: --build-sea generates into a sibling
|
||||
// tempfile, signing happens in place on the temp, then a rename
|
||||
@@ -129,8 +128,8 @@ func BuildViaBuildSEA(ctx context.Context, opts BuildOptions) error {
|
||||
base := filepath.Base(opts.OutPath)
|
||||
id = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
if err := adHocSignMachO(ctx, tmpOut, id); err != nil && !errors.Is(err, ErrCodeSignUnavailable) {
|
||||
return fmt.Errorf("nodesea: ad-hoc sign: %w", err)
|
||||
if err := signMachO(tmpOut, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +1,36 @@
|
||||
package nodesea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/goreleaser/quill/quill"
|
||||
)
|
||||
|
||||
// ErrCodeSignUnavailable is returned by adHocSignMachO when codesign(1)
|
||||
// is not present on PATH (e.g. cross-compiling a darwin SEA from a
|
||||
// linux host). Callers may treat this as a soft failure: the produced
|
||||
// binary is well-formed but kernel-rejected on macOS until re-signed.
|
||||
var ErrCodeSignUnavailable = errors.New("nodesea: codesign(1) not available; output left unsigned")
|
||||
|
||||
// codeSignBinary names the executable used to ad-hoc sign Mach-O
|
||||
// outputs. Variable so tests can stub or skip it.
|
||||
// signMachO ad-hoc signs the Mach-O at path with identifier id using
|
||||
// quill (pure-Go Mach-O signer). Works on any host OS — no codesign(1)
|
||||
// dependency, so cross-compiling darwin SEAs from linux/windows hosts
|
||||
// produces a kernel-loadable binary.
|
||||
//
|
||||
// `node --build-sea` leaves a placeholder LC_CODE_SIGNATURE pointing at
|
||||
// end-of-file with no signature bytes appended. quill's signSingleBinary
|
||||
// calls RemoveSigningContent before signing, so the placeholder is
|
||||
// stripped and replaced with a valid ad-hoc CMS superblob.
|
||||
//
|
||||
// Ad-hoc only — no developer cert involved. Users with a Developer ID
|
||||
// can layer real signing on top via the signs: pipe; quill removes the
|
||||
// ad-hoc signature first there too.
|
||||
//
|
||||
// Variable so tests can stub without a real Mach-O fixture.
|
||||
//
|
||||
//nolint:gochecknoglobals
|
||||
var codeSignBinary = "codesign"
|
||||
|
||||
// runCodeSign is the executor for codesign(1). Variable so tests can
|
||||
// record argv without invoking the real binary.
|
||||
//
|
||||
//nolint:gochecknoglobals
|
||||
var runCodeSign = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, codeSignBinary, args...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// adHocSignMachO applies an ad-hoc signature to the Mach-O at path via
|
||||
// `codesign --sign - --force --identifier <id> <path>`.
|
||||
//
|
||||
// We use codesign(1) (Apple's signer) because `node --build-sea`
|
||||
// produces a Mach-O with a placeholder LC_CODE_SIGNATURE load command
|
||||
// that points at end-of-file with no signature bytes appended; an
|
||||
// in-process signer that does not recognize that placeholder will
|
||||
// corrupt the layout. codesign(1) handles this correctly.
|
||||
//
|
||||
// When codesign(1) is not on PATH (typical on non-darwin build hosts
|
||||
// cross-compiling for darwin), this returns ErrCodeSignUnavailable
|
||||
// without modifying path. Callers that need a runnable binary on macOS
|
||||
// must re-sign via goreleaser's signs: pipe on a darwin runner.
|
||||
func adHocSignMachO(ctx context.Context, path, id string) error {
|
||||
if _, err := exec.LookPath(codeSignBinary); err != nil {
|
||||
return ErrCodeSignUnavailable
|
||||
}
|
||||
out, err := runCodeSign(ctx, "--sign", "-", "--force", "--identifier", id, path)
|
||||
var signMachO = func(path, id string) error {
|
||||
cfg, err := quill.NewSigningConfigFromPEMs(path, "", "", "", false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("codesign %s: %w (output: %s)", path, err, strings.TrimSpace(string(out)))
|
||||
return fmt.Errorf("nodesea: quill config for %s: %w", path, err)
|
||||
}
|
||||
cfg.WithIdentity(id)
|
||||
if err := quill.Sign(*cfg); err != nil {
|
||||
return fmt.Errorf("nodesea: ad-hoc sign %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package nodesea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,76 +9,42 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAdHocSignMachO_CodesignMissing(t *testing.T) {
|
||||
// Force lookups to miss by emptying PATH.
|
||||
t.Setenv("PATH", filepath.Join(t.TempDir(), "definitely-empty"))
|
||||
func TestSignMachO_RecordsArgs(t *testing.T) {
|
||||
prev := signMachO
|
||||
t.Cleanup(func() { signMachO = prev })
|
||||
|
||||
err := adHocSignMachO(t.Context(), "/some/path", "id")
|
||||
require.ErrorIs(t, err, ErrCodeSignUnavailable)
|
||||
}
|
||||
|
||||
func TestAdHocSignMachO_RecordsArgv(t *testing.T) {
|
||||
// Stage a fake codesign on PATH so LookPath succeeds.
|
||||
bin := filepath.Join(t.TempDir(), "codesign")
|
||||
require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755))
|
||||
t.Setenv("PATH", filepath.Dir(bin))
|
||||
|
||||
prev := runCodeSign
|
||||
t.Cleanup(func() { runCodeSign = prev })
|
||||
|
||||
var got []string
|
||||
runCodeSign = func(_ context.Context, args ...string) ([]byte, error) {
|
||||
got = args
|
||||
return nil, nil
|
||||
var gotPath, gotID string
|
||||
signMachO = func(path, id string) error {
|
||||
gotPath, gotID = path, id
|
||||
return nil
|
||||
}
|
||||
|
||||
require.NoError(t, adHocSignMachO(t.Context(), "/path/to/bin", "my.bundle.id"))
|
||||
require.Equal(t,
|
||||
[]string{"--sign", "-", "--force", "--identifier", "my.bundle.id", "/path/to/bin"},
|
||||
got)
|
||||
require.NoError(t, signMachO("/path/to/bin", "my.bundle.id"))
|
||||
require.Equal(t, "/path/to/bin", gotPath)
|
||||
require.Equal(t, "my.bundle.id", gotID)
|
||||
}
|
||||
|
||||
func TestAdHocSignMachO_CodesignFailure(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "codesign")
|
||||
require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755))
|
||||
t.Setenv("PATH", filepath.Dir(bin))
|
||||
func TestSignMachO_PropagatesError(t *testing.T) {
|
||||
prev := signMachO
|
||||
t.Cleanup(func() { signMachO = prev })
|
||||
|
||||
prev := runCodeSign
|
||||
t.Cleanup(func() { runCodeSign = prev })
|
||||
|
||||
runCodeSign = func(_ context.Context, _ ...string) ([]byte, error) {
|
||||
return []byte("codesign: bad bag o' bits"), errors.New("exit status 1")
|
||||
signMachO = func(string, string) error {
|
||||
return errors.New("quill: bad bag o' bits")
|
||||
}
|
||||
|
||||
err := adHocSignMachO(t.Context(), "/p", "id")
|
||||
err := signMachO("/p", "id")
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrCodeSignUnavailable)
|
||||
require.Contains(t, err.Error(), "bad bag o' bits")
|
||||
}
|
||||
|
||||
// TestBuildViaBuildSEA_Darwin_NoCodesign verifies the "cross-compile from
|
||||
// linux for darwin" fallback: when codesign(1) is missing the build
|
||||
// completes successfully and leaves the binary unsigned.
|
||||
func TestBuildViaBuildSEA_Darwin_NoCodesign(t *testing.T) {
|
||||
t.Setenv("PATH", filepath.Join(t.TempDir(), "no-codesign-here"))
|
||||
// TestSignMachO_RealQuill exercises the live quill signer on a tiny
|
||||
// fake non-Mach-O file to confirm the wiring fails with a real quill
|
||||
// error (not a panic) when the input isn't a valid Mach-O. Ad-hoc
|
||||
// signing of a real Mach-O is covered by quill's own test suite.
|
||||
func TestSignMachO_RealQuill(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "not-a-macho")
|
||||
require.NoError(t, os.WriteFile(bin, []byte("not a mach-o file"), 0o644))
|
||||
|
||||
const version = "v22.20.0"
|
||||
target := Target("darwin-arm64")
|
||||
stageTargetNode(t, version, target)
|
||||
|
||||
mainPath := filepath.Join(t.TempDir(), "main.js")
|
||||
require.NoError(t, os.WriteFile(mainPath, []byte(`console.log("ok");`), 0o644))
|
||||
|
||||
outPath := filepath.Join(t.TempDir(), "myapp")
|
||||
stubRunBuildSEA(t, nil)
|
||||
|
||||
require.NoError(t, BuildViaBuildSEA(t.Context(), BuildOptions{
|
||||
BuildToolNode: "/fake/node",
|
||||
Target: target,
|
||||
Version: version,
|
||||
MainJS: mainPath,
|
||||
OutPath: outPath,
|
||||
}))
|
||||
|
||||
require.FileExists(t, outPath)
|
||||
err := signMachO(bin, "id")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
// sea-config.json`. That command injects the SEA blob into a copy of
|
||||
// the per-target Node binary GoReleaser fetches from
|
||||
// https://nodejs.org/dist (verifying SHA-256). On macOS targets the
|
||||
// produced binary is ad-hoc signed via codesign(1) so the kernel
|
||||
// loader will accept it. The package owns the cache layout, the
|
||||
// download + verify path, and the `--build-sea` orchestration.
|
||||
// produced binary is ad-hoc signed via quill (pure-Go, host-OS
|
||||
// independent) so the kernel loader will accept it. The package owns
|
||||
// the cache layout, the download + verify path, and the `--build-sea`
|
||||
// orchestration.
|
||||
package nodesea
|
||||
|
||||
// Format identifies the container format of a Node.js host binary.
|
||||
|
||||
Reference in New Issue
Block a user