Skip to content
Merged
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 .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: majd
11 changes: 11 additions & 0 deletions .github/workflows/dry-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,14 @@ jobs:
CGO_ENABLED: 1
CGO_CFLAGS: -mmacosx-version-min=10.15
CGO_LDFLAGS: -mmacosx-version-min=10.15
build_ios:
name: Build for iOS
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.0"
cache: true
- run: brew install ldid cmake
- run: ./tools/build-ios.sh
42 changes: 42 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,48 @@ jobs:
name: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}
path: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}
if-no-files-found: error
build_ios:
name: Build for iOS
runs-on: macos-latest
needs: [get_version, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.0"
cache: true
- run: brew install ldid cmake
- run: ./tools/build-ios.sh "ipatool-$VERSION-ios-arm64"
env:
VERSION: ${{ needs.get_version.outputs.version }}
- uses: actions/upload-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64
path: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64
if-no-files-found: error
release_ios:
name: Release for iOS
runs-on: ubuntu-latest
needs: [get_version, build_ios, release_windows]
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64
path: bin
- run: chmod +x "bin/$FILE" && tar -czvf "$FILE.tar.gz" "bin/$FILE"
env:
FILE: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64
- run: ./tools/sha256sum.sh "$TARBALL" > "$TARBALL.sha256sum"
env:
TARBALL: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64.tar.gz
- uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64.*
tag: ${{ github.ref }}
overwrite: false
file_glob: true
release_windows:
name: Release for Windows
runs-on: ubuntu-latest
Expand Down
39 changes: 15 additions & 24 deletions cmd/auth.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
package cmd

import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/avast/retry-go"
"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/spf13/cobra"
"golang.org/x/term"
)

func authCmd() *cobra.Command {
Expand All @@ -32,18 +29,6 @@ func authCmd() *cobra.Command {
}

func loginCmd() *cobra.Command {
promptForAuthCode := func() (string, error) {
authCode, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", fmt.Errorf("failed to read string: %w", err)
}

authCode = strings.Trim(authCode, "\n")
authCode = strings.Trim(authCode, "\r")

return authCode, nil
}

var email, password, authCode, sessionOutput string
var mzfinance bool

Expand All @@ -53,29 +38,37 @@ func loginCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
interactive := cmd.Context().Value(interactiveKey).(bool)

if email == "" && !interactive {
return errors.New("email is required when not running in interactive mode; use the \"--email\" flag")
}

if email == "" && interactive {
value, err := readPrompt("enter email: ", false)
if err != nil {
return fmt.Errorf("failed to read email: %w", err)
}
email = value
}

if password == "" && !interactive {
return errors.New("password is required when not running in interactive mode; use the \"--password\" flag")
}

if password == "" && interactive {
dependencies.Logger.Log().Msg("enter password:")

bytes, err := term.ReadPassword(int(os.Stdin.Fd()))
value, err := readPrompt("enter password: ", true)
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
password = string(bytes)
password = value
}

var lastErr error

// nolint:wrapcheck
return retry.Do(func() error {
if errors.Is(lastErr, appstore.ErrAuthCodeRequired) && interactive {
dependencies.Logger.Log().Msg("enter 2FA code:")

var err error
authCode, err = promptForAuthCode()
authCode, err = readPrompt("enter 2FA code: ", false)
if err != nil {
return fmt.Errorf("failed to read auth code: %w", err)
}
Expand Down Expand Up @@ -157,8 +150,6 @@ func loginCmd() *cobra.Command {
cmd.Flags().StringVar(&sessionOutput, "session-output", "", "path to save the account session to after a successful login")
cmd.Flags().BoolVar(&mzfinance, "mzfinance", false, "use the stable legacy MZFinance login flow (GSA -> MZFinance) instead of the default native/fast path")

_ = cmd.MarkFlagRequired("email")

return cmd
}

Expand Down
101 changes: 101 additions & 0 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package cmd

import (
"context"
"os"
"path/filepath"

"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/majd/ipatool/v2/pkg/log"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("Login command", func() {
var store *fakeLoginAppStore

BeforeEach(func() {
previousDependencies := dependencies
DeferCleanup(func() { dependencies = previousDependencies })
store = &fakeLoginAppStore{}
dependencies.AppStore = store
dependencies.Logger = log.NewLogger(log.Args{})
})

DescribeTable("requires credentials in non-interactive mode", func(args []string, message string) {
cmd := loginCmd()
cmd.SetContext(context.WithValue(context.Background(), interactiveKey, false))
cmd.SetArgs(args)

Expect(cmd.Execute()).To(MatchError(message))
Expect(store.loginCalls).To(BeZero())
},
Entry("missing email", []string{"--password", "secret"}, "email is required when not running in interactive mode; use the \"--email\" flag"),
Entry("missing password", []string{"--email", "[email protected]"}, "password is required when not running in interactive mode; use the \"--password\" flag"),
)

DescribeTable("uses supplied credentials", func(interactive bool) {
cmd := loginCmd()
cmd.SetContext(context.WithValue(context.Background(), interactiveKey, interactive))
cmd.SetArgs([]string{"--email", "[email protected]", "--password", "secret"})

Expect(cmd.Execute()).To(Succeed())
Expect(store.loginCalls).To(Equal(1))
Expect(store.input).To(Equal(appstore.LoginInput{Email: "[email protected]", Password: "secret"}))
},
Entry("interactive", true),
Entry("non-interactive", false),
)

DescribeTable("prompts for email", func(input, message string) {
dir := GinkgoT().TempDir()
inputPath := filepath.Join(dir, "stdin")
Expect(os.WriteFile(inputPath, []byte(input), 0o600)).To(Succeed())
stdin, err := os.Open(inputPath)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(stdin.Close)
stderr, err := os.Create(filepath.Join(dir, "stderr"))
Expect(err).NotTo(HaveOccurred())
DeferCleanup(stderr.Close)

previousStdin, previousStderr := os.Stdin, os.Stderr
os.Stdin, os.Stderr = stdin, stderr
DeferCleanup(func() { os.Stdin, os.Stderr = previousStdin, previousStderr })

cmd := loginCmd()
cmd.SetContext(context.WithValue(context.Background(), interactiveKey, true))
cmd.SetArgs([]string{"--password", "secret"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true

err = cmd.Execute()
if message == "" {
Expect(err).NotTo(HaveOccurred())
Expect(store.loginCalls).To(Equal(1))
Expect(store.input).To(Equal(appstore.LoginInput{Email: "[email protected]", Password: "secret"}))
} else {
Expect(err).To(MatchError(message))
Expect(store.loginCalls).To(BeZero())
}

prompt, err := os.ReadFile(stderr.Name())
Expect(err).NotTo(HaveOccurred())
Expect(string(prompt)).To(Equal("enter email: "))
},
Entry("reads unmasked input without requiring a terminal", "[email protected]\n", ""),
Entry("reports input errors", "", "failed to read email: failed to read input: EOF"),
)
})

type fakeLoginAppStore struct {
appstore.AppStore
input appstore.LoginInput
loginCalls int
}

func (f *fakeLoginAppStore) Login(input appstore.LoginInput) (appstore.LoginOutput, error) {
f.input = input
f.loginCalls++

return appstore.LoginOutput{}, nil
}
46 changes: 14 additions & 32 deletions cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ func newLogger(format OutputFormat, verbose bool) log.Logger {
// file is corrupt (e.g. left over from an interrupted write or an
// incompatible cookie format), the broken file is moved aside and a fresh
// jar is created instead of crashing the whole program on startup.
func newCookieJar(machine machine.Machine) http.CookieJar {
filename := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName, CookieJarFileName)
func newCookieJar(stateDirectory string) http.CookieJar {
filename := filepath.Join(stateDirectory, CookieJarFileName)

jar, err := cookiejar.New(&cookiejar.Options{Filename: filename})
if err == nil {
Expand Down Expand Up @@ -120,22 +120,22 @@ func (envSessionKeychain) Remove(_ string) error {
}

// keychainPassphraseFile is the name of the file that stores the auto-generated
// keychain passphrase. Keeping it in the ipatool config directory means the
// keychain passphrase. Keeping it in the ipatool state directory means the
// session token stays decryptable across runs without prompting the user.
const keychainPassphraseFile = "keychain-passphrase"

// resolveKeychainPassphrase returns the passphrase used to encrypt the local
// keychain file. An explicitly provided --keychain-passphrase wins; otherwise a
// random passphrase is generated on first use and persisted in the ipatool
// config directory, so the user is never prompted for a separate local
// state directory, so the user is never prompted for a separate local
// password. (The OS keyring backends, when present, are used in preference to
// the file backend anyway.)
func resolveKeychainPassphrase(machine machine.Machine) (string, error) {
func resolveKeychainPassphrase(stateDirectory string) (string, error) {
if keychainPassphrase != "" {
return keychainPassphrase, nil
}

dir := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName)
dir := stateDirectory
path := filepath.Join(dir, keychainPassphraseFile)

if data, err := os.ReadFile(path); err == nil {
Expand All @@ -150,7 +150,7 @@ func resolveKeychainPassphrase(machine machine.Machine) (string, error) {
passphrase := hex.EncodeToString(random)

if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("failed to create config directory: %w", err)
return "", fmt.Errorf("failed to create state directory: %w", err)
}

if err := os.WriteFile(path, []byte(passphrase+"\n"), 0o600); err != nil {
Expand All @@ -161,24 +161,24 @@ func resolveKeychainPassphrase(machine machine.Machine) (string, error) {
}

// newKeychain returns a new keychain instance.
func newKeychain(machine machine.Machine) keychain.Keychain {
func newKeychain(stateDirectory string) keychain.Keychain {
if session := os.Getenv("IPATOOL_SESSION"); session != "" {
return envSessionKeychain{data: []byte(session)}
}

passphrase, err := resolveKeychainPassphrase(machine)
passphrase, err := resolveKeychainPassphrase(stateDirectory)
if err != nil {
util.Must("", err)
}

ring := util.Must(keyring.Open(keyring.Config{
ring := util.Must(openKeyring(keyring.Config{
AllowedBackends: []keyring.BackendType{
keyring.KeychainBackend,
keyring.SecretServiceBackend,
keyring.FileBackend,
},
ServiceName: KeychainServiceName,
FileDir: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName),
FileDir: stateDirectory,
FilePasswordFunc: func(s string) (string, error) {
return passphrase, nil
},
Expand All @@ -195,31 +195,13 @@ func initWithCommand(cmd *cobra.Command) {
dependencies.Logger = newLogger(format, verbose)
dependencies.OS = operatingsystem.New()
dependencies.Machine = machine.New(machine.Args{OS: dependencies.OS})
dependencies.CookieJar = newCookieJar(dependencies.Machine)
dependencies.Keychain = newKeychain(dependencies.Machine)
stateDirectory := util.Must(prepareStateDirectory(dependencies.OS, dependencies.Machine.HomeDirectory()))
dependencies.CookieJar = newCookieJar(stateDirectory)
dependencies.Keychain = newKeychain(stateDirectory)
dependencies.AppStore = appstore.NewAppStore(appstore.Args{
CookieJar: dependencies.CookieJar,
OperatingSystem: dependencies.OS,
Keychain: dependencies.Keychain,
Machine: dependencies.Machine,
})

util.Must("", createConfigDirectory(dependencies.OS, dependencies.Machine))
}

// createConfigDirectory creates the configuration directory for the CLI tool, if needed.
func createConfigDirectory(os operatingsystem.OperatingSystem, machine machine.Machine) error {
configDirectoryPath := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName)
_, err := os.Stat(configDirectoryPath)

if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(configDirectoryPath, 0700)
if err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
} else if err != nil {
return fmt.Errorf("could not read metadata: %w", err)
}

return nil
}
Loading
Loading