diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index ad352d6..ea607a3 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -3,10 +3,8 @@ package main import ( "context" "fmt" - "maps" "os" "path/filepath" - "slices" "strings" "github.com/MobAI-App/ios-builder/internal/config" @@ -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)) } @@ -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 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) } diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index 695574a..2de5c2a 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -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) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 159c8a9..b8cf656 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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 } @@ -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 } @@ -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"}, @@ -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 diff --git a/internal/github/contents.go b/internal/github/contents.go new file mode 100644 index 0000000..edb90ae --- /dev/null +++ b/internal/github/contents.go @@ -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, "/") +} diff --git a/internal/github/contents_test.go b/internal/github/contents_test.go new file mode 100644 index 0000000..5a4bafe --- /dev/null +++ b/internal/github/contents_test.go @@ -0,0 +1,167 @@ +package github + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "testing" +) + +// apiServer answers requests with handler and returns a client pointed at it. +func apiServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("unexpected auth %q on %s %s", r.Header.Get("Authorization"), r.Method, r.URL) + } + w.Header().Set("Content-Type", "application/json") + handler(w, r) + })) + t.Cleanup(srv.Close) + return NewClientWithBaseURL("tok", srv.URL) +} + +func TestListRepositoriesFollowsPages(t *testing.T) { + var queries []string + c := apiServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/user/repos" { + t.Errorf("unexpected path %s", r.URL.Path) + } + queries = append(queries, r.URL.RawQuery) + var repos []Repository + if r.URL.Query().Get("page") == "1" { + for i := range 100 { + repos = append(repos, Repository{FullName: fmt.Sprintf("o/r%d", i), DefaultRef: "main"}) + } + } else { + repos = []Repository{{FullName: "o/last", DefaultRef: "main", Private: true}} + } + if err := json.NewEncoder(w).Encode(repos); err != nil { + t.Error(err) + } + }) + repos, err := c.ListRepositories(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(repos) != 101 || repos[100].FullName != "o/last" || !repos[100].Private { + t.Fatalf("repos: %d, last %+v", len(repos), repos[len(repos)-1]) + } + want := []string{ + "per_page=100&page=1&sort=pushed&affiliation=owner,collaborator,organization_member", + "per_page=100&page=2&sort=pushed&affiliation=owner,collaborator,organization_member", + } + if !slices.Equal(queries, want) { + t.Errorf("pages requested: %v, want %v", queries, want) + } +} + +func TestListBranches(t *testing.T) { + c := apiServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/o/r/branches" { + t.Errorf("unexpected path %s", r.URL.Path) + } + fmt.Fprint(w, `[{"name":"main","protected":true},{"name":"dev"}]`) + }) + names, err := c.ListBranches(context.Background(), "o", "r") + if err != nil || !slices.Equal(names, []string{"main", "dev"}) { + t.Fatalf("branches: %v, %v", names, err) + } +} + +// GitHub returns the content base64-wrapped at 60 columns; GetFile hands +// back the bytes and the blob SHA a later PutFile needs. +func TestGetFileDecodesContent(t *testing.T) { + c := apiServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/o/r/contents/dir/builder.json" || r.URL.Query().Get("ref") != "main" { + t.Errorf("unexpected request %s", r.URL) + } + content := base64.StdEncoding.EncodeToString([]byte(`{"project": "App"}`)) + fmt.Fprintf(w, `{"path":"dir/builder.json","sha":"blob1","encoding":"base64","content":"%s\n%s\n"}`, content[:10], content[10:]) + }) + file, err := c.GetFile(context.Background(), "o", "r", "dir/builder.json", "main") + if err != nil { + t.Fatal(err) + } + if string(file.Content) != `{"project": "App"}` || file.SHA != "blob1" || file.Path != "dir/builder.json" { + t.Errorf("file = %+v (%q)", file, file.Content) + } +} + +func TestGetFileNotFound(t *testing.T) { + c := apiServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found","status":"404"}`) + }) + _, err := c.GetFile(context.Background(), "o", "r", "builder.json", "") + if !errors.Is(err, ErrFileNotFound) { + t.Fatalf("missing file: %v", err) + } + // Other failures are not "not found". + c = apiServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"message":"forbidden"}`) + }) + if _, err := c.GetFile(context.Background(), "o", "r", "builder.json", ""); err == nil || errors.Is(err, ErrFileNotFound) { + t.Fatalf("403: %v", err) + } +} + +func TestPutFileCommits(t *testing.T) { + var body map[string]string + c := apiServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/repos/o/r/contents/builder.json" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"content":{"sha":"blob2"},"commit":{"sha":"abc123","html_url":"https://github.com/o/r/commit/abc123"}}`) + }) + commit, err := c.PutFile(context.Background(), "o", "r", "builder.json", &PutFileRequest{ + Message: "signing profile", Content: []byte("{}"), Branch: "main", SHA: "blob1", + }) + if err != nil { + t.Fatal(err) + } + if commit.SHA != "abc123" || commit.HTMLURL != "https://github.com/o/r/commit/abc123" { + t.Errorf("commit = %+v", commit) + } + want := map[string]string{"message": "signing profile", "content": base64.StdEncoding.EncodeToString([]byte("{}")), "branch": "main", "sha": "blob1"} + if len(body) != len(want) { + t.Errorf("body = %v, want %v", body, want) + } + for k, v := range want { + if body[k] != v { + t.Errorf("body[%s] = %q, want %q", k, body[k], v) + } + } +} + +// A new file carries no sha, and a path with spaces is escaped per segment. +func TestPutFileNewFileEscapesPath(t *testing.T) { + c := apiServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/repos/o/r/contents/ios%20app/builder.json" { + t.Errorf("unexpected path %s", r.URL.EscapedPath()) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if _, ok := body["sha"]; ok { + t.Errorf("sha sent for a new file: %v", body) + } + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"commit":{"sha":"new"}}`) + }) + commit, err := c.PutFile(context.Background(), "o", "r", "ios app/builder.json", &PutFileRequest{Message: "m", Content: []byte("{}")}) + if err != nil || commit.SHA != "new" { + t.Fatalf("new file: %+v, %v", commit, err) + } +} diff --git a/internal/github/types.go b/internal/github/types.go index b112334..38efd67 100644 --- a/internal/github/types.go +++ b/internal/github/types.go @@ -120,6 +120,30 @@ type ArtifactsResponse struct { Artifacts []Artifact `json:"artifacts"` } +// FileContent is one file read from a repository: its bytes and the blob SHA +// that PutFile needs to update it. +type FileContent struct { + Path string `json:"path"` + SHA string `json:"sha"` + Content []byte `json:"-"` +} + +// PutFileRequest is one file to create or update with a commit. +type PutFileRequest struct { + Message string + Content []byte + // Branch is the branch to commit on; empty means the default branch. + Branch string + // SHA is the blob being replaced, required to update an existing file. + SHA string +} + +// FileCommit is the commit PutFile made. +type FileCommit struct { + SHA string `json:"sha"` + HTMLURL string `json:"html_url"` +} + // APIError represents an error response from the GitHub API type APIError struct { Message string `json:"message"` diff --git a/internal/signing/manual.go b/internal/signing/manual.go new file mode 100644 index 0000000..876dcd0 --- /dev/null +++ b/internal/signing/manual.go @@ -0,0 +1,163 @@ +package signing + +// The manual half of `signing setup`: a certificate and profiles the user +// already has, taken as they are. The CLI reads the files and prompts; a +// program embedding Builder hands the bytes over. Both come through here for +// the type check, the extension matching, the upload and builder.json. + +import ( + "context" + "fmt" + "io" + "maps" + "slices" + "strings" + + "github.com/MobAI-App/ios-builder/internal/config" +) + +// ManualOptions drives SetupManual. +type ManualOptions struct { + // Type is the distribution, as ManualType read it from the profile. + Type Type + // ProfileName is the builder.json profile written with the distribution; + // empty means the distribution's own name. + ProfileName string + // P12 is the certificate with its private key; Password opens it. + P12 []byte + Password string + // Profile is the app's .mobileprovision; ExtensionProfiles are the + // extensions' by bundle ID, as MatchExtensionProfiles pairs them. + Profile []byte + ExtensionProfiles map[string][]byte + // Log receives progress lines; nil discards them. + Log io.Writer + // SaveConfig persists cfg; nil writes builder.json in the working directory. + SaveConfig func(*config.Config) error +} + +// ManualResult is what SetupManual reports. As with Setup, a failed upload is +// not an error: it is recorded here for the caller to report, and the +// profile is then left out of builder.json. +type ManualResult struct { + // SigningSet is the suffix of the secrets written (DEVELOPMENT, AD_HOC, + // STORE, ENTERPRISE). + SigningSet string `json:"signing_set"` + SecretsUploaded bool `json:"secrets_uploaded"` + // GitHubUpload is "ok" or why the upload failed. + GitHubUpload string `json:"github_upload"` + // BuildProfile is the builder.json profile that builds with the set. + BuildProfile string `json:"build_profile"` + // ProfileWritten says BuildProfile was written to builder.json, which + // happens only when the secrets were uploaded. + ProfileWritten bool `json:"profile_written"` + // ReplacedDistribution is the distribution the profile had before, when + // it was a different one. + ReplacedDistribution string `json:"replaced_distribution,omitempty"` + // UploadError is the failed upload, nil when the secrets reached GitHub. + UploadError error `json:"-"` +} + +// SetupManual uploads a signing set the user supplied and records the +// profile in builder.json: `builder signing setup --certificate --profile` +// after its prompts. storeErr is a secret store that could not be built (no +// GitHub login), reported like a failed upload. +func SetupManual(ctx context.Context, store SecretStore, storeErr error, cfg *config.Config, opts *ManualOptions) (*ManualResult, error) { + set, err := config.SigningSet(string(opts.Type)) + if err != nil { + return nil, err + } + profileName := opts.ProfileName + if profileName == "" { + profileName = string(opts.Type) + } + res := &ManualResult{SigningSet: set, BuildProfile: profileName} + res.UploadError = UploadSet(ctx, store, storeErr, cfg, opts.Log, set, opts.P12, opts.Password, opts.Profile, opts.ExtensionProfiles) + res.SecretsUploaded = res.UploadError == nil + res.GitHubUpload = "ok" + if res.UploadError != nil { + res.GitHubUpload = res.UploadError.Error() + } + + // The profile is written only when its secrets reached the repository: + // builder.json must not claim a signing set the repository does not have. + if res.SecretsUploaded { + res.ReplacedDistribution = WriteProfile(cfg, profileName, opts.Type) + res.ProfileWritten = true + } + if err := saveConfig(opts.SaveConfig, cfg); err != nil { + return res, err + } + return res, nil +} + +// ManualType is what the .mobileprovision says it is. A distribution that +// disagrees is an error, since the runner refuses such a pair; empty takes +// the profile's word. +func ManualType(profileData []byte, distribution string) (Type, error) { + typ, err := ProfileType(profileData) + if err != nil { + return "", err + } + if distribution == "" { + return typ, nil + } + want, err := ParseType(distribution) + 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 +} + +// MatchExtensionProfiles pairs every extension in extensions with the key +// of the profile (name → 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 Type) (map[string]string, error) { + appIDs := make(map[string]string, len(files)) + for _, path := range slices.Sorted(maps.Keys(files)) { + fileType, err := 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 = 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 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 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 +} diff --git a/internal/signing/manual_test.go b/internal/signing/manual_test.go new file mode 100644 index 0000000..1e7599c --- /dev/null +++ b/internal/signing/manual_test.go @@ -0,0 +1,199 @@ +package signing + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "errors" + "os" + "slices" + "strings" + "testing" + + "golang.org/x/crypto/nacl/box" + + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/github" +) + +// fakeSecrets is a SecretStore that opens what it is given, so a test can +// read the values back. +type fakeSecrets struct { + pub, priv *[32]byte + stored map[string]string + names []string + writeErr error +} + +func newFakeSecrets(t *testing.T) *fakeSecrets { + t.Helper() + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return &fakeSecrets{pub: pub, priv: priv, stored: map[string]string{}} +} + +func (f *fakeSecrets) GetPublicKey(context.Context, string, string) (*github.PublicKey, error) { + return &github.PublicKey{KeyID: "key-1", Key: base64.StdEncoding.EncodeToString(f.pub[:])}, nil +} + +func (f *fakeSecrets) CreateOrUpdateSecret(_ context.Context, _, _, name, encryptedValue, _ string) error { + if f.writeErr != nil { + return f.writeErr + } + sealed, err := base64.StdEncoding.DecodeString(encryptedValue) + if err != nil { + return err + } + value, ok := box.OpenAnonymous(nil, sealed, f.pub, f.priv) + if !ok { + return os.ErrInvalid + } + f.stored[name] = string(value) + f.names = append(f.names, name) + return nil +} + +func (f *fakeSecrets) ListSecretNames(context.Context, string, string) ([]string, error) { + return f.names, nil +} + +func storeProfile(appID string) []byte { + return mobileprovision("TeamIdentifierABCDE12345Entitlementsget-task-allowapplication-identifierABCDE12345." + appID + "") +} + +// SetupManual uploads the four secrets of the profile's set, writes the +// build profile and hands builder.json to SaveConfig instead of the disk. +func TestSetupManualUploadsAndSavesThroughCallback(t *testing.T) { + store := newFakeSecrets(t) + cfg := &config.Config{Project: "App", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}, IOS: config.IOSConfig{Extensions: []string{"com.example.app.widget"}}} + var saved *config.Config + widget := storeProfile("com.example.app.widget") + res, err := SetupManual(context.Background(), store, nil, cfg, &ManualOptions{ + Type: TypeStore, P12: []byte("p12 bytes"), Password: "pw", Profile: storeProfile("com.example.app"), + ExtensionProfiles: map[string][]byte{"com.example.app.widget": widget}, + SaveConfig: func(c *config.Config) error { saved = c; return nil }, + }) + if err != nil { + t.Fatal(err) + } + if !res.SecretsUploaded || res.GitHubUpload != "ok" || res.SigningSet != "STORE" || res.BuildProfile != "store" || !res.ProfileWritten || res.ReplacedDistribution != "" { + t.Errorf("result = %+v", res) + } + want := []string{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE", "IOS_EXTENSION_PROFILES_STORE"} + if !slices.Equal(store.names, want) { + t.Errorf("uploaded %v, want %v", store.names, want) + } + if store.stored["IOS_CERTIFICATE_STORE"] != base64.StdEncoding.EncodeToString([]byte("p12 bytes")) || store.stored["IOS_CERTIFICATE_PASSWORD_STORE"] != "pw" { + t.Errorf("certificate secrets: %q", store.stored) + } + if got, err := DecodeExtensionProfiles(store.stored["IOS_EXTENSION_PROFILES_STORE"]); err != nil || !bytes.Equal(got["com.example.app.widget"], widget) { + t.Errorf("extension profiles: %q, %v", store.stored["IOS_EXTENSION_PROFILES_STORE"], err) + } + if saved != cfg || cfg.Profiles["store"].Distribution != "store" { + t.Errorf("saved %v, profiles %v", saved, cfg.Profiles) + } + if _, err := os.Stat(config.ConfigFileName); err == nil { + t.Error("builder.json written to the working directory despite SaveConfig") + } +} + +// A failed upload is reported in the result, not as an error, and the profile +// stays out of builder.json: it must not claim a set the repository lacks. +// The config is still saved, as the CLI does. +func TestSetupManualReportsUploadFailure(t *testing.T) { + store := newFakeSecrets(t) + store.writeErr = errors.New("no admin access") + cfg := &config.Config{GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}, Profiles: map[string]config.Profile{"beta": {Distribution: "development", Scheme: "Beta"}}} + saved := false + res, err := SetupManual(context.Background(), store, nil, cfg, &ManualOptions{ + Type: TypeAdHoc, ProfileName: "beta", P12: []byte("p12"), Password: "pw", Profile: storeProfile("com.example.app"), + SaveConfig: func(*config.Config) error { saved = true; return nil }, + }) + if err != nil { + t.Fatal(err) + } + if res.SecretsUploaded || res.ProfileWritten || res.UploadError == nil || !strings.Contains(res.GitHubUpload, "no admin access") { + t.Errorf("result = %+v", res) + } + if !saved || res.ReplacedDistribution != "" || cfg.Profiles["beta"].Distribution != "development" { + t.Errorf("profile: saved %v, replaced %q, %+v", saved, res.ReplacedDistribution, cfg.Profiles["beta"]) + } + // With the upload working, the same call replaces the profile's distribution. + store.writeErr = nil + res, err = SetupManual(context.Background(), store, nil, cfg, &ManualOptions{ + Type: TypeAdHoc, ProfileName: "beta", P12: []byte("p12"), Password: "pw", Profile: storeProfile("com.example.app"), + SaveConfig: func(*config.Config) error { return nil }, + }) + if err != nil || !res.ProfileWritten || res.ReplacedDistribution != "development" || cfg.Profiles["beta"].Distribution != "ad-hoc" || cfg.Profiles["beta"].Scheme != "Beta" { + t.Errorf("replaced profile: %+v, %v, %+v", res, err, cfg.Profiles["beta"]) + } + // A store that could not be built counts as a failed upload too. + res, err = SetupManual(context.Background(), nil, errors.New("not logged in"), cfg, &ManualOptions{ + Type: TypeAdHoc, ProfileName: "beta", P12: []byte("p12"), Password: "pw", Profile: storeProfile("com.example.app"), + SaveConfig: func(*config.Config) error { return nil }, + }) + if err != nil || res.SecretsUploaded || res.GitHubUpload != "not logged in" { + t.Errorf("no store: %+v, %v", res, err) + } +} + +// A SaveConfig that fails is the error, with the upload result kept. +func TestSetupManualSaveFailure(t *testing.T) { + cfg := &config.Config{GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}} + res, err := SetupManual(context.Background(), newFakeSecrets(t), nil, cfg, &ManualOptions{ + Type: TypeDevelopment, P12: []byte("p12"), Password: "pw", Profile: storeProfile("com.example.app"), + SaveConfig: func(*config.Config) error { return errors.New("disk full") }, + }) + if err == nil || !strings.Contains(err.Error(), "failed to update config: disk full") || res == nil || !res.SecretsUploaded { + t.Fatalf("save failure: %+v, %v", res, err) + } +} + +// Without SaveConfig, builder.json lands in the working directory: the CLI's +// behavior, unchanged. +func TestSaveConfigDefaultsToWorkingDirectory(t *testing.T) { + t.Chdir(t.TempDir()) + cfg := &config.Config{Project: "App"} + if err := saveConfig(nil, cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.NewManager().Load() + if err != nil || loaded.Project != "App" { + t.Fatalf("builder.json: %+v, %v", loaded, err) + } +} + +func TestManualType(t *testing.T) { + dev := mobileprovision("ProvisionedDevices00008030-1Entitlementsget-task-allow") + if typ, err := ManualType(storeProfile("com.example.app"), ""); err != nil || typ != TypeStore { + t.Fatalf("store profile: %q %v", typ, err) + } + // The distribution may confirm the type, aliases included, but not change it. + if typ, err := ManualType(dev, "development"); err != nil || typ != TypeDevelopment { + t.Fatalf("development: %q %v", typ, err) + } + if _, err := ManualType(dev, "internal"); err == nil || !strings.Contains(err.Error(), "ad-hoc") { + t.Fatalf("disagreeing distribution accepted: %v", err) + } + if _, err := ManualType([]byte("not a profile"), ""); err == nil { + t.Fatal("unreadable profile accepted") + } +} + +func TestMatchExtensionProfiles(t *testing.T) { + widget, share, wildcard := storeProfile("com.example.app.widget"), storeProfile("com.example.app.share"), storeProfile("com.example.*") + extensions := []string{"com.example.app.share", "com.example.app.widget"} + got, err := MatchExtensionProfiles(extensions, map[string][]byte{"any": wildcard, "w": widget}, TypeStore) + if err != nil || got["com.example.app.widget"] != "w" || got["com.example.app.share"] != "any" { + t.Fatalf("exact over wildcard: %v, %v", got, err) + } + if _, err = MatchExtensionProfiles(extensions, map[string][]byte{"s": share}, TypeStore); err == nil || !strings.Contains(err.Error(), "extension com.example.app.widget has no profile") { + t.Fatalf("missing profile: %v", err) + } + if _, err = MatchExtensionProfiles(nil, map[string][]byte{"w": widget}, TypeDevelopment); err == nil || !strings.Contains(err.Error(), "w is a store profile, but the app profile is development") { + t.Fatalf("type mismatch: %v", err) + } +} diff --git a/internal/signing/sets.go b/internal/signing/sets.go index 33b0843..5db6ad0 100644 --- a/internal/signing/sets.go +++ b/internal/signing/sets.go @@ -75,6 +75,22 @@ type SetupOptions struct { OutDirAsGiven string // Log receives progress lines; nil discards them. Log io.Writer + // SaveConfig persists cfg with the profile written; nil writes + // builder.json in the working directory, as the CLI does. A program with + // no checkout (one that commits builder.json through the GitHub API, say) + // supplies its own. + SaveConfig func(*config.Config) error +} + +// saveConfig is SaveConfig, or builder.json in the working directory. +func saveConfig(save func(*config.Config) error, cfg *config.Config) error { + if save == nil { + save = config.NewManager().Save + } + if err := save(cfg); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + return nil } // SetupResult is what Setup reports. A failed upload is not an error: the @@ -150,8 +166,8 @@ func Setup(ctx context.Context, client *asc.Client, store SecretStore, storeErr if cfg.IOS.BundleID == "" { cfg.IOS.BundleID = opts.BundleID } - if err := config.NewManager().Save(cfg); err != nil { - return res, fmt.Errorf("failed to update config: %w", err) + if err := saveConfig(opts.SaveConfig, cfg); err != nil { + return res, err } return res, nil } diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 75dbc05..0c0261e 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -13,7 +13,10 @@ import ( // ErrNotAuthenticated indicates no stored authentication token was found. var ErrNotAuthenticated = auth.ErrNotAuthenticated -type Token = auth.Token +type ( + Token = auth.Token + DeviceCode = auth.DeviceCode +) // Login performs GitHub OAuth Device Code flow authentication, printing the // verification URL and code to stdout, and stores the resulting token. @@ -21,6 +24,20 @@ func Login(ctx context.Context) (*Token, error) { return auth.Login(ctx) } +// RequestDeviceCode and PollForToken are Login's two halves for a program +// with its own UI: show code.UserCode and code.VerificationURI, then poll. +// The token comes back unstored; keep it wherever the program keeps secrets +// and hand it to github.NewClient. +func RequestDeviceCode(ctx context.Context) (*DeviceCode, error) { + return auth.RequestDeviceCode(ctx) +} + +// PollForToken waits until the user has authorized code, or it expires or +// ctx ends. +func PollForToken(ctx context.Context, code *DeviceCode) (*Token, error) { + return auth.PollForToken(ctx, code) +} + // GetToken retrieves the stored GitHub token from the OS keychain or file storage. func GetToken() (string, error) { return auth.GetToken() diff --git a/pkg/github/github.go b/pkg/github/github.go index c71b041..c92cf4b 100644 --- a/pkg/github/github.go +++ b/pkg/github/github.go @@ -12,17 +12,23 @@ const ( ) type ( - Client = github.Client - Repository = github.Repository - WorkflowRun = github.WorkflowRun - Job = github.Job - JobStep = github.JobStep - Artifact = github.Artifact - PublicKey = github.PublicKey - APIError = github.APIError - ProgressFunc = github.ProgressFunc + Client = github.Client + Repository = github.Repository + WorkflowRun = github.WorkflowRun + Job = github.Job + JobStep = github.JobStep + Artifact = github.Artifact + PublicKey = github.PublicKey + APIError = github.APIError + ProgressFunc = github.ProgressFunc + FileContent = github.FileContent + PutFileRequest = github.PutFileRequest + FileCommit = github.FileCommit ) +// ErrFileNotFound is Client.GetFile's answer for a path the branch does not have. +var ErrFileNotFound = github.ErrFileNotFound + // NewClient creates a GitHub API client authenticated with token. func NewClient(token string) *Client { return github.NewClient(token) diff --git a/pkg/signing/signing.go b/pkg/signing/signing.go index b74ba36..d879f56 100644 --- a/pkg/signing/signing.go +++ b/pkg/signing/signing.go @@ -29,6 +29,8 @@ type ( Commands = signing.Commands SetupOptions = signing.SetupOptions SetupResult = signing.SetupResult + ManualOptions = signing.ManualOptions + ManualResult = signing.ManualResult EnsureOptions = signing.EnsureOptions ) @@ -62,6 +64,33 @@ func Setup(ctx context.Context, client *asc.Client, store SecretStore, storeErr return signing.Setup(ctx, client, store, storeErr, cfg, opts) } +// SetupManual uploads a signing set the caller already has (a .p12 and the +// profiles) and records the profile in builder.json: `builder signing setup +// --certificate --profile` without its prompts. +func SetupManual(ctx context.Context, store SecretStore, storeErr error, cfg *config.Config, opts *ManualOptions) (*ManualResult, error) { + return signing.SetupManual(ctx, store, storeErr, cfg, opts) +} + +// ManualType is the distribution a .mobileprovision is for; a non-empty +// distribution that disagrees with it is an error. +func ManualType(profileData []byte, distribution string) (Type, error) { + return signing.ManualType(profileData, distribution) +} + +// MatchExtensionProfiles pairs every extension bundle ID with the key of the +// profile (name to contents) whose app id covers it, for +// ManualOptions.ExtensionProfiles. +func MatchExtensionProfiles(extensions []string, files map[string][]byte, typ Type) (map[string]string, error) { + return signing.MatchExtensionProfiles(extensions, files, typ) +} + +// BuildP12 combines a PEM private key with the certificate Apple issued for +// it (DER or PEM) into a password-protected PKCS#12, the file Keychain +// Access exports and ManualOptions.P12 takes. +func BuildP12(keyPEM, certData []byte, password string) ([]byte, error) { + return signing.BuildP12(keyPEM, certData, password) +} + // EnsureSecrets provisions a missing or partial signing set before a build // is dispatched, so a distribution build never fails on the runner for want // of secrets. A nil error with nothing logged means the set was complete.