Skip to content

Commit

Permalink
Create keychain in Mac biomes
Browse files Browse the repository at this point in the history
Cherry-pick of 5ca264a

[Fixes ch3916]
  • Loading branch information
zombiezen committed Feb 11, 2021
1 parent c938fb2 commit c5773c5
Show file tree
Hide file tree
Showing 4 changed files with 266 additions and 1 deletion.
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions cmd/yb/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
120 changes: 120 additions & 0 deletions cmd/yb/keychain.go
Original file line number Diff line number Diff line change
@@ -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
}
130 changes: 130 additions & 0 deletions cmd/yb/keychain_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}

0 comments on commit c5773c5

Please sign in to comment.