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
91 changes: 17 additions & 74 deletions cmd/builder/signing.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ package main
import (
"context"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"

"github.com/MobAI-App/ios-builder/internal/config"
Expand Down Expand Up @@ -391,22 +389,21 @@ func runSigningSetup(cmd *cobra.Command, args []string) error {
ctx = context.Background()
}
fmt.Fprintln(out)
uploadErr := uploadSigningSet(ctx, store, storeErr, cfg, out, set, certData, password, profileData, extensionProfiles)
res, err := signing.SetupManual(ctx, store, storeErr, cfg, &signing.ManualOptions{
Type: typ, ProfileName: profileName, P12: certData, Password: password, Profile: profileData, ExtensionProfiles: extensionProfiles, Log: out,
})
var uploadErr error
if res != nil {
uploadErr = res.UploadError
}
if uploadErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", uploadErr)
}

// The profile is written only when its secrets reached the repository:
// builder.json must not claim a signing set the repository does not have.
replaced := ""
if uploadErr == nil {
replaced = writeSigningProfile(cfg, profileName, typ)
}
if err := config.NewManager().Save(cfg); err != nil {
return fmt.Errorf("failed to update config: %w", err)
if err != nil {
return err
}
if uploadErr == nil {
fmt.Fprintln(out, profileWritten(profileName, typ, replaced))
if res.ProfileWritten {
fmt.Fprintln(out, profileWritten(profileName, typ, res.ReplacedDistribution))
} else {
fmt.Fprintln(out, profileNotWritten(profileName))
}
Expand All @@ -427,72 +424,18 @@ func runSigningSetup(cmd *cobra.Command, args []string) error {
return nil
}

// The manual-mode helpers live in internal/signing so a program embedding
// Builder can take a user's files the same way; these names are what this
// package and its tests call them.

// matchExtensionProfiles pairs every extension in ios.extensions with the
// path of the --extension-profile (path → contents) whose app id covers it.
// Each must be of the app profile's type; an extension without a profile, or
// a profile for no listed extension, is an error naming it.
func matchExtensionProfiles(extensions []string, files map[string][]byte, typ signing.Type) (map[string]string, error) {
appIDs := make(map[string]string, len(files))
for _, path := range slices.Sorted(maps.Keys(files)) {
fileType, err := signing.ProfileType(files[path])
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if fileType != typ {
return nil, fmt.Errorf("%s is a %s profile, but the app profile is %s; every extension profile must be of the same type", path, fileType, typ)
}
if appIDs[path], err = signing.ProfileBundleID(files[path]); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
}
// The longest app id is the most specific, so an exact profile wins over
// a wildcard one covering the same extension.
paths := slices.SortedFunc(maps.Keys(appIDs), func(a, b string) int {
return len(appIDs[b]) - len(appIDs[a])
})
profiles := make(map[string]string, len(extensions))
var problems []string
for _, path := range paths {
covered := false
for _, id := range extensions {
if signing.Covers(appIDs[path], id) {
covered = true
if _, ok := profiles[id]; !ok {
profiles[id] = path
}
}
}
if !covered {
problems = append(problems, fmt.Sprintf("%s covers %s, which is not in ios.extensions", path, appIDs[path]))
}
}
for _, id := range extensions {
if _, ok := profiles[id]; !ok {
problems = append(problems, fmt.Sprintf("extension %s has no profile; pass --extension-profile <mobileprovision> for it", id))
}
}
if len(problems) > 0 {
return nil, fmt.Errorf("extension profiles do not match ios.extensions in builder.json:\n %s", strings.Join(problems, "\n "))
}
return profiles, nil
return signing.MatchExtensionProfiles(extensions, files, typ)
}

// manualSigningType is what the .mobileprovision says it is. A --distribution
// that disagrees is an error, since the runner refuses such a pair.
func manualSigningType(profileData []byte, distributionFlag string) (signing.Type, error) {
typ, err := signing.ProfileType(profileData)
if err != nil {
return "", err
}
if distributionFlag == "" {
return typ, nil
}
want, err := signing.ParseType(distributionFlag)
if err != nil {
return "", err
}
if want != typ {
return "", fmt.Errorf("the profile is a %s profile, but --distribution %s was given; builds with distribution %s would refuse it", typ, want, want)
}
return typ, nil
return signing.ManualType(profileData, distributionFlag)
}
4 changes: 0 additions & 4 deletions cmd/builder/signing_auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,6 @@ func setupDistribution(cfg *config.Config, profileName, flag string) (signing.Ty
// its tests call them.
type secretStore = signing.SecretStore

func uploadSigningSet(ctx context.Context, store secretStore, storeErr error, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte, extensions map[string][]byte) error {
return signing.UploadSet(ctx, store, storeErr, cfg, log, set, p12, password, profile, extensions)
}

func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte, extensions map[string][]byte) error {
return signing.UploadSecrets(ctx, gh, cfg, log, set, p12, password, profile, extensions)
}
Expand Down
13 changes: 9 additions & 4 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ type DeviceCode struct {
// Login performs GitHub OAuth Device Code flow authentication.
// It displays a URL and code for the user to authorize, then stores the token in the keychain.
func Login(ctx context.Context) (*Token, error) {
code, err := requestDeviceCode(ctx)
code, err := RequestDeviceCode(ctx)
if err != nil {
return nil, err
}
Expand All @@ -58,7 +58,7 @@ func Login(ctx context.Context) (*Token, error) {
fmt.Println()
fmt.Println("Waiting for authorization...")

token, err := pollForToken(ctx, code)
token, err := PollForToken(ctx, code)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -196,7 +196,10 @@ func deleteTokenFile() error {
return nil
}

func requestDeviceCode(ctx context.Context) (*DeviceCode, error) {
// RequestDeviceCode starts the device flow: the code and URL the user has to
// visit. Login prints them; a program with its own UI shows them itself and
// then calls PollForToken.
func RequestDeviceCode(ctx context.Context) (*DeviceCode, error) {
data := url.Values{
"client_id": {clientID},
"scope": {"repo workflow gist"},
Expand Down Expand Up @@ -231,7 +234,9 @@ func requestDeviceCode(ctx context.Context) (*DeviceCode, error) {
return &code, nil
}

func pollForToken(ctx context.Context, code *DeviceCode) (*Token, error) {
// PollForToken waits until the user has authorized the device code, or it
// expires or ctx ends. The token is returned, not stored.
func PollForToken(ctx context.Context, code *DeviceCode) (*Token, error) {
interval := time.Duration(code.Interval) * time.Second
if interval == 0 {
interval = 5 * time.Second
Expand Down
109 changes: 109 additions & 0 deletions internal/github/contents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package github

import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strings"
)

// ErrFileNotFound is GetFile's answer for a path the branch does not have.
var ErrFileNotFound = errors.New("file not found")

// ListRepositories lists the repositories the token's user can push to, the
// most recently pushed first, following every page. A program choosing a
// repository to set signing up for starts here.
func (c *Client) ListRepositories(ctx context.Context) ([]Repository, error) {
var all []Repository
for page := 1; ; page++ {
var list []Repository
if err := c.do(ctx, fmt.Sprintf("/user/repos?per_page=100&page=%d&sort=pushed&affiliation=owner,collaborator,organization_member", page), &list); err != nil {
return nil, fmt.Errorf("list repositories: %w", err)
}
all = append(all, list...)
if len(list) < 100 {
return all, nil
}
}
}

// ListBranches lists the repository's branch names, following every page.
func (c *Client) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
var names []string
for page := 1; ; page++ {
var list []struct {
Name string `json:"name"`
}
if err := c.do(ctx, fmt.Sprintf("/repos/%s/%s/branches?per_page=100&page=%d", owner, repo, page), &list); err != nil {
return nil, fmt.Errorf("list branches of %s/%s: %w", owner, repo, err)
}
for _, b := range list {
names = append(names, b.Name)
}
if len(list) < 100 {
return names, nil
}
}
}

// GetFile reads one file of the repository at ref (a branch, tag or commit;
// empty means the default branch). A missing file is ErrFileNotFound, so a
// caller can tell "no builder.json yet" from a failure.
func (c *Client) GetFile(ctx context.Context, owner, repo, path, ref string) (*FileContent, error) {
query := ""
if ref != "" {
query = "?ref=" + url.QueryEscape(ref)
}
var file struct {
FileContent
Encoding string `json:"encoding"`
Content string `json:"content"`
}
if err := c.do(ctx, fmt.Sprintf("/repos/%s/%s/contents/%s%s", owner, repo, escapePath(path), query), &file); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Status == "404" {
return nil, fmt.Errorf("%s in %s/%s: %w", path, owner, repo, ErrFileNotFound)
}
return nil, fmt.Errorf("read %s of %s/%s: %w", path, owner, repo, err)
}
if file.Encoding != "base64" {
return nil, fmt.Errorf("read %s of %s/%s: unexpected encoding %q", path, owner, repo, file.Encoding)
}
// GitHub wraps the base64 at 60 columns.
data, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(file.Content, "\n", ""))
if err != nil {
return nil, fmt.Errorf("read %s of %s/%s: decode: %w", path, owner, repo, err)
}
file.FileContent.Content = data
return &file.FileContent, nil
}

// PutFile creates or updates one file with a commit on req.Branch. Updating
// needs req.SHA, the blob GetFile reported, so a file changed since it was
// read is refused by GitHub (409) rather than overwritten.
func (c *Client) PutFile(ctx context.Context, owner, repo, path string, req *PutFileRequest) (*FileCommit, error) {
body := struct {
Message string `json:"message"`
Content string `json:"content"`
Branch string `json:"branch,omitempty"`
SHA string `json:"sha,omitempty"`
}{Message: req.Message, Content: base64.StdEncoding.EncodeToString(req.Content), Branch: req.Branch, SHA: req.SHA}
var result struct {
Commit FileCommit `json:"commit"`
}
if err := c.call(ctx, "PUT", fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, escapePath(path)), body, &result); err != nil {
return nil, fmt.Errorf("write %s of %s/%s: %w", path, owner, repo, err)
}
return &result.Commit, nil
}

// escapePath escapes each segment of a repository path, keeping the slashes.
func escapePath(path string) string {
parts := strings.Split(path, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
Loading
Loading