Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make runwda easy #428

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ require (
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/frankban/quicktest v1.14.6 // indirect
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa h1:RDBNVkRviHZtvDvId8XSGPu3rmpmSe+wKRcEWNgsfWU=
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
Expand Down
251 changes: 251 additions & 0 deletions ios/codesign/codesign.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
/*
This package contains everything needed to sign ios apps, parse, validate and generate provisioning profiles and certificates.
*/
package codesign

import (
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"time"

log "github.com/sirupsen/logrus"
)

const codesignPath = "/usr/bin/codesign"

// In iOS apps you will find three types of directories that need signing applied.
// They either end with .app, .appex (app extensions) or .xctest.
const (
appSuffix = ".app"
appExtensionSuffix = ".appex"
xctestSuffix = ".xctest"
)

// SigningConfig contains the CertSha1 of the certificate that will be used for signing.
// EntitlementsFilePath points to a plist file containing the entitlements extracted from
// the correct mobileprovisioning profile.
// KeychainPath contains the path to the keychain that contains the signing certificate.
type SigningConfig struct {
CertSha1 string
EntitlementsFilePath string
KeychainPath string
ProfileBytes []byte
}

func Resign(udid string, ipaFile *os.File, s SigningWorkspace) error {
if runtime.GOOS != "darwin" {
return errors.New("Resign: can only resign on macOS for now.")
}
if udid == "" {
return errors.New("udid is empty")
}
info, err := ipaFile.Stat()
if err != nil {
return fmt.Errorf("Resign: could not get file info: %w", err)
}
_, directory, err := ExtractIpa(ipaFile, info.Size())
if err != nil {
return fmt.Errorf("Resign: could not extract ipa: %w", err)
}
defer os.RemoveAll(directory)

index := FindProfileForDevice(udid, s.profiles)

if index == -1 {
return fmt.Errorf("Resign: could not find profile for device %s", udid)
}

appFolder, err := FindAppFolder(directory)
if err != nil {
return fmt.Errorf("Resign: could not find .app folder in extracted ipa payload folder: %w", err)
}

archs, err := ExtractArchitectures(appFolder)
if err != nil {
return fmt.Errorf("Resign: could not determine build architecture of build, run 'lipo -info appDir/appExecutable' to debug: %w", err)
}
if IsSimulatorApp(archs) {
return errors.New("Resign: cannot resign simulator app")
}

err = Sign(directory, s.GetConfig(index))
if err != nil {
return fmt.Errorf("Resign: could not sign app: %w", err)
}

w, err := os.Create(ipaFile.Name())
CompressToIpa(directory, w)
return nil

}

// RemoveSignature executes "codesign --remove-signature" for the given path.
func RemoveSignature(dir string) error {
cmd := exec.Command(codesignPath, "--remove-signature", dir)
output, err := cmd.CombinedOutput()
if err != nil {
log.WithFields(log.Fields{"error": err, "cmd": cmd, "output": string(output)}).Errorf("error removing signature with codesign")
return err
}
log.WithFields(log.Fields{"cmd": cmd, "output": string(output)}).Debugf("codesign invoked")
return err
}

// Sign uses the cert, entitlements and keychain from the SigningConf to codesign the unzipped app
// in the root path. Root needs to be a directory named 'Payload' with all the app contents inside of it.
// Then the filetree will be walked and all the frameworks and app folders will be codesigned.
func Sign(root string, config SigningConfig) error {
if !strings.HasSuffix(root, "Payload") {
root = path.Join(root, "Payload")
}
rootPathInfo, err := os.Stat(root)
if err != nil {
return err
}
if !rootPathInfo.IsDir() {
return errors.New("does not exist:" + root)
}
dirs, err := findAppDirs(root)
if err != nil {
return err
}

for _, dir := range dirs {
err := signFrameworks(dir, config)
if err != nil {
return fmt.Errorf("error signing frameworks %s err:%w", dir, err)
}
err = signAppDir(dir, config)
if err != nil {
return fmt.Errorf("error signing appDir %s err:%w", dir, err)
}
}

return nil
}

// Verify runs "codesign -vv --deep" to verbosely verify recursively the given path is properly signed.
func Verify(path string) error {
cmd := exec.Command(codesignPath, "-vv", "--deep", path)
output, err := cmd.CombinedOutput()
if err != nil {
log.WithFields(log.Fields{"path": path, "error": err, "cmd": cmd, "output": string(output)}).Infof("codesign invoked")
return err
}
return nil
}

func signFrameworks(root string, config SigningConfig) error {
frameworksPath := path.Join(root, "Frameworks")
//it is a recursive call, if there are no more frameworks found, we just return nil here
if _, err := os.Stat(frameworksPath); os.IsNotExist(err) {
return nil
}
files, err := os.ReadDir(frameworksPath)
if err != nil {
return err
}
//Now recursively look into each child to find other Frameworks directories deeper
//in the file tree and sign them. To get valid overall signatures, of course the
//Frameworks at the leaf level of the file tree must be signed first.
// Afterwards sign the current Frameworks directory.
for _, file := range files {
if strings.HasSuffix(file.Name(), ".framework") {
fullpath := path.Join(frameworksPath, file.Name())
err := signFrameworks(fullpath, config)
if err != nil {
return fmt.Errorf("signing Frameworks had err:%w", err)
}
err = exeuteCodesignFramework(fullpath, config)
if err != nil {
return fmt.Errorf("running codesign on frameworks had err:%w", err)
}
}
}
return nil
}

func exeuteCodesignFramework(path string, config SigningConfig) error {
cmd := exec.Command(codesignPath, "-vv", "--keychain", config.KeychainPath, "--deep", "--force", "--sign", config.CertSha1, path)
output, err := cmd.CombinedOutput()
if err != nil {
log.WithFields(log.Fields{"error": err, "cmd": cmd, "output": string(output)}).Errorf("codesign invoked")
return err
}
log.WithFields(log.Fields{"cmd": cmd, "output": string(output)}).Debugf("codesign invoked")
return err
}

func signAppDir(appPath string, config SigningConfig) error {
if shouldReplaceProfile(appPath) {
target := path.Join(appPath, "embedded.mobileprovision")
err := os.WriteFile(target, config.ProfileBytes, 0644)
if err != nil {
return fmt.Errorf("failed replacing embedded.mobileprovision profile in %s with %w", appPath, err)
}
}
cmd := exec.Command(codesignPath, "-vv", "--keychain", config.KeychainPath, "--deep", "--force", "--sign", config.CertSha1, "--entitlements", config.EntitlementsFilePath, appPath)
output, err := cmd.CombinedOutput()

if err != nil {
log.WithFields(log.Fields{"error": err, "cmd": cmd, "output": string(output)}).Errorf("codesign failed")
return err
}
log.WithFields(log.Fields{"cmd": cmd, "output": string(output)}).Debugf("codesign invoked")
return err
}

func findAppDirs(root string) ([]string, error) {
allFiles, err := GetFiles(root)
if err != nil {
return []string{}, err
}
appDirs := []string{}
for _, file := range allFiles {
if isDirWithApp(file) {
appDirs = append(appDirs, file)
}
}
return reverse(appDirs), nil
}

func isDirWithApp(dir string) bool {
return strings.HasSuffix(dir, appSuffix) || strings.HasSuffix(dir, xctestSuffix) || strings.HasSuffix(dir, appExtensionSuffix)
}

func shouldReplaceProfile(dir string) bool {
return strings.HasSuffix(dir, appSuffix) || strings.HasSuffix(dir, appExtensionSuffix)
}

// GetFiles performs a walk to recursively find all files and directories in the given root path.
// It returns a list of all files omitting the root path itself.
func GetFiles(root string) ([]string, error) {
walkStart := time.Now()
var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("error walking file tree for path: %s error: %w", path, err)
}
if path == root {
return nil
}
files = append(files, path)
return nil
})
walkDuration := time.Since(walkStart)
log.Infof("Walk duration: %v", walkDuration)
return files, err
}

func reverse(a []string) []string {
for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 {
a[left], a[right] = a[right], a[left]
}
return a
}
129 changes: 129 additions & 0 deletions ios/codesign/codesign_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package codesign_test

import (
"bytes"
"fmt"
"os"
"os/exec"
"path"
"testing"
"time"

"github.com/danielpaulus/go-ios/ios/codesign"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)

// TestCodeSign tests the resigning process end to end.
// The ipa will be extracted, signed, zipped and in case
// the environment variable udid is specified, installed to a device.
func TestCodeSign(t *testing.T) {

ipa := readBytes("fixtures/wda.ipa")

workspace, cleanup, err := makeWorkspace()
if err != nil {
log.Errorf("failed creating workspace: %+v", err)
t.Fail()
return
}
defer cleanup()

readerAt := bytes.NewReader(ipa)
duration, directory, err := codesign.ExtractIpa(readerAt, int64(len(ipa)))
if err != nil {
log.Errorf("failed extracting: %+v", err)
t.Fail()
return
}
log.Infof("Extraction took:%v", duration)
defer os.RemoveAll(directory)

index := 0
if udid, yes := runOnRealDevice(); yes {
index, err = findProfile(udid)
if err != nil {
log.Errorf("failed finding profile: %+v", err)
t.Fail()
return
}
}
signingConfig := workspace.GetConfig(index)

startSigning := time.Now()
err = codesign.Sign(directory, signingConfig)
assert.NoError(t, err)
durationSigning := time.Since(startSigning)
log.Infof("signing took: %v", durationSigning)

b := &bytes.Buffer{}

assert.NoError(t, codesign.Verify(path.Join(directory, "Payload", "WebDriverAgentRunner-Runner.app")))

compressStart := time.Now()
err = codesign.CompressToIpa(directory, b)
if err != nil {
log.Errorf("Compression failed with %+v", err)
t.Fail()
return
}
compressDuration := time.Since(compressStart)
log.Infof("compressiontook: %v", compressDuration)

if udid, yes := runOnRealDevice(); yes {
installOnRealDevice(udid, b.Bytes())
} else {
log.Warn("No UDID provided, not running installation on actual device")
}
}

func runOnRealDevice() (string, bool) {
udid := os.Getenv("udid")
return udid, udid != ""
}

func makeWorkspace() (codesign.SigningWorkspace, func(), error) {
dir, err := os.MkdirTemp("", "sign-test")
if err != nil {
return codesign.SigningWorkspace{}, nil, err
}

workspace := codesign.NewSigningWorkspace(dir)
workspace.PrepareProfiles("../provisioningprofiles")
workspace.PrepareKeychain("test.keychain")

cleanUp := func() {
defer os.RemoveAll(dir)
defer workspace.Close()
}
return workspace, cleanUp, nil
}

func findProfile(udid string) (int, error) {
profiles, err := codesign.ParseProfiles("../provisioningprofiles")
if err != nil {
return -1, fmt.Errorf("could not parse profiles %+v", err)
}
index := codesign.FindProfileForDevice(udid, profiles)
if index == -1 {
return -1, fmt.Errorf("Device: %s is not in profiles", udid)
}
return index, nil
}

func installOnRealDevice(udid string, ipa []byte) {
ipafile, err := os.CreateTemp("", "myname-*.ipa")
if err != nil {
log.Error(err)
}
defer os.Remove(ipafile.Name())

ipafile.Write(ipa)
ipafile.Close()

installerlogs, err := exec.Command("ios", "install", ipafile.Name(), "--udid="+udid).CombinedOutput()
if err != nil {
log.Errorf("failed installing, logs: %s with err %+v", string(installerlogs), err)
}
log.Info("Install successful")
}
Binary file added ios/codesign/fixtures/wda.ipa
Binary file not shown.
Loading
Loading