diff --git a/go.mod b/go.mod index 792ccbd..bf739af 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,10 @@ require ( github.com/google/go-github/v35 v35.3.0 github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-hclog v0.16.1 + github.com/jimeh/undent v1.1.0 github.com/mattn/go-isatty v0.0.13 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/stretchr/testify v1.6.1 // indirect + github.com/stretchr/testify v1.7.0 github.com/urfave/cli/v2 v2.3.0 golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e // indirect golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect diff --git a/go.sum b/go.sum index a22249a..66a4802 100644 --- a/go.sum +++ b/go.sum @@ -117,6 +117,8 @@ github.com/hashicorp/go-hclog v0.16.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jimeh/undent v1.1.0 h1:Cge7P4Ws6buy0SVuHBluY/aOKdFuJUMzoJswfAHZ4zE= +github.com/jimeh/undent v1.1.0/go.mod h1:oxYCIzdbyQNy8GXnCnjRJ2NS6Uq4p4yWoeawiGFqoHI= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -136,6 +138,8 @@ github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rhysd/go-fakeio v1.0.0 h1:+TjiKCOs32dONY7DaoVz/VPOdvRkPfBkEyUDIpM8FQY= +github.com/rhysd/go-fakeio v1.0.0/go.mod h1:joYxF906trVwp2JLrE4jlN7A0z6wrz8O6o1UjarbFzE= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -144,8 +148,9 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index befee09..c0b84e3 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -45,6 +45,7 @@ func New(version, commit, date string) *CLI { }, Commands: []*cli2.Command{ planCmd(), + signCmd(), { Name: "version", Usage: "print the version", diff --git a/pkg/cli/sign.go b/pkg/cli/sign.go new file mode 100644 index 0000000..cd54876 --- /dev/null +++ b/pkg/cli/sign.go @@ -0,0 +1,114 @@ +package cli + +import ( + "os" + "path/filepath" + + "github.com/jimeh/build-emacs-for-macos/pkg/plan" + "github.com/jimeh/build-emacs-for-macos/pkg/sign" + cli2 "github.com/urfave/cli/v2" +) + +func signCmd() *cli2.Command { + return &cli2.Command{ + Name: "sign", + Usage: "sign a Emacs.app bundle with codesign", + ArgsUsage: "", + Flags: []cli2.Flag{ + &cli2.StringFlag{ + Name: "sign", + Aliases: []string{"s"}, + Usage: "signing identity passed to codeside", + EnvVars: []string{"AC_SIGN_IDENTITY"}, + Required: true, + }, + &cli2.StringSliceFlag{ + Name: "entitlements", + Aliases: []string{"e"}, + Usage: "comma-separated list of entitlements to enable", + Value: cli2.NewStringSlice(sign.DefaultEmacsEntitlements...), + }, + &cli2.BoolFlag{ + Name: "deep", + Aliases: []string{"d"}, + Usage: "pass --deep to codesign", + Value: true, + }, + &cli2.BoolFlag{ + Name: "timestamp", + Aliases: []string{"t"}, + Usage: "pass --timestamp to codesign", + Value: true, + }, + &cli2.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "pass --force to codesign", + Value: true, + }, + &cli2.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "pass --verbose to codesign", + Value: false, + }, + &cli2.StringSliceFlag{ + Name: "options", + Aliases: []string{"o"}, + Usage: "options passed to codesign", + Value: cli2.NewStringSlice("runtime"), + }, + &cli2.StringFlag{ + Name: "codesign", + Usage: "specify custom path to codesign executable", + }, + &cli2.StringFlag{ + Name: "plan", + Usage: "path to build plan YAML file produced by " + + "emacs-builder plan", + Aliases: []string{"p"}, + EnvVars: []string{"EMACS_BUILDER_PLAN"}, + TakesFile: true, + }, + }, + Action: actionWrapper(signAction), + } +} + +func signAction(c *cli2.Context, opts *Options) error { + signOpts := &sign.Options{ + Identity: c.String("sign"), + Options: c.StringSlice("options"), + Deep: c.Bool("deep"), + Timestamp: c.Bool("timestamp"), + Force: c.Bool("force"), + Verbose: c.Bool("verbose"), + CodeSignCmd: c.String("codesign"), + } + + if v := c.StringSlice("entitlements"); len(v) > 0 { + e := sign.Entitlements(v) + signOpts.Entitlements = &e + } + + if !opts.quiet { + signOpts.Output = os.Stdout + } + + app := c.Args().Get(0) + + if f := c.String("plan"); f != "" { + p, err := plan.Load(f) + if err != nil { + return err + } + + if p.Output != nil && p.Build != nil { + app = filepath.Join( + p.Output.Directory, p.Build.Name, "Emacs.app", + ) + } + } + + return sign.Emacs(c.Context, app, signOpts) +} diff --git a/pkg/sign/emacs.go b/pkg/sign/emacs.go new file mode 100644 index 0000000..a5e0398 --- /dev/null +++ b/pkg/sign/emacs.go @@ -0,0 +1,159 @@ +package sign + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/go-hclog" +) + +// Emacs signs a Emacs.app application bundle with Apple's codesign utility, +// using correct default entitlements, and also pre-signing any *.eln files +// which are in the bundle, as codesign will not detect them as requiring +// signing even with the --deep flag. +func Emacs(ctx context.Context, appBundle string, opts *Options) error { + if !strings.HasSuffix(appBundle, ".app") { + return fmt.Errorf("%s is not a .app application bundle", appBundle) + } + + appBundle, err := filepath.Abs(appBundle) + if err != nil { + return err + } + + _, err = os.Stat(appBundle) + if err != nil { + return err + } + + logger := hclog.FromContext(ctx).Named("sign") + logger.Info("preparing to sign Emacs.app", "app", appBundle) + + newOpts := *opts + + if newOpts.EntitlementsFile == "" { + if newOpts.Entitlements == nil { + e := Entitlements(DefaultEmacsEntitlements) + newOpts.Entitlements = &e + } + + f, err2 := newOpts.Entitlements.TempFile() + if err2 != nil { + return err2 + } + defer os.Remove(f) + + newOpts.EntitlementsFile = f + newOpts.Entitlements = nil + } + + err = signElnFiles(ctx, appBundle, &newOpts) + if err != nil { + return err + } + + err = signCLIHelper(ctx, appBundle, &newOpts) + if err != nil { + return err + } + + // Ensure app bundle is signed last, as modifications to the bundle after + // signing will invalidate the signature. Hence anything within it that + // needs to be separately signed, has to happen before signing the whole + // application bundle. + return Files(ctx, []string{appBundle}, &newOpts) +} + +func signElnFiles(ctx context.Context, appBundle string, opts *Options) error { + logger := hclog.FromContext(ctx).Named("sign") + + elnFiles, err := elnFiles(appBundle) + if err != nil { + return err + } + + if len(elnFiles) == 0 { + return nil + } + + logger.Info(fmt.Sprintf( + "found %d native-lisp *.eln files in %s to sign", + len(elnFiles), filepath.Base(appBundle), + )) + for _, file := range elnFiles { + err := Files(ctx, []string{file}, opts) + if err != nil { + return err + } + } + + return nil +} + +func signCLIHelper(ctx context.Context, appBundle string, opts *Options) error { + logger := hclog.FromContext(ctx).Named("sign") + + cliHelper := filepath.Join(appBundle, "Contents", "MacOS", "bin", "emacs") + fi, err := os.Stat(cliHelper) + if err != nil && !os.IsNotExist(err) { + return err + } else if err == nil && fi.Mode().IsRegular() { + logger.Info(fmt.Sprintf( + "found Contents/MacOS/bin/emacs CLI helper script in %s to sign", + filepath.Base(appBundle), + )) + + err = Files(ctx, []string{cliHelper}, opts) + if err != nil { + return err + } + } + + return nil +} + +// elnFiles finds all native-compilation *.eln files within a Emacs.app bundle, +// based on expected paths they might be stored in. +func elnFiles(emacsApp string) ([]string, error) { + dirs := []string{ + // Current *.eln location. + filepath.Join(emacsApp, "Contents", "Resources", "native-lisp"), + // Legacy *.eln location. + filepath.Join(emacsApp, "Contents", "MacOS", "lib", "emacs"), + } + + var files []string + walkDirFunc := func(path string, _d fs.DirEntry, _err error) error { + if strings.HasSuffix(path, ".eln") { + files = append(files, path) + } + + return nil + } + + for _, dir := range dirs { + fi, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + continue + } + + return nil, err + } + + if !fi.IsDir() { + continue + } + + err = filepath.WalkDir(dir, walkDirFunc) + if err != nil { + return nil, err + } + } + + return files, nil +} diff --git a/pkg/sign/entitlements.go b/pkg/sign/entitlements.go new file mode 100644 index 0000000..7941d20 --- /dev/null +++ b/pkg/sign/entitlements.go @@ -0,0 +1,54 @@ +package sign + +import ( + "bytes" + _ "embed" + "io" + "os" + "text/template" +) + +// DefaultEmacsEntitlements is the default set of entitlements application +// bundles are signed with if no entitlements are provided. +var DefaultEmacsEntitlements = []string{ + "com.apple.security.cs.allow-jit", + "com.apple.security.network.client", + "com.apple.security.cs.disable-library-validation", + "com.apple.security.automation.apple-events", +} + +//go:embed entitlements.tpl +var entitlementsTemplate string + +type Entitlements []string + +func (e Entitlements) XML() ([]byte, error) { + var buf bytes.Buffer + err := e.Write(&buf) + + return buf.Bytes(), err +} + +func (e Entitlements) Write(w io.Writer) error { + tpl, err := template.New("entitlements.plist").Parse(entitlementsTemplate) + if err != nil { + return err + } + + return tpl.Execute(w, e) +} + +func (e Entitlements) TempFile() (string, error) { + f, err := os.CreateTemp("", "*.entitlements.plist") + if err != nil { + return "", err + } + defer f.Close() + + err = e.Write(f) + if err != nil { + return "", err + } + + return f.Name(), nil +} diff --git a/pkg/sign/entitlements.tpl b/pkg/sign/entitlements.tpl new file mode 100644 index 0000000..a0477ef --- /dev/null +++ b/pkg/sign/entitlements.tpl @@ -0,0 +1,9 @@ + + + + + {{- range . }} + {{ . }} + {{ end }} + + diff --git a/pkg/sign/entitlements_test.go b/pkg/sign/entitlements_test.go new file mode 100644 index 0000000..b0d5fae --- /dev/null +++ b/pkg/sign/entitlements_test.go @@ -0,0 +1,117 @@ +package sign + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/jimeh/undent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var entitlementsTestCases = []struct { + name string + entitlements Entitlements + want string +}{ + { + name: "none", + entitlements: Entitlements{}, + //nolint:lll + want: undent.String(` + + + + + + `, + ), + }, + { + name: "one", + entitlements: Entitlements{"com.apple.security.cs.allow-jit"}, + //nolint:lll + want: undent.String(` + + + + + com.apple.security.cs.allow-jit + + + `, + ), + }, + { + name: "many", + entitlements: Entitlements{ + "com.apple.security.cs.allow-jit", + "com.apple.security.network.client", + "com.apple.security.cs.disable-library-validation", + "com.apple.security.automation.apple-events", + }, + //nolint:lll + want: undent.String(` + + + + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.cs.disable-library-validation + + com.apple.security.automation.apple-events + + + `, + ), + }, +} + +func TestDefaultEmacsEntitlements(t *testing.T) { + assert.Equal(t, + []string{ + "com.apple.security.cs.allow-jit", + "com.apple.security.network.client", + "com.apple.security.cs.disable-library-validation", + "com.apple.security.automation.apple-events", + }, + DefaultEmacsEntitlements, + ) +} + +func TestEntitlements_Write(t *testing.T) { + for _, tt := range entitlementsTestCases { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + + err := tt.entitlements.Write(&buf) + require.NoError(t, err) + + assert.Equal(t, tt.want, strings.TrimSpace(buf.String())) + }) + } +} + +func TestEntitlements_TempFile(t *testing.T) { + for _, tt := range entitlementsTestCases { + t.Run(tt.name, func(t *testing.T) { + tmpFile, err := tt.entitlements.TempFile() + require.NoError(t, err) + defer os.Remove(tmpFile) + + content, err := os.ReadFile(tmpFile) + require.NoError(t, err) + + assert.Equal(t, tt.want, strings.TrimSpace(string(content))) + assert.True(t, + strings.HasSuffix(tmpFile, ".entitlements.plist"), + "temp file name does not match \"*.entitlements.plist\"", + ) + }) + } +} diff --git a/pkg/sign/files.go b/pkg/sign/files.go new file mode 100644 index 0000000..da2ca28 --- /dev/null +++ b/pkg/sign/files.go @@ -0,0 +1,67 @@ +package sign + +import ( + "context" + "os" + "os/exec" + "strings" + + "github.com/hashicorp/go-hclog" +) + +func Files(ctx context.Context, files []string, opts *Options) error { + logger := hclog.FromContext(ctx).Named("sign") + args := []string{} + + if opts.Identity != "" { + args = append(args, "--sign", opts.Identity) + } + if opts.Deep { + args = append(args, "--deep") + } + if opts.Timestamp { + args = append(args, "--timestamp") + } + if opts.Force { + args = append(args, "--force") + } + if opts.Verbose { + args = append(args, "--verbose") + } + if len(opts.Options) > 0 { + args = append(args, "--options", strings.Join(opts.Options, ",")) + } + + if opts.EntitlementsFile != "" { + args = append(args, "--entitlements", opts.EntitlementsFile) + } else if opts.Entitlements != nil { + entitlementsFile, err := opts.Entitlements.TempFile() + if err != nil { + return err + } + defer os.Remove(entitlementsFile) + logger.Debug("wrote entitlements", "file", entitlementsFile) + + args = append(args, "--entitlements", entitlementsFile) + } + + baseCmd := opts.CodeSignCmd + if baseCmd == "" { + path, err := exec.LookPath("codesign") + if err != nil { + return err + } + baseCmd = path + } + + args = append(args, files...) + + logger.Debug("executing", "command", baseCmd, "args", args) + cmd := exec.CommandContext(ctx, baseCmd, args...) + if opts.Output != nil { + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + } + + return cmd.Run() +} diff --git a/pkg/sign/options.go b/pkg/sign/options.go new file mode 100644 index 0000000..b2d4195 --- /dev/null +++ b/pkg/sign/options.go @@ -0,0 +1,16 @@ +package sign + +import "io" + +type Options struct { + Identity string + Entitlements *Entitlements + EntitlementsFile string + Options []string + Deep bool + Timestamp bool + Force bool + Verbose bool + Output io.Writer + CodeSignCmd string +}