From c5773c51f4893bbf1947b905483505b4d4fe3da4 Mon Sep 17 00:00:00 2001 From: Ross Light Date: Thu, 11 Feb 2021 10:51:34 -0800 Subject: [PATCH] Create keychain in Mac biomes Cherry-pick of 5ca264a268bd38f7756a0356eae56c4d694908c5 [Fixes ch3916] --- CHANGELOG.md | 14 ++++- cmd/yb/helpers.go | 3 + cmd/yb/keychain.go | 120 +++++++++++++++++++++++++++++++++++++ cmd/yb/keychain_test.go | 130 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 cmd/yb/keychain.go create mode 100644 cmd/yb/keychain_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee7d10d..cb2c3f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,19 @@ The format is based on [Keep a Changelog][], and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [Keep a Changelog]: https://keepachangelog.com/en/1.0.0/ -[Unreleased]: https://github.com/yourbase/yb/compare/v0.5.5...HEAD +[Unreleased]: https://github.com/yourbase/yb/compare/v0.5.6...HEAD + +## [0.5.6][] - 2021-02-11 + +Version 0.5.6 fixes a build environment keychain issue for macOS. + +[0.5.6]: https://github.com/yourbase/yb/releases/tag/v0.5.6 + +### Fixed + +- On macOS, yb will now create an empty, default keychain in the build + environment. Previously, there was not a keychain inside the build + environment. ## [0.5.5][] - 2020-12-01 diff --git a/cmd/yb/helpers.go b/cmd/yb/helpers.go index 050d4f95..fe5724e3 100644 --- a/cmd/yb/helpers.go +++ b/cmd/yb/helpers.go @@ -69,6 +69,9 @@ func newBiome(ctx context.Context, opts newBiomeOptions) (biome.BiomeCloser, err return nil, fmt.Errorf("set up environment for target %s: %w", opts.target, err) } log.Debugf(ctx, "Home located at %s", l.HomeDir) + if err := ensureKeychain(ctx, l); err != nil { + return nil, fmt.Errorf("set up environment for target %s: %w", opts.target, err) + } bio, err := injectNetrc(ctx, l, netrc) if err != nil { return nil, fmt.Errorf("set up environment for target %s: %w", opts.target, err) diff --git a/cmd/yb/keychain.go b/cmd/yb/keychain.go new file mode 100644 index 00000000..122e0938 --- /dev/null +++ b/cmd/yb/keychain.go @@ -0,0 +1,120 @@ +// Copyright 2021 YourBase Inc. +// +// Licensed 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 +// +// https://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. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/yourbase/yb/internal/biome" + "zombiezen.com/go/log" +) + +// ensureKeychain ensures that a default keychain is present in the biome. +// If the biome is not a macOS environment, then ensureKeychain does nothing. +func ensureKeychain(ctx context.Context, bio biome.Biome) error { + if bio.Describe().OS != biome.MacOS { + return nil + } + + // Check whether a default keychain already exists. + stdout := new(strings.Builder) + stderr := new(strings.Builder) + err := bio.Run(ctx, &biome.Invocation{ + Argv: []string{"security", "default-keychain", "-d", "user"}, + Stdout: stdout, + Stderr: stderr, + }) + if err == nil && len(parseKeychainOutput(stdout.String())) > 0 { + // Keychain already exists. + return nil + } + if err != nil { + if stderr.Len() > 0 { + log.Debugf(ctx, "No default keychain; will create. Error:\n%s%v", stderr, err) + } else { + log.Debugf(ctx, "No default keychain; will create. Error: %v", err) + } + } + + // From experimentation, `security list-keychains -s` will silently fail + // unless ~/Library/Preferences exists. + if err := biome.MkdirAll(ctx, bio, bio.JoinPath(bio.Dirs().Home, "Library", "Preferences")); err != nil { + return fmt.Errorf("ensure build environment keychain: %w", err) + } + + // List the existing user keychains. There likely won't be any, but for + // robustness, we preserve them in the search path. + stdout.Reset() + stderr.Reset() + err = bio.Run(ctx, &biome.Invocation{ + Argv: []string{"security", "list-keychains", "-d", "user"}, + Stdout: stdout, + Stderr: stderr, + }) + if err != nil { + if stderr.Len() > 0 { + // stderr will almost certainly end in '\n'. + return fmt.Errorf("ensure build environment keychain: %s%w", stderr, err) + } + return fmt.Errorf("ensure build environment keychain: %w", err) + } + keychainList := parseKeychainOutput(stdout.String()) + + // Create a passwordless keychain. + const keychainName = "yb.keychain" + if err := runCommand(ctx, bio, "security", "create-keychain", "-p", "", keychainName); err != nil { + return fmt.Errorf("ensure build environment keychain: %w", err) + } + + // The keychain must be added to the search path. + // See https://stackoverflow.com/questions/20391911/os-x-keychain-not-visible-to-keychain-access-app-in-mavericks + // + // We prepend it to the search path so that Fastlane picks it up: + // https://github.com/fastlane/fastlane/blob/832e3e4a19d9cff5d5a14a61e9614b5659327427/fastlane_core/lib/fastlane_core/cert_checker.rb#L133-L134 + searchPathArgs := []string{"security", "list-keychains", "-d", "user", "-s", keychainName} + for _, k := range keychainList { + searchPathArgs = append(searchPathArgs, filepath.Base(k)) + } + if err := runCommand(ctx, bio, searchPathArgs...); err != nil { + return fmt.Errorf("ensure build environment keychain: %w", err) + } + + // Set the new keychain as the default. + if err := runCommand(ctx, bio, "security", "default-keychain", "-s", keychainName); err != nil { + return fmt.Errorf("ensure build environment keychain: %w", err) + } + return nil +} + +func parseKeychainOutput(out string) []string { + lines := strings.Split(out, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + paths := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, `"`) || !strings.HasSuffix(line, `"`) { + continue + } + paths = append(paths, line[1:len(line)-1]) + } + return paths +} diff --git a/cmd/yb/keychain_test.go b/cmd/yb/keychain_test.go new file mode 100644 index 00000000..0dd5b27a --- /dev/null +++ b/cmd/yb/keychain_test.go @@ -0,0 +1,130 @@ +// Copyright 2021 YourBase Inc. +// +// Licensed 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 +// +// https://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. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/yourbase/yb/internal/biome" + "zombiezen.com/go/log/testlog" +) + +func TestEnsureKeychain(t *testing.T) { + ctx := testlog.WithTB(context.Background(), t) + l := biome.Local{ + PackageDir: t.TempDir(), + HomeDir: t.TempDir(), + } + if osName := l.Describe().OS; osName != biome.MacOS { + t.Skipf("OS = %q; test can only run on %q", osName, biome.MacOS) + } + if err := ensureKeychain(ctx, l); err != nil { + t.Error("ensureKeychain(...):", err) + } + + // Verify that a single keychain shows up in the list. + stdout := new(strings.Builder) + stderr := new(strings.Builder) + err := l.Run(ctx, &biome.Invocation{ + Argv: []string{"security", "list-keychains", "-d", "user"}, + Stdout: stdout, + Stderr: stderr, + }) + if stderr.Len() > 0 { + t.Logf("stderr:\n%s", stderr) + } + if err != nil { + t.Error("security list-keychains:", err) + } + if keychains := parseKeychainOutput(stdout.String()); len(keychains) != 1 { + t.Errorf("keychains = %q; want len(keychains) == 1", keychains) + } + + // Verify that a default keychain is set. + stdout.Reset() + stderr.Reset() + err = l.Run(ctx, &biome.Invocation{ + Argv: []string{"security", "default-keychain", "-d", "user"}, + Stdout: stdout, + Stderr: stderr, + }) + if stderr.Len() > 0 { + t.Logf("stderr:\n%s", stderr) + } + if err != nil { + t.Error("security default-keychain:", err) + } + if len(parseKeychainOutput(stdout.String())) == 0 { + t.Error("No default keychain set") + } + + // Verify that running multiple times does not return an error and does not + // create a new keychain. + if err := ensureKeychain(ctx, l); err != nil { + t.Error("Second ensureKeychain(...):", err) + } + stdout.Reset() + stderr.Reset() + err = l.Run(ctx, &biome.Invocation{ + Argv: []string{"security", "list-keychains", "-d", "user"}, + Stdout: stdout, + Stderr: stderr, + }) + if stderr.Len() > 0 { + t.Logf("stderr:\n%s", stderr) + } + if err != nil { + t.Error("security list-keychains:", err) + } + if keychains := parseKeychainOutput(stdout.String()); len(keychains) != 1 { + t.Errorf("keychains after second ensure = %q; want len(keychains) == 1", keychains) + } +} + +func TestParseKeychainOutput(t *testing.T) { + tests := []struct { + output string + want []string + }{ + { + output: "", + want: nil, + }, + { + output: " \"/Users/yourbase/Library/Keychains/login.keychain-db\"\n", + want: []string{"/Users/yourbase/Library/Keychains/login.keychain-db"}, + }, + { + output: " \"/Users/yourbase/Library/Keychains/login.keychain-db\"\n" + + " \"/Library/Keychains/System.keychain\"\n", + want: []string{ + "/Users/yourbase/Library/Keychains/login.keychain-db", + "/Library/Keychains/System.keychain", + }, + }, + } + for _, test := range tests { + got := parseKeychainOutput(test.output) + if diff := cmp.Diff(test.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("parseKeychainOutput(%q) (-want +got):\n%s", test.output, diff) + } + } +}