diff --git a/go.mod b/go.mod index 399b3c7..8ccefaf 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.3.2 github.com/fatih/color v1.16.0 github.com/spf13/cobra v1.8.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index 9f84a32..bf59db9 100644 --- a/go.sum +++ b/go.sum @@ -19,5 +19,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/crashes/crashes.go b/src/crashes/crashes.go new file mode 100644 index 0000000..47bfec1 --- /dev/null +++ b/src/crashes/crashes.go @@ -0,0 +1,988 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) OwnPulse Contributors + +// Package crashes implements an App Store Connect API client for iOS crash +// diagnosis. It is a Go port of ops/asc_client.py from the ownpulse repo. +package crashes + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + ascBaseURL = "https://api.appstoreconnect.apple.com" + ascHost = "api.appstoreconnect.apple.com" + ascAudience = "appstoreconnect-v1" + jwtExpirySeconds = 20 * 60 + sopsRelPath = "secrets/ios/appstore-connect.sops.yaml" +) + +// Credentials hold App Store Connect API credentials. KeyPEM never touches disk. +type Credentials struct { + KeyID string + IssuerID string + AppID string + KeyPEM []byte +} + +// CredentialOptions controls credential resolution. Provide either explicit +// KeyID/IssuerID/AppID/KeyPEM or a SOPSPath. Other fields are optional. +type CredentialOptions struct { + KeyID string + IssuerID string + AppID string + KeyPEM []byte + + SOPSPath string + + // SopsRunner allows tests to stub out the `sops -d` invocation. Production + // code leaves this nil — real exec.Command is used. + SopsRunner SopsRunner +} + +// SopsRunner runs `sops -d ` and returns stdout. It returns the wrapped +// exec.ExitError on non-zero exit; tests can return errSopsNotInstalled to +// mimic a missing binary. +type SopsRunner func(path string) (stdout []byte, err error) + +// ErrSopsNotInstalled is returned (or wrapped) by SopsRunner when the `sops` +// binary cannot be found on PATH. +var ErrSopsNotInstalled = errors.New("sops not installed") + +// HTTPGetter is a minimal HTTP GET function for dependency injection in tests. +type HTTPGetter func(url string, headers map[string]string) ([]byte, error) + +// MissingCredentialsError is returned by ResolveCredentials when one or more +// credential fields could not be resolved from any source. Callers can use +// errors.As to detect this and add their own context (e.g. the workspace-config +// SOPS path opdev's main looked at). +type MissingCredentialsError struct { + Fields []string +} + +func (e *MissingCredentialsError) Error() string { + return "credentials missing — " + strings.Join(e.Fields, ", ") +} + +// ASCError represents a non-success App Store Connect response. +type ASCError struct { + Status int + URL string + Body string +} + +func (e *ASCError) Error() string { + body := e.Body + if len(body) > 500 { + body = body[:500] + } + return fmt.Sprintf("ASC %d on %s: %s", e.Status, e.URL, body) +} + +// -------------------------------------------------------------------------- +// JWT +// -------------------------------------------------------------------------- + +func b64url(data []byte) string { + return base64.RawURLEncoding.EncodeToString(data) +} + +// MintJWT mints an ES256 JWT for App Store Connect with a 20-minute expiry. +// The signature is the raw R||S concatenation (64 bytes), not DER. +func MintJWT(creds *Credentials) (string, error) { + if creds == nil { + return "", errors.New("nil credentials") + } + block, _ := pem.Decode(creds.KeyPEM) + if block == nil { + return "", errors.New("failed to load private key: no PEM block found") + } + + ecKey, err := loadECPrivateKey(block.Bytes) + if err != nil { + return "", err + } + + if ecKey.Curve != elliptic.P256() { + return "", errors.New("expected an EC P-256 private key (ES256)") + } + + header := map[string]string{"alg": "ES256", "kid": creds.KeyID, "typ": "JWT"} + now := time.Now().Unix() + payload := map[string]interface{}{ + "iss": creds.IssuerID, + "iat": now, + "exp": now + jwtExpirySeconds, + "aud": ascAudience, + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return "", err + } + + headerB64 := b64url(headerJSON) + payloadB64 := b64url(payloadJSON) + signingInput := headerB64 + "." + payloadB64 + + digest := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, ecKey, digest[:]) + if err != nil { + return "", fmt.Errorf("ECDSA sign failed: %w", err) + } + + // Raw R||S — each component padded to 32 bytes (P-256). + rawSig := make([]byte, 64) + rBytes := r.Bytes() + sBytes := s.Bytes() + copy(rawSig[32-len(rBytes):32], rBytes) + copy(rawSig[64-len(sBytes):64], sBytes) + + return signingInput + "." + b64url(rawSig), nil +} + +// loadECPrivateKey parses a DER-encoded private key, accepting either PKCS#8 +// or SEC1 encoding. If both parsers fail, both errors are surfaced — the +// PKCS#8 error is wrapped (errors.Is/As works against it) and the SEC1 error +// is included in the message for human debugging. +func loadECPrivateKey(der []byte) (*ecdsa.PrivateKey, error) { + key, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + ecKey, err2 := x509.ParseECPrivateKey(der) + if err2 != nil { + return nil, fmt.Errorf("failed to load private key (pkcs8: %w; sec1: %v)", err, err2) + } + return ecKey, nil + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("expected an EC P-256 private key (ES256), got %T", key) + } + return ecKey, nil +} + +// -------------------------------------------------------------------------- +// Credentials +// -------------------------------------------------------------------------- + +// sopsYAML mirrors the decrypted SOPS YAML document. Production uses +// `asc_api_key_b64` (base64-encoded .p8 contents); `key_pem` is kept as a +// fallback for ad-hoc / test files that store the PEM literally. +type sopsYAML struct { + KeyID string `yaml:"key_id"` + IssuerID string `yaml:"issuer_id"` + AppID string `yaml:"app_id"` + KeyPEM string `yaml:"key_pem"` // legacy / test path: raw PEM block + APIKeyB64 string `yaml:"asc_api_key_b64"` // production path: base64(.p8 contents) +} + +// LoadCredentials resolves credentials from explicit fields, or by invoking +// `sops -d ` and parsing the resulting YAML. The PEM is never written +// to disk. +func LoadCredentials(opts CredentialOptions) (*Credentials, error) { + if opts.SOPSPath != "" { + return loadFromSOPS(opts) + } + + var missing []string + if opts.KeyID == "" { + missing = append(missing, "key_id") + } + if opts.IssuerID == "" { + missing = append(missing, "issuer_id") + } + if opts.AppID == "" { + missing = append(missing, "app_id") + } + if len(opts.KeyPEM) == 0 { + missing = append(missing, "key_pem") + } + if len(missing) > 0 { + return nil, fmt.Errorf("missing credential fields: %s", strings.Join(missing, ", ")) + } + + return &Credentials{ + KeyID: opts.KeyID, + IssuerID: opts.IssuerID, + AppID: opts.AppID, + KeyPEM: opts.KeyPEM, + }, nil +} + +func loadFromSOPS(opts CredentialOptions) (*Credentials, error) { + info, err := os.Stat(opts.SOPSPath) + if err != nil || info.IsDir() { + return nil, fmt.Errorf("SOPS file not found: %s", opts.SOPSPath) + } + + runner := opts.SopsRunner + if runner == nil { + runner = defaultSopsRunner + } + stdout, err := runner(opts.SOPSPath) + if err != nil { + if errors.Is(err, ErrSopsNotInstalled) { + return nil, errors.New("'sops' not found in PATH; install with: brew install sops (or your platform equivalent)") + } + return nil, fmt.Errorf("sops -d failed: %w", err) + } + + var doc sopsYAML + if err := yaml.Unmarshal(stdout, &doc); err != nil { + return nil, fmt.Errorf("parsing SOPS YAML: %w", err) + } + + // Resolve PEM bytes. Production stores the .p8 as base64 in + // `asc_api_key_b64`; legacy/test files use literal `key_pem`. + // Base64 wins when both are present. + var pemBytes []byte + switch { + case doc.APIKeyB64 != "": + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(doc.APIKeyB64)) + if err != nil { + return nil, fmt.Errorf("decoding asc_api_key_b64 from SOPS YAML: %w", err) + } + pemBytes = decoded + case doc.KeyPEM != "": + pemBytes = []byte(doc.KeyPEM) + } + + var missing []string + if doc.KeyID == "" { + missing = append(missing, "key_id") + } + if doc.IssuerID == "" { + missing = append(missing, "issuer_id") + } + if doc.AppID == "" { + missing = append(missing, "app_id") + } + if len(pemBytes) == 0 { + missing = append(missing, "missing PEM: set either asc_api_key_b64 (base64) or key_pem (PEM literal) in the SOPS YAML") + } + if len(missing) > 0 { + return nil, fmt.Errorf("SOPS file missing required fields: %s", strings.Join(missing, ", ")) + } + + return &Credentials{ + KeyID: doc.KeyID, + IssuerID: doc.IssuerID, + AppID: doc.AppID, + KeyPEM: pemBytes, + }, nil +} + +func defaultSopsRunner(path string) ([]byte, error) { + if _, err := exec.LookPath("sops"); err != nil { + return nil, ErrSopsNotInstalled + } + cmd := exec.Command("sops", "-d", path) + var stderr strings.Builder + cmd.Stderr = &stderr + stdout, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("sops invocation failed: %s: %w", strings.TrimSpace(stderr.String()), err) + } + return stdout, nil +} + +// -------------------------------------------------------------------------- +// HTTP +// -------------------------------------------------------------------------- + +// bearerRedactRe matches `Bearer ` in error bodies so the token is +// stripped before the body is surfaced. Apple has historically echoed request +// headers in some error responses; this is a cheap defense. +var bearerRedactRe = regexp.MustCompile(`(?i)Bearer\s+\S+`) + +func redactBearer(s string) string { + return bearerRedactRe.ReplaceAllString(s, "Bearer [REDACTED]") +} + +// TODO(opdev): thread context.Context through the network layer once we wire +// in a cancellation source above this CLI (signals, timeouts beyond per-request). +// For a one-shot CLI this is acceptable; refactor when we add long-running modes. + +// DefaultHTTPGetter performs a real HTTPS GET. It is wired in by the CLI; tests +// inject a stub instead. +// +// Redirects are NOT followed: a 3xx hops away from the App Store Connect host +// would either silently leak the bearer token or, if we host-checked the new +// URL, generate confusing errors. The caller (ascGet) already pins the host. +func DefaultHTTPGetter(rawURL string, headers map[string]string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, &ASCError{Status: resp.StatusCode, URL: rawURL, Body: redactBearer(string(body))} + } + return body, nil +} + +// assertASCHost pins outbound requests to https://api.appstoreconnect.apple.com. +// +// It enforces: +// - scheme is exactly "https" (case-insensitive) +// - hostname equals "api.appstoreconnect.apple.com" exactly (case-insensitive) +// — guards against suffix attacks like "api.appstoreconnect.apple.com.evil.com" +// - no userinfo segment ("https://user@evil.com/...") — even though url.Parse +// would return the userinfo's host, we reject any URL that carries one +// - non-empty hostname (rejects IP-literal-only or malformed inputs) +func assertASCHost(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return &ASCError{Status: 0, URL: rawURL, Body: fmt.Sprintf("invalid URL: %v", err)} + } + if u.User != nil { + return &ASCError{Status: 0, URL: rawURL, Body: "refusing URL with userinfo segment"} + } + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + if scheme != "https" || host != ascHost { + return &ASCError{Status: 0, URL: rawURL, Body: fmt.Sprintf("refusing to follow off-host URL: %s", rawURL)} + } + return nil +} + +func ascGet(token, rawURL string, httpGet HTTPGetter) (map[string]interface{}, error) { + if err := assertASCHost(rawURL); err != nil { + return nil, err + } + body, err := httpGet(rawURL, map[string]string{ + "Authorization": "Bearer " + token, + "Accept": "application/json", + }) + if err != nil { + return nil, err + } + if len(body) == 0 { + return map[string]interface{}{}, nil + } + var out map[string]interface{} + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("parsing ASC response from %s: %w", rawURL, err) + } + return out, nil +} + +// -------------------------------------------------------------------------- +// Builds +// -------------------------------------------------------------------------- + +// Build mirrors the relevant subset of an App Store Connect build resource. +type Build struct { + ID string `json:"id"` + Attributes map[string]interface{} `json:"attributes"` + Raw map[string]interface{} `json:"raw,omitempty"` +} + +// ListBuilds returns builds for an app, newest first, optionally filtered to +// those uploaded since `since`. It paginates via `links.next`, asserting that +// every URL stays on the App Store Connect host with HTTPS. +func ListBuilds(token, appID string, since *time.Time, httpGet HTTPGetter) ([]Build, error) { + next := fmt.Sprintf( + "%s/v1/builds?filter%%5Bapp%%5D=%s&sort=-uploadedDate&limit=200", + ascBaseURL, url.QueryEscape(appID), + ) + + var builds []Build + for next != "" { + payload, err := ascGet(token, next, httpGet) + if err != nil { + return nil, err + } + if data, ok := payload["data"].([]interface{}); ok { + for _, item := range data { + if obj, ok := item.(map[string]interface{}); ok { + b := Build{Raw: obj} + if id, ok := obj["id"].(string); ok { + b.ID = id + } + if attrs, ok := obj["attributes"].(map[string]interface{}); ok { + b.Attributes = attrs + } + builds = append(builds, b) + } + } + } + next = "" + if links, ok := payload["links"].(map[string]interface{}); ok { + if n, ok := links["next"].(string); ok && n != "" { + next = n + } + } + } + + if since == nil { + return builds, nil + } + + cutoff := since.UTC() + filtered := make([]Build, 0, len(builds)) + for _, b := range builds { + ts, ok := parseUploadedDate(b.Attributes) + if !ok { + continue + } + if !ts.Before(cutoff) { + filtered = append(filtered, b) + } + } + return filtered, nil +} + +func parseUploadedDate(attrs map[string]interface{}) (time.Time, bool) { + if attrs == nil { + return time.Time{}, false + } + raw, ok := attrs["uploadedDate"].(string) + if !ok || raw == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05-0700"} { + if t, err := time.Parse(layout, raw); err == nil { + return t.UTC(), true + } + } + return time.Time{}, false +} + +// -------------------------------------------------------------------------- +// Crash feedback +// -------------------------------------------------------------------------- + +// Diagnostic is the normalized crash/feedback record returned to callers. +// All nullable fields are pointers so JSON output omits them cleanly via +// omitempty where appropriate. +type Diagnostic struct { + BuildID string `json:"build_id"` + Signature *string `json:"signature"` + Signal *string `json:"signal"` + Stack interface{} `json:"stack"` + OSVersion *string `json:"os_version"` + Device *string `json:"device"` + Count *int `json:"count"` + FirstSeen *string `json:"first_seen"` + LastSeen *string `json:"last_seen"` + TesterNotes *string `json:"tester_notes,omitempty"` + Raw map[string]interface{} `json:"raw,omitempty"` +} + +// Field mapping is provisional — Apple's perfPowerMetrics crash schema is not formally documented. Adjust when we see real responses. +func normalizeDiagnostic(raw map[string]interface{}, buildID string) Diagnostic { + attrs, _ := raw["attributes"].(map[string]interface{}) + d := Diagnostic{BuildID: buildID, Raw: raw} + + d.Signature = firstString(attrs, "signature", "symbol") + if d.Signature == nil { + if id, ok := raw["id"].(string); ok && id != "" { + s := id + d.Signature = &s + } + } + d.Signal = firstString(attrs, "signal", "exceptionType") + if attrs != nil { + for _, k := range []string{"stack", "callStack", "symbols"} { + if v, ok := attrs[k]; ok && v != nil { + d.Stack = v + break + } + } + } + d.OSVersion = firstString(attrs, "osVersion", "platformVersion") + d.Device = firstString(attrs, "deviceModel", "device") + d.Count = firstInt(attrs, "count", "occurrences") + d.FirstSeen = firstString(attrs, "firstSeen", "startDate") + d.LastSeen = firstString(attrs, "lastSeen", "endDate") + return d +} + +func firstString(m map[string]interface{}, keys ...string) *string { + if m == nil { + return nil + } + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + if s, ok := v.(string); ok && s != "" { + cp := s + return &cp + } + } + } + return nil +} + +func firstInt(m map[string]interface{}, keys ...string) *int { + if m == nil { + return nil + } + for _, k := range keys { + v, ok := m[k] + if !ok || v == nil { + continue + } + switch t := v.(type) { + case float64: + n := int(t) + return &n + case int: + n := t + return &n + case string: + if n, err := strconv.Atoi(t); err == nil { + return &n + } + } + } + return nil +} + +// CrashFeedback fetches crash signatures plus tester notes for a build. 404 on +// either endpoint is swallowed with a stderr warning. +func CrashFeedback(token, buildID string, httpGet HTTPGetter) ([]Diagnostic, error) { + var results []Diagnostic + + metricsURL := fmt.Sprintf("%s/v1/builds/%s/perfPowerMetrics", ascBaseURL, url.PathEscape(buildID)) + metrics, err := ascGet(token, metricsURL, httpGet) + if err != nil { + var ascErr *ASCError + if errors.As(err, &ascErr) && ascErr.Status == 404 { + fmt.Fprintf(os.Stderr, "warning: perfPowerMetrics 404 for build %s\n", buildID) + } else { + return nil, err + } + } else if data, ok := metrics["data"].([]interface{}); ok { + for _, item := range data { + if obj, ok := item.(map[string]interface{}); ok { + results = append(results, normalizeDiagnostic(obj, buildID)) + } + } + } + + locURL := fmt.Sprintf("%s/v1/builds/%s/betaBuildLocalizations", ascBaseURL, url.PathEscape(buildID)) + locs, err := ascGet(token, locURL, httpGet) + if err != nil { + var ascErr *ASCError + if errors.As(err, &ascErr) && ascErr.Status == 404 { + fmt.Fprintf(os.Stderr, "warning: betaBuildLocalizations 404 for build %s\n", buildID) + } else { + return nil, err + } + } else if data, ok := locs["data"].([]interface{}); ok { + for _, item := range data { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + attrs, _ := obj["attributes"].(map[string]interface{}) + notes := firstString(attrs, "whatsNew") + if notes == nil { + continue + } + results = append(results, Diagnostic{ + BuildID: buildID, + TesterNotes: notes, + Raw: obj, + }) + } + } + + return results, nil +} + +// -------------------------------------------------------------------------- +// Diagnose orchestration +// -------------------------------------------------------------------------- + +var durationRe = regexp.MustCompile(`^(\d+)([smhdw])$`) + +// ParseSince parses a duration spec like "24h" or "7d", or an ISO 8601 +// timestamp, into a UTC time.Time relative to time.Now(). +func ParseSince(spec string) (time.Time, error) { + spec = strings.TrimSpace(spec) + if m := durationRe.FindStringSubmatch(spec); m != nil { + n, _ := strconv.Atoi(m[1]) + var unit time.Duration + switch m[2] { + case "s": + unit = time.Second + case "m": + unit = time.Minute + case "h": + unit = time.Hour + case "d": + unit = 24 * time.Hour + case "w": + unit = 7 * 24 * time.Hour + } + return time.Now().UTC().Add(-time.Duration(n) * unit), nil + } + normalized := spec + if strings.HasSuffix(normalized, "Z") { + normalized = strings.TrimSuffix(normalized, "Z") + "+00:00" + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05-07:00"} { + if t, err := time.Parse(layout, normalized); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, fmt.Errorf("invalid --since %q: use e.g. 24h, 7d, or ISO 8601", spec) +} + +// DiagnoseOptions wires the diagnose CLI flags to the orchestrator. +type DiagnoseOptions struct { + AppID string // override; empty means use creds.AppID + Since *time.Time + BuildVer string // filter to a specific build version + DeviceID string // accepted but Apple-side-limited; warning emitted + SignalName string // client-side filter + HTTPGet HTTPGetter +} + +// DiagnoseResult is the output of Diagnose — a flat list of diagnostics grouped +// for rendering. +type DiagnoseResult struct { + Builds []Build `json:"builds"` + Diagnostics []Diagnostic `json:"diagnostics"` +} + +// Diagnose mints a JWT, lists builds (applying Since + BuildVer filters), +// pulls crash feedback for each, applies the Signal filter, and returns a +// DiagnoseResult. +func Diagnose(creds *Credentials, opts DiagnoseOptions) (DiagnoseResult, error) { + var result DiagnoseResult + if opts.HTTPGet == nil { + opts.HTTPGet = DefaultHTTPGetter + } + + token, err := MintJWT(creds) + if err != nil { + return result, err + } + + appID := opts.AppID + if appID == "" { + appID = creds.AppID + } + + builds, err := ListBuilds(token, appID, opts.Since, opts.HTTPGet) + if err != nil { + return result, err + } + + if opts.BuildVer != "" { + filtered := make([]Build, 0, len(builds)) + for _, b := range builds { + if v, ok := b.Attributes["version"].(string); ok && v == opts.BuildVer { + filtered = append(filtered, b) + } + } + builds = filtered + } + + if opts.DeviceID != "" { + fmt.Fprintln(os.Stderr, "warning: --device filter is Apple-side-only and limited in Phase 1.") + } + + result.Builds = builds + for _, b := range builds { + if b.ID == "" { + continue + } + entries, err := CrashFeedback(token, b.ID, opts.HTTPGet) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: crash_feedback(%s) failed: %v\n", b.ID, err) + continue + } + result.Diagnostics = append(result.Diagnostics, entries...) + } + + if opts.SignalName != "" { + needle := strings.ToLower(opts.SignalName) + filtered := make([]Diagnostic, 0, len(result.Diagnostics)) + for _, d := range result.Diagnostics { + if d.Signal != nil && strings.Contains(strings.ToLower(*d.Signal), needle) { + filtered = append(filtered, d) + } + } + result.Diagnostics = filtered + } + + return result, nil +} + +// -------------------------------------------------------------------------- +// Rendering +// -------------------------------------------------------------------------- + +// RenderTable writes a human-readable grouped table of diagnostics to w. +func RenderTable(w io.Writer, result DiagnoseResult) { + entries := result.Diagnostics + if len(entries) == 0 { + fmt.Fprintln(w, "(no crash entries returned)") + return + } + + type group struct { + sig string + items []Diagnostic + } + groupMap := map[string]*group{} + var order []string + for _, e := range entries { + sig := "(no signature)" + if e.Signature != nil && *e.Signature != "" { + sig = *e.Signature + } + if _, ok := groupMap[sig]; !ok { + groupMap[sig] = &group{sig: sig} + order = append(order, sig) + } + groupMap[sig].items = append(groupMap[sig].items, e) + } + sort.Strings(order) // deterministic output + + cols := []string{"signature", "signal", "count", "last_seen", "os", "device"} + widths := map[string]int{} + for _, c := range cols { + widths[c] = len(c) + } + type row struct { + fields map[string]string + items []Diagnostic + } + rows := make([]row, 0, len(order)) + for _, sig := range order { + g := groupMap[sig] + first := g.items[0] + total := 0 + for _, it := range g.items { + if it.Count != nil { + total += *it.Count + } + } + if total == 0 { + total = len(g.items) + } + fields := map[string]string{ + "signature": truncate(g.sig, 60), + "signal": strOrEmpty(first.Signal), + "count": strconv.Itoa(total), + "last_seen": strOrEmpty(first.LastSeen), + "os": strOrEmpty(first.OSVersion), + "device": strOrEmpty(first.Device), + } + for _, c := range cols { + if l := len(fields[c]); l > widths[c] { + widths[c] = l + } + } + rows = append(rows, row{fields: fields, items: g.items}) + } + + pad := func(s string, n int) string { + if len(s) >= n { + return s + } + return s + strings.Repeat(" ", n-len(s)) + } + fmtRow := func(get func(string) string) string { + parts := make([]string, len(cols)) + for i, c := range cols { + parts[i] = pad(get(c), widths[c]) + } + return strings.Join(parts, " | ") + } + + fmt.Fprintln(w, fmtRow(func(c string) string { return c })) + sepParts := make([]string, len(cols)) + for i, c := range cols { + sepParts[i] = strings.Repeat("-", widths[c]) + } + fmt.Fprintln(w, strings.Join(sepParts, "-+-")) + for _, r := range rows { + fmt.Fprintln(w, fmtRow(func(c string) string { return r.fields[c] })) + for _, item := range r.items { + if item.Stack != nil { + switch s := item.Stack.(type) { + case []interface{}: + for _, line := range s { + fmt.Fprintf(w, " %v\n", line) + } + case string: + for _, line := range strings.Split(s, "\n") { + fmt.Fprintf(w, " %s\n", line) + } + } + } + if item.TesterNotes != nil { + for _, line := range strings.Split(*item.TesterNotes, "\n") { + fmt.Fprintf(w, " [tester] %s\n", line) + } + } + } + fmt.Fprintln(w) + } +} + +func strOrEmpty(p *string) string { + if p == nil { + return "" + } + return *p +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +// -------------------------------------------------------------------------- +// Defaults +// -------------------------------------------------------------------------- + +// SOPSRelPath is the conventional path inside an ownpulse-infra checkout to +// the App Store Connect credentials YAML. Exported so callers (e.g. opdev's +// main package, which owns workspace-config lookups) can build the absolute +// path themselves. +const SOPSRelPath = sopsRelPath + +// ResolveCredentials resolves credentials field-by-field, with precedence: +// +// 1. Explicit field on `explicit` (e.g. --key-id flag, --app-id flag) +// 2. Environment variable (ASC_KEY_ID, ASC_ISSUER_ID, ASC_APP_ID, ASC_KEY_PEM) +// 3. Field loaded from the SOPS-decrypted YAML at `explicit.SOPSPath` +// +// Each field is resolved independently — passing only --key-id with the rest +// in SOPS produces a merged result rather than silently dropping the flag. +// Returns an error naming every field that could not be filled from any +// source. +// +// This function is workspace-config-agnostic: the SOPS path must be supplied +// by the caller. opdev's main package owns the flag/env/workspace.toml +// resolution and passes the final path in. +// +// Note: there is no --key-pem flag (and intentionally so — PEM contents on the +// command line leak via ps/shell history). Only env or SOPS can supply KeyPEM. +func ResolveCredentials(explicit CredentialOptions) (*Credentials, error) { + // Attempt to load SOPS values up front so we can fall back to them on a + // per-field basis. If SOPS isn't asked for or fails, leave sopsCreds nil + // and report whatever sopsErr we got only if we end up *needing* a SOPS + // field that wasn't otherwise provided. + var ( + sopsCreds *Credentials + sopsErr error + ) + sopsPath := explicit.SOPSPath + if sopsPath != "" { + sopsCreds, sopsErr = LoadCredentials(CredentialOptions{ + SOPSPath: sopsPath, + SopsRunner: explicit.SopsRunner, + }) + } + + pick := func(flag, env string, fromSOPS func(*Credentials) string) string { + if flag != "" { + return flag + } + if v := os.Getenv(env); v != "" { + return v + } + if sopsCreds != nil { + return fromSOPS(sopsCreds) + } + return "" + } + pickBytes := func(flag []byte, env string, fromSOPS func(*Credentials) []byte) []byte { + if len(flag) > 0 { + return flag + } + if v := os.Getenv(env); v != "" { + return []byte(v) + } + if sopsCreds != nil { + return fromSOPS(sopsCreds) + } + return nil + } + + keyID := pick(explicit.KeyID, "ASC_KEY_ID", func(c *Credentials) string { return c.KeyID }) + issuerID := pick(explicit.IssuerID, "ASC_ISSUER_ID", func(c *Credentials) string { return c.IssuerID }) + appID := pick(explicit.AppID, "ASC_APP_ID", func(c *Credentials) string { return c.AppID }) + keyPEM := pickBytes(explicit.KeyPEM, "ASC_KEY_PEM", func(c *Credentials) []byte { return c.KeyPEM }) + + var missing []string + if keyID == "" { + missing = append(missing, "key_id (flag --key-id / env ASC_KEY_ID / SOPS)") + } + if issuerID == "" { + missing = append(missing, "issuer_id (flag --issuer-id / env ASC_ISSUER_ID / SOPS)") + } + if appID == "" { + missing = append(missing, "app_id (flag --app-id / env ASC_APP_ID / SOPS)") + } + if len(keyPEM) == 0 { + missing = append(missing, "key_pem (env ASC_KEY_PEM / SOPS)") + } + + if len(missing) > 0 { + if sopsErr != nil { + return nil, fmt.Errorf("credentials missing — %s; (SOPS load also failed: %w)", + strings.Join(missing, ", "), sopsErr) + } + return nil, &MissingCredentialsError{Fields: missing} + } + + return &Credentials{ + KeyID: keyID, + IssuerID: issuerID, + AppID: appID, + KeyPEM: keyPEM, + }, nil +} diff --git a/src/crashes/crashes_test.go b/src/crashes/crashes_test.go new file mode 100644 index 0000000..4ff1e1d --- /dev/null +++ b/src/crashes/crashes_test.go @@ -0,0 +1,743 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) OwnPulse Contributors + +package crashes + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "math/big" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func genECKeyPEM(t *testing.T) ([]byte, *ecdsa.PublicKey) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + block := &pem.Block{Type: "PRIVATE KEY", Bytes: der} + return pem.EncodeToMemory(block), &priv.PublicKey +} + +func genRSAKeyPEM(t *testing.T) []byte { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatalf("marshal RSA: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) +} + +func b64urlDecode(t *testing.T, s string) []byte { + t.Helper() + out, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + t.Fatalf("base64 decode %q: %v", s, err) + } + return out +} + +// --------------------------------------------------------------------------- +// JWT +// --------------------------------------------------------------------------- + +func TestMintJWT_HeaderShape(t *testing.T) { + pemBytes, _ := genECKeyPEM(t) + tok, err := MintJWT(&Credentials{KeyID: "MYKEYID", IssuerID: "iss", KeyPEM: pemBytes}) + if err != nil { + t.Fatalf("MintJWT: %v", err) + } + parts := strings.Split(tok, ".") + if len(parts) != 3 { + t.Fatalf("token parts = %d, want 3", len(parts)) + } + var hdr map[string]string + if err := json.Unmarshal(b64urlDecode(t, parts[0]), &hdr); err != nil { + t.Fatalf("decode header: %v", err) + } + if hdr["alg"] != "ES256" { + t.Errorf("alg = %q, want ES256", hdr["alg"]) + } + if hdr["kid"] != "MYKEYID" { + t.Errorf("kid = %q, want MYKEYID", hdr["kid"]) + } + if hdr["typ"] != "JWT" { + t.Errorf("typ = %q, want JWT", hdr["typ"]) + } +} + +func TestMintJWT_PayloadShape(t *testing.T) { + pemBytes, _ := genECKeyPEM(t) + before := time.Now().Unix() + tok, err := MintJWT(&Credentials{KeyID: "K", IssuerID: "issuer-uuid", KeyPEM: pemBytes}) + if err != nil { + t.Fatalf("MintJWT: %v", err) + } + after := time.Now().Unix() + + parts := strings.Split(tok, ".") + var payload map[string]interface{} + if err := json.Unmarshal(b64urlDecode(t, parts[1]), &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + + if payload["iss"] != "issuer-uuid" { + t.Errorf("iss = %v", payload["iss"]) + } + if payload["aud"] != "appstoreconnect-v1" { + t.Errorf("aud = %v", payload["aud"]) + } + iat, _ := payload["iat"].(float64) + exp, _ := payload["exp"].(float64) + if int64(iat) < before || int64(iat) > after { + t.Errorf("iat %v not in [%d,%d]", iat, before, after) + } + if int64(exp-iat) != 1200 { + t.Errorf("exp-iat = %v, want 1200", exp-iat) + } +} + +func TestMintJWT_SignatureVerifies(t *testing.T) { + pemBytes, pub := genECKeyPEM(t) + tok, err := MintJWT(&Credentials{KeyID: "K", IssuerID: "I", KeyPEM: pemBytes}) + if err != nil { + t.Fatalf("MintJWT: %v", err) + } + parts := strings.Split(tok, ".") + sig := b64urlDecode(t, parts[2]) + if len(sig) != 64 { + t.Fatalf("signature len = %d, want 64 (raw R||S)", len(sig)) + } + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + signingInput := []byte(parts[0] + "." + parts[1]) + digest := sha256.Sum256(signingInput) + if !ecdsa.Verify(pub, digest[:], r, s) { + t.Fatal("ecdsa.Verify failed against minted signature") + } +} + +func TestMintJWT_RejectsRSA(t *testing.T) { + rsaPEM := genRSAKeyPEM(t) + _, err := MintJWT(&Credentials{KeyID: "K", IssuerID: "I", KeyPEM: rsaPEM}) + if err == nil { + t.Fatal("expected error minting JWT from RSA key, got nil") + } +} + +// --------------------------------------------------------------------------- +// ListBuilds +// --------------------------------------------------------------------------- + +// stubResponse pairs a body with an optional error (used to simulate 404s etc.). +type stubResponse struct { + body []byte + err error +} + +// stubGetter returns canned responses in order. Each call consumes one entry. +type stubGetter struct { + t *testing.T + calls []string + responses []stubResponse + idx int +} + +func (s *stubGetter) get(url string, _ map[string]string) ([]byte, error) { + s.calls = append(s.calls, url) + if s.idx >= len(s.responses) { + s.t.Fatalf("unexpected call #%d to %s", s.idx+1, url) + } + r := s.responses[s.idx] + s.idx++ + return r.body, r.err +} + +// newStub builds a stubGetter from raw byte bodies (no errors). +func newStub(t *testing.T, bodies ...[]byte) *stubGetter { + resps := make([]stubResponse, len(bodies)) + for i, b := range bodies { + resps[i] = stubResponse{body: b} + } + return &stubGetter{t: t, responses: resps} +} + +func TestListBuilds_SinceFilter(t *testing.T) { + now := time.Now().UTC() + body, _ := json.Marshal(map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{ + "id": "recent", + "attributes": map[string]interface{}{"uploadedDate": now.Add(-1 * time.Hour).Format(time.RFC3339)}, + }, + map[string]interface{}{ + "id": "old1", + "attributes": map[string]interface{}{"uploadedDate": now.Add(-30 * 24 * time.Hour).Format(time.RFC3339)}, + }, + map[string]interface{}{ + "id": "old2", + "attributes": map[string]interface{}{"uploadedDate": now.Add(-60 * 24 * time.Hour).Format(time.RFC3339)}, + }, + }, + "links": map[string]interface{}{"next": nil}, + }) + stub := newStub(t, body) + since := now.Add(-24 * time.Hour) + builds, err := ListBuilds("tok", "app-1", &since, stub.get) + if err != nil { + t.Fatalf("ListBuilds: %v", err) + } + if len(builds) != 1 || builds[0].ID != "recent" { + t.Fatalf("got %+v, want [recent]", builds) + } + if len(stub.calls) != 1 { + t.Errorf("calls = %d, want 1", len(stub.calls)) + } +} + +func TestListBuilds_PaginationFollowsNext(t *testing.T) { + page1, _ := json.Marshal(map[string]interface{}{ + "data": []interface{}{map[string]interface{}{"id": "a", "attributes": map[string]interface{}{}}}, + "links": map[string]interface{}{"next": "https://api.appstoreconnect.apple.com/v1/builds?cursor=2"}, + }) + page2, _ := json.Marshal(map[string]interface{}{ + "data": []interface{}{map[string]interface{}{"id": "b", "attributes": map[string]interface{}{}}}, + "links": map[string]interface{}{"next": nil}, + }) + stub := newStub(t, page1, page2) + builds, err := ListBuilds("tok", "app-1", nil, stub.get) + if err != nil { + t.Fatalf("ListBuilds: %v", err) + } + if len(builds) != 2 || builds[0].ID != "a" || builds[1].ID != "b" { + t.Fatalf("builds = %+v", builds) + } + if len(stub.calls) != 2 { + t.Errorf("calls = %d, want 2", len(stub.calls)) + } +} + +func TestListBuilds_RejectsOffHost(t *testing.T) { + page1, _ := json.Marshal(map[string]interface{}{ + "data": []interface{}{}, + "links": map[string]interface{}{"next": "https://evil.com/v1/builds?cursor=2"}, + }) + stub := newStub(t, page1) + _, err := ListBuilds("tok", "app-1", nil, stub.get) + if err == nil { + t.Fatal("expected off-host rejection, got nil") + } + var ascErr *ASCError + if !errors.As(err, &ascErr) { + t.Fatalf("expected *ASCError, got %T: %v", err, err) + } +} + +func TestListBuilds_RejectsHTTPScheme(t *testing.T) { + page1, _ := json.Marshal(map[string]interface{}{ + "data": []interface{}{}, + "links": map[string]interface{}{"next": "http://api.appstoreconnect.apple.com/v1/builds?cursor=2"}, + }) + stub := newStub(t, page1) + _, err := ListBuilds("tok", "app-1", nil, stub.get) + if err == nil { + t.Fatal("expected http-scheme rejection, got nil") + } + var ascErr *ASCError + if !errors.As(err, &ascErr) { + t.Fatalf("expected *ASCError, got %T", err) + } +} + +// TestAssertASCHost_Vectors locks the host-pin invariants against common +// bypass patterns. Both accept-cases and reject-cases share one table. +func TestAssertASCHost_Vectors(t *testing.T) { + cases := []struct { + name string + url string + wantError bool + }{ + {"canonical", "https://api.appstoreconnect.apple.com/v1/builds", false}, + {"canonical with query", "https://api.appstoreconnect.apple.com/v1/builds?cursor=x", false}, + {"mixed case host", "https://API.AppStoreConnect.apple.com/v1/builds", false}, + {"mixed case scheme", "HTTPS://api.appstoreconnect.apple.com/v1/builds", false}, + + {"plain http", "http://api.appstoreconnect.apple.com/v1/builds", true}, + {"suffix attack", "https://api.appstoreconnect.apple.com.evil.com/v1/builds", true}, + {"prefix attack", "https://evil-api.appstoreconnect.apple.com/v1/builds", true}, + {"userinfo override", "https://api.appstoreconnect.apple.com@evil.com/v1/builds", true}, + {"userinfo with apple host", "https://user:pass@api.appstoreconnect.apple.com/v1/builds", true}, + {"embedded at ambiguity", "https://api.appstoreconnect.apple.com#@evil.com/v1/builds", false}, // fragment, host pin still wins + {"ip literal", "https://17.0.0.1/v1/builds", true}, + {"file scheme", "file:///etc/passwd", true}, + {"empty", "", true}, + {"garbage", "::::not a url", true}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + err := assertASCHost(c.url) + if c.wantError && err == nil { + t.Fatalf("assertASCHost(%q) = nil, want error", c.url) + } + if !c.wantError && err != nil { + t.Fatalf("assertASCHost(%q) = %v, want nil", c.url, err) + } + }) + } +} + +// --------------------------------------------------------------------------- +// LoadCredentials +// --------------------------------------------------------------------------- + +func TestLoadCredentials_ExplicitFields(t *testing.T) { + creds, err := LoadCredentials(CredentialOptions{ + KeyID: "K", + IssuerID: "I", + AppID: "A", + KeyPEM: []byte("PEM-BYTES"), + }) + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if creds.KeyID != "K" || creds.IssuerID != "I" || creds.AppID != "A" || string(creds.KeyPEM) != "PEM-BYTES" { + t.Fatalf("creds = %+v", creds) + } +} + +func TestLoadCredentials_MissingFieldsError(t *testing.T) { + _, err := LoadCredentials(CredentialOptions{KeyID: "K"}) + if err == nil { + t.Fatal("expected error, got nil") + } + msg := err.Error() + for _, want := range []string{"issuer_id", "app_id", "key_pem"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q missing %q", msg, want) + } + } +} + +func TestLoadCredentials_SOPSPath(t *testing.T) { + tmp := t.TempDir() + "/fake.yaml" + if err := writeFile(tmp, "stub"); err != nil { + t.Fatal(err) + } + yamlDoc := "key_id: KEYID123\n" + + "issuer_id: 11111111-2222-3333-4444-555555555555\n" + + "app_id: '1234567890'\n" + + "key_pem: |\n" + + " -----BEGIN PRIVATE KEY-----\n" + + " FAKE\n" + + " -----END PRIVATE KEY-----\n" + + creds, err := LoadCredentials(CredentialOptions{ + SOPSPath: tmp, + SopsRunner: func(path string) ([]byte, error) { + if path != tmp { + t.Errorf("path = %s", path) + } + return []byte(yamlDoc), nil + }, + }) + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if creds.KeyID != "KEYID123" { + t.Errorf("key_id = %q", creds.KeyID) + } + if creds.IssuerID != "11111111-2222-3333-4444-555555555555" { + t.Errorf("issuer = %q", creds.IssuerID) + } + if creds.AppID != "1234567890" { + t.Errorf("app_id = %q", creds.AppID) + } + if !strings.Contains(string(creds.KeyPEM), "BEGIN PRIVATE KEY") { + t.Errorf("key_pem missing BEGIN marker: %q", string(creds.KeyPEM)) + } +} + +func TestLoadCredentials_SOPS_Base64Encoded(t *testing.T) { + // Production path: SOPS stores the .p8 contents as base64 under + // asc_api_key_b64. LoadCredentials must decode it into raw PEM bytes. + const testPEM = "-----BEGIN PRIVATE KEY-----\nFAKE-CONTENTS\n-----END PRIVATE KEY-----\n" + b64 := base64.StdEncoding.EncodeToString([]byte(testPEM)) + + tmp := t.TempDir() + "/fake.yaml" + if err := writeFile(tmp, "stub"); err != nil { + t.Fatal(err) + } + yamlDoc := "key_id: KEYID123\n" + + "issuer_id: 11111111-2222-3333-4444-555555555555\n" + + "app_id: '1234567890'\n" + + "asc_api_key_b64: " + b64 + "\n" + + creds, err := LoadCredentials(CredentialOptions{ + SOPSPath: tmp, + SopsRunner: func(path string) ([]byte, error) { return []byte(yamlDoc), nil }, + }) + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if string(creds.KeyPEM) != testPEM { + t.Fatalf("KeyPEM = %q, want decoded PEM %q", string(creds.KeyPEM), testPEM) + } +} + +func TestLoadCredentials_SOPS_PrefersBase64OverPEM(t *testing.T) { + // When both fields are present, asc_api_key_b64 must win — it's the + // production schema, so any legacy key_pem residue is stale. + const winnerPEM = "-----BEGIN PRIVATE KEY-----\nWINNER\n-----END PRIVATE KEY-----\n" + const loserPEM = "-----BEGIN PRIVATE KEY-----\nLOSER\n-----END PRIVATE KEY-----\n" + b64 := base64.StdEncoding.EncodeToString([]byte(winnerPEM)) + + tmp := t.TempDir() + "/fake.yaml" + if err := writeFile(tmp, "stub"); err != nil { + t.Fatal(err) + } + // Note: key_pem is a YAML literal block so it survives intact for the + // comparison below. + yamlDoc := "key_id: K\n" + + "issuer_id: I\n" + + "app_id: A\n" + + "asc_api_key_b64: " + b64 + "\n" + + "key_pem: |\n " + strings.ReplaceAll(loserPEM, "\n", "\n ") + + creds, err := LoadCredentials(CredentialOptions{ + SOPSPath: tmp, + SopsRunner: func(path string) ([]byte, error) { return []byte(yamlDoc), nil }, + }) + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if string(creds.KeyPEM) != winnerPEM { + t.Fatalf("KeyPEM = %q, want base64-decoded winner %q", string(creds.KeyPEM), winnerPEM) + } +} + +func TestLoadCredentials_SOPSMissing(t *testing.T) { + tmp := t.TempDir() + "/fake.yaml" + if err := writeFile(tmp, "stub"); err != nil { + t.Fatal(err) + } + _, err := LoadCredentials(CredentialOptions{ + SOPSPath: tmp, + SopsRunner: func(path string) ([]byte, error) { return nil, ErrSopsNotInstalled }, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "sops") || !strings.Contains(err.Error(), "PATH") { + t.Errorf("error missing install hint: %v", err) + } +} + +func writeFile(path, contents string) error { + return os.WriteFile(path, []byte(contents), 0o600) +} + +// --------------------------------------------------------------------------- +// ResolveCredentials — field-by-field precedence +// --------------------------------------------------------------------------- + +func TestResolveCredentials_FieldByFieldPrecedence(t *testing.T) { + // Clear any inherited env so we start from a known baseline. + clearAllASCEnv := func(t *testing.T) { + t.Helper() + for _, k := range []string{"ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_APP_ID", "ASC_KEY_PEM", "OWNPULSE_INFRA_PATH"} { + t.Setenv(k, "") + } + } + + // Pre-baked SOPS stub used by the cases that exercise the SOPS layer. + sopsYAML := "key_id: sops-kid\n" + + "issuer_id: sops-iss\n" + + "app_id: sops-app\n" + + "key_pem: sops-pem\n" + sopsRunner := func(_ string) ([]byte, error) { return []byte(sopsYAML), nil } + makeSOPSFile := func(t *testing.T) string { + t.Helper() + path := t.TempDir() + "/fake.yaml" + if err := writeFile(path, "stub"); err != nil { + t.Fatal(err) + } + return path + } + + type want struct { + KeyID, IssuerID, AppID, KeyPEM string + } + + t.Run("all flags win", func(t *testing.T) { + clearAllASCEnv(t) + // Even if env + SOPS would also supply values, flags win on all four. + t.Setenv("ASC_KEY_ID", "env-kid") + t.Setenv("ASC_ISSUER_ID", "env-iss") + t.Setenv("ASC_APP_ID", "env-app") + t.Setenv("ASC_KEY_PEM", "env-pem") + creds, err := ResolveCredentials(CredentialOptions{ + KeyID: "flag-kid", IssuerID: "flag-iss", AppID: "flag-app", + }) + if err != nil { + t.Fatalf("resolve: %v", err) + } + // KeyPEM has no flag → env wins. + got := want{creds.KeyID, creds.IssuerID, creds.AppID, string(creds.KeyPEM)} + if got != (want{"flag-kid", "flag-iss", "flag-app", "env-pem"}) { + t.Fatalf("got %+v", got) + } + }) + + t.Run("env wins when no flags", func(t *testing.T) { + clearAllASCEnv(t) + t.Setenv("ASC_KEY_ID", "env-kid") + t.Setenv("ASC_ISSUER_ID", "env-iss") + t.Setenv("ASC_APP_ID", "env-app") + t.Setenv("ASC_KEY_PEM", "env-pem") + creds, err := ResolveCredentials(CredentialOptions{}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got := want{creds.KeyID, creds.IssuerID, creds.AppID, string(creds.KeyPEM)} + if got != (want{"env-kid", "env-iss", "env-app", "env-pem"}) { + t.Fatalf("got %+v", got) + } + }) + + t.Run("sops wins when no flags or env", func(t *testing.T) { + clearAllASCEnv(t) + creds, err := ResolveCredentials(CredentialOptions{ + SOPSPath: makeSOPSFile(t), + SopsRunner: sopsRunner, + }) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got := want{creds.KeyID, creds.IssuerID, creds.AppID, string(creds.KeyPEM)} + if got != (want{"sops-kid", "sops-iss", "sops-app", "sops-pem"}) { + t.Fatalf("got %+v", got) + } + }) + + t.Run("flag for one field + env for the rest", func(t *testing.T) { + clearAllASCEnv(t) + t.Setenv("ASC_ISSUER_ID", "env-iss") + t.Setenv("ASC_APP_ID", "env-app") + t.Setenv("ASC_KEY_PEM", "env-pem") + creds, err := ResolveCredentials(CredentialOptions{KeyID: "flag-kid"}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got := want{creds.KeyID, creds.IssuerID, creds.AppID, string(creds.KeyPEM)} + if got != (want{"flag-kid", "env-iss", "env-app", "env-pem"}) { + t.Fatalf("got %+v — flag should override one field without dropping the env-sourced rest", got) + } + }) + + t.Run("flag + env + sops merged across all fields", func(t *testing.T) { + clearAllASCEnv(t) + t.Setenv("ASC_APP_ID", "env-app") + creds, err := ResolveCredentials(CredentialOptions{ + KeyID: "flag-kid", + SOPSPath: makeSOPSFile(t), + SopsRunner: sopsRunner, + // IssuerID + KeyPEM should come from SOPS, AppID from env. + }) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got := want{creds.KeyID, creds.IssuerID, creds.AppID, string(creds.KeyPEM)} + if got != (want{"flag-kid", "sops-iss", "env-app", "sops-pem"}) { + t.Fatalf("got %+v — expected mixed sources", got) + } + }) + + t.Run("missing field across all sources errors with field name", func(t *testing.T) { + clearAllASCEnv(t) + // KeyPEM has no flag, no env, no SOPS file → must error and name key_pem. + _, err := ResolveCredentials(CredentialOptions{ + KeyID: "k", IssuerID: "i", AppID: "a", + }) + if err == nil { + t.Fatal("expected error for missing key_pem") + } + if !strings.Contains(err.Error(), "key_pem") { + t.Errorf("error should name key_pem, got: %v", err) + } + }) +} + +// --------------------------------------------------------------------------- +// CrashFeedback — 404 swallowed +// --------------------------------------------------------------------------- + +func TestCrashFeedback_Swallows404(t *testing.T) { + // Both endpoints return 404 → expect no error, empty result. + stub := &stubGetter{ + t: t, + responses: []stubResponse{ + {err: &ASCError{Status: 404, URL: "https://api.appstoreconnect.apple.com/v1/builds/xx/perfPowerMetrics", Body: "not found"}}, + {err: &ASCError{Status: 404, URL: "https://api.appstoreconnect.apple.com/v1/builds/xx/betaBuildLocalizations", Body: "not found"}}, + }, + } + out, err := CrashFeedback("tok", "xx", stub.get) + if err != nil { + t.Fatalf("CrashFeedback returned error on 404: %v", err) + } + if len(out) != 0 { + t.Fatalf("expected empty slice on double-404, got %d entries", len(out)) + } +} + +func TestCrashFeedback_PropagatesNon404(t *testing.T) { + stub := &stubGetter{ + t: t, + responses: []stubResponse{ + {err: &ASCError{Status: 500, URL: "x", Body: "internal"}}, + }, + } + _, err := CrashFeedback("tok", "xx", stub.get) + if err == nil { + t.Fatal("expected 500 to surface, got nil") + } +} + +// --------------------------------------------------------------------------- +// Diagnose — signal filter +// --------------------------------------------------------------------------- + +func TestDiagnose_SignalFilter(t *testing.T) { + pemBytes, _ := genECKeyPEM(t) + now := time.Now().UTC() + + buildsPage, _ := json.Marshal(map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{ + "id": "b1", + "attributes": map[string]interface{}{"uploadedDate": now.Add(-1 * time.Hour).Format(time.RFC3339)}, + }, + }, + "links": map[string]interface{}{"next": nil}, + }) + perfPage, _ := json.Marshal(map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{ + "id": "c1", + "attributes": map[string]interface{}{"signature": "sig-A", "signal": "SIGSEGV"}, + }, + map[string]interface{}{ + "id": "c2", + "attributes": map[string]interface{}{"signature": "sig-B", "signal": "SIGABRT"}, + }, + map[string]interface{}{ + "id": "c3", + "attributes": map[string]interface{}{"signature": "sig-C", "signal": "EXC_BAD_ACCESS"}, + }, + }, + }) + locPage, _ := json.Marshal(map[string]interface{}{"data": []interface{}{}}) + + stub := newStub(t, buildsPage, perfPage, locPage) + + result, err := Diagnose( + &Credentials{KeyID: "K", IssuerID: "I", AppID: "A", KeyPEM: pemBytes}, + DiagnoseOptions{SignalName: "segv", HTTPGet: stub.get}, + ) + if err != nil { + t.Fatalf("Diagnose: %v", err) + } + if len(result.Diagnostics) != 1 { + t.Fatalf("expected 1 diagnostic after SIGSEGV filter, got %d", len(result.Diagnostics)) + } + if got := result.Diagnostics[0].Signal; got == nil || *got != "SIGSEGV" { + t.Fatalf("signal = %v, want SIGSEGV", got) + } +} + +// --------------------------------------------------------------------------- +// DefaultHTTPGetter — redirects are not followed +// --------------------------------------------------------------------------- + +func TestDefaultHTTPGetter_DoesNotFollowRedirects(t *testing.T) { + // Stand up two test servers: src returns a 302 to dst; dst would set a + // magic body. If the client follows the redirect, we'd see dst's body. + // We expect either an error or the 302 surfaced through our ASCError path. + dst := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("FOLLOWED-REDIRECT")) + })) + defer dst.Close() + + src := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", dst.URL) + w.WriteHeader(http.StatusFound) + _, _ = w.Write([]byte("redirect-body")) + })) + defer src.Close() + + // Trust the test servers' self-signed certs. + prev := http.DefaultTransport + http.DefaultTransport = src.Client().Transport + defer func() { http.DefaultTransport = prev }() + + _, err := DefaultHTTPGetter(src.URL, nil) + if err == nil { + t.Fatal("expected 302 to surface as error (redirect not followed)") + } + var ascErr *ASCError + if !errors.As(err, &ascErr) { + t.Fatalf("expected *ASCError on 302, got %T: %v", err, err) + } + if ascErr.Status != http.StatusFound { + t.Fatalf("status = %d, want 302", ascErr.Status) + } + if strings.Contains(ascErr.Body, "FOLLOWED-REDIRECT") { + t.Fatal("body contains downstream content — redirect WAS followed") + } +} + +// --------------------------------------------------------------------------- +// redactBearer +// --------------------------------------------------------------------------- + +func TestRedactBearer(t *testing.T) { + cases := []struct { + in, want string + }{ + {"Authorization: Bearer abc.def.ghi", "Authorization: Bearer [REDACTED]"}, + {"bearer XYZ", "Bearer [REDACTED]"}, + {"Bearer\tmy-token here", "Bearer [REDACTED] here"}, + {"no token here", "no token here"}, + } + for _, c := range cases { + if got := redactBearer(c.in); got != c.want { + t.Errorf("redactBearer(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/src/main.go b/src/main.go index b3d65f5..11690e8 100644 --- a/src/main.go +++ b/src/main.go @@ -1,15 +1,19 @@ package main import ( + "encoding/json" + "errors" "fmt" "os" "path/filepath" "strings" + "time" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/ownpulse/ownpulse-dev/src/config" + "github.com/ownpulse/ownpulse-dev/src/crashes" "github.com/ownpulse/ownpulse-dev/src/workspace" ) @@ -47,6 +51,7 @@ create a workspace.override.toml alongside it.`, cleanCmd(), e2eCmd(), updateCmd(), + crashesCmd(), ) if err := root.Execute(); err != nil { @@ -302,6 +307,302 @@ May require sudo if installed to a system directory like /usr/local/bin.`, } } +// --- crashes --- + +type crashesFlags struct { + since string + device string + signal string + build string + jsonOut bool + sopsPath string + appID string + + // Mostly for tests / power users. Note: no --key-pem flag — PEM contents + // passed on argv would leak via ps(1), shell history, and process + // accounting. The PEM must come from ASC_KEY_PEM or SOPS. + keyID string + issuerID string + buildID string +} + +func crashesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "crashes", + Short: "Diagnose iOS crashes via App Store Connect", + Long: `Pulls crash signatures and tester feedback from App Store Connect. + +The SOPS-encrypted credentials file is located in this order: + 1. --sops-path explicit override + 2. $OWNPULSE_INFRA_PATH/secrets/... legacy / non-workspace use + 3. workspace config looks up ownpulse-infra repo at + /ownpulse-infra/secrets/ + ios/appstore-connect.sops.yaml + +Individual fields can also be supplied directly, with this precedence: + 1. --key-id / --issuer-id / --app-id flags (no --key-pem; argv leaks) + 2. Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_APP_ID, ASC_KEY_PEM + (ASC_KEY_PEM holds the *contents* of the .p8 file, not a path) + 3. Values from the SOPS YAML located above + +The decrypted PEM stays in memory only; never written to disk.`, + } + + for _, sub := range []*cobra.Command{ + crashesDiagnoseCmd(), + crashesListBuildsCmd(), + crashesCrashFeedbackCmd(), + } { + // Don't print cobra usage on RunE error — the error message alone is plenty. + sub.SilenceUsage = true + cmd.AddCommand(sub) + } + return cmd +} + +func addCommonCrashesFlags(cmd *cobra.Command, f *crashesFlags) { + cmd.Flags().StringVar(&f.sopsPath, "sops-path", "", "path to SOPS-encrypted credentials YAML") + cmd.Flags().StringVar(&f.appID, "app-id", "", "override app id from credentials/env") + cmd.Flags().StringVar(&f.keyID, "key-id", "", "explicit App Store Connect key id") + cmd.Flags().StringVar(&f.issuerID, "issuer-id", "", "explicit App Store Connect issuer id") + // No --key-pem flag by design: PEM contents in argv leak via ps(1), shell + // history, and process accounting. Use the ASC_KEY_PEM env var or SOPS. +} + +func crashesDiagnoseCmd() *cobra.Command { + f := &crashesFlags{} + cmd := &cobra.Command{ + Use: "diagnose", + Short: "End-to-end: list builds, fetch crashes, render report", + RunE: func(cmd *cobra.Command, args []string) error { + creds, err := resolveCrashesCredentials(f) + if err != nil { + return err + } + var since *time.Time + if f.since != "" { + ts, err := crashes.ParseSince(f.since) + if err != nil { + return err + } + since = &ts + } + result, err := crashes.Diagnose(creds, crashes.DiagnoseOptions{ + AppID: f.appID, + Since: since, + BuildVer: f.build, + DeviceID: f.device, + SignalName: f.signal, + }) + if err != nil { + return err + } + if f.jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result.Diagnostics) + } + crashes.RenderTable(os.Stdout, result) + return nil + }, + } + addCommonCrashesFlags(cmd, f) + cmd.Flags().StringVar(&f.since, "since", "7d", "duration (24h, 7d, 30d) or ISO 8601 timestamp") + cmd.Flags().StringVar(&f.device, "device", "", "filter by device id (Phase 1: warning only)") + cmd.Flags().StringVar(&f.signal, "signal", "", "client-side filter on signal name (e.g. SIGSEGV)") + cmd.Flags().StringVar(&f.build, "build", "", "filter to a specific build version") + cmd.Flags().BoolVar(&f.jsonOut, "json", false, "emit JSON instead of a human table") + return cmd +} + +func crashesListBuildsCmd() *cobra.Command { + f := &crashesFlags{} + cmd := &cobra.Command{ + Use: "list-builds", + Short: "List builds for an app (JSON)", + RunE: func(cmd *cobra.Command, args []string) error { + creds, err := resolveCrashesCredentials(f) + if err != nil { + return err + } + token, err := crashes.MintJWT(creds) + if err != nil { + return err + } + appID := f.appID + if appID == "" { + appID = creds.AppID + } + var since *time.Time + if f.since != "" { + ts, err := crashes.ParseSince(f.since) + if err != nil { + return err + } + since = &ts + } + builds, err := crashes.ListBuilds(token, appID, since, crashes.DefaultHTTPGetter) + if err != nil { + return err + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(builds) + }, + } + addCommonCrashesFlags(cmd, f) + cmd.Flags().StringVar(&f.since, "since", "", "duration (24h, 7d, 30d) or ISO 8601 timestamp") + return cmd +} + +func crashesCrashFeedbackCmd() *cobra.Command { + f := &crashesFlags{} + cmd := &cobra.Command{ + Use: "crash-feedback", + Short: "Fetch crash feedback for a build (JSON)", + RunE: func(cmd *cobra.Command, args []string) error { + if f.buildID == "" { + return fmt.Errorf("--build-id is required") + } + creds, err := resolveCrashesCredentials(f) + if err != nil { + return err + } + token, err := crashes.MintJWT(creds) + if err != nil { + return err + } + entries, err := crashes.CrashFeedback(token, f.buildID, crashes.DefaultHTTPGetter) + if err != nil { + return err + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(entries) + }, + } + addCommonCrashesFlags(cmd, f) + cmd.Flags().StringVar(&f.buildID, "build-id", "", "App Store Connect build id (required)") + return cmd +} + +// sopsPathLookup tracks the SOPS-path resolution for a given invocation so we +// can emit a rich error if everything falls through. +type sopsPathLookup struct { + resolved string // final path (may be empty) + source string // "flag", "env", "workspace-config", or "none" + configAttempt string // expected workspace path if config was consulted + configProblem string // why config lookup didn't yield a usable path, if any +} + +// resolveSOPSPath applies the documented resolution order. It does NOT verify +// the file exists; downstream LoadCredentials handles that. Errors from +// loadConfig are intentionally swallowed — opdev should still work when run +// outside a workspace. +func resolveSOPSPath(flagPath string) sopsPathLookup { + if flagPath != "" { + return sopsPathLookup{resolved: flagPath, source: "flag"} + } + if env := os.Getenv("OWNPULSE_INFRA_PATH"); env != "" { + return sopsPathLookup{ + resolved: filepath.Join(env, crashes.SOPSRelPath), + source: "env", + } + } + + out := sopsPathLookup{source: "none"} + cfg, err := loadConfig() + if err != nil { + out.configProblem = fmt.Sprintf("workspace config not loadable (%v)", err) + return out + } + + const infraRepo = "ownpulse-infra" + var repo *config.RepoConfig + for i := range cfg.Repos { + if cfg.Repos[i].Name == infraRepo { + repo = &cfg.Repos[i] + break + } + } + if repo == nil { + out.configProblem = fmt.Sprintf("repo %q not registered in workspace", infraRepo) + return out + } + + candidate := filepath.Join(cfg.Workspace.CloneRoot, repo.Name, crashes.SOPSRelPath) + out.configAttempt = candidate + if _, err := os.Stat(candidate); err != nil { + out.configProblem = fmt.Sprintf("expected file not present at %s (%v)", candidate, err) + return out + } + out.resolved = candidate + out.source = "workspace-config" + return out +} + +func resolveCrashesCredentials(f *crashesFlags) (*crashes.Credentials, error) { + lookup := resolveSOPSPath(f.sopsPath) + creds, err := crashes.ResolveCredentials(crashes.CredentialOptions{ + KeyID: f.keyID, + IssuerID: f.issuerID, + AppID: f.appID, + SOPSPath: lookup.resolved, + }) + if err != nil { + var miss *crashes.MissingCredentialsError + if errors.As(err, &miss) { + return nil, missingCredsMessage(lookup, miss) + } + return nil, err + } + return creds, nil +} + +// missingCredsMessage builds the multi-paragraph error users see when no +// resolution path produced credentials. The intent is to help them figure out +// which knob to turn next. +func missingCredsMessage(lookup sopsPathLookup, miss *crashes.MissingCredentialsError) error { + // Describe each source the way the user would think about it. + flagLine := "1. --sops-path flag (not set)" + if lookup.source == "flag" { + flagLine = fmt.Sprintf("1. --sops-path %s (used; load failed or fields missing)", lookup.resolved) + } + + envVal := os.Getenv("OWNPULSE_INFRA_PATH") + envLine := "2. OWNPULSE_INFRA_PATH env (not set)" + if envVal != "" { + envLine = fmt.Sprintf("2. OWNPULSE_INFRA_PATH=%s (used; load failed or fields missing)", envVal) + } + + var configLine string + switch { + case lookup.source == "workspace-config": + configLine = fmt.Sprintf("3. workspace config: %s (used; load failed or fields missing)", lookup.resolved) + case lookup.configProblem != "": + configLine = fmt.Sprintf("3. workspace config: %s", lookup.configProblem) + default: + configLine = "3. workspace config: not consulted" + } + + ascLine := "4. ASC_KEY_ID/ASC_ISSUER_ID/ASC_APP_ID/ASC_KEY_PEM env vars (not all set)" + + body := strings.Join([]string{ + "credentials missing. Tried:", + " " + flagLine, + " " + envLine, + " " + configLine, + " " + ascLine, + "", + "Run `opdev list` to see registered repos. Run `opdev setup` to clone missing ones.", + }, "\n") + + if len(miss.Fields) > 0 { + body += "\n\nMissing fields: " + strings.Join(miss.Fields, ", ") + } + return errors.New(body) +} + func resolveAgentsPath(cfg *config.WorkspaceConfig) (string, error) { agentsPath := cfg.Agents.DefinitionsPath if agentsPath == "" { diff --git a/src/main_test.go b/src/main_test.go new file mode 100644 index 0000000..f925726 --- /dev/null +++ b/src/main_test.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) OwnPulse Contributors + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ownpulse/ownpulse-dev/src/crashes" +) + +// writeTempWorkspaceConfig creates a minimal workspace.toml in tmpDir whose +// clone_root points at cloneRoot, with an ownpulse-infra repo registered. +// Returns the absolute path to the generated workspace.toml. +func writeTempWorkspaceConfig(t *testing.T, tmpDir, cloneRoot string, includeInfra bool) string { + t.Helper() + repoBlock := "" + if includeInfra { + repoBlock = ` +[[repo]] +name = "ownpulse-infra" +description = "infra" +visibility = "private" +` + } + doc := "[workspace]\nname = \"test\"\ndefault_org = \"test\"\ndefault_branch = \"main\"\nclone_root = \"" + cloneRoot + "\"\n\n[agents]\ndefinitions_path = \"./agents\"\n" + repoBlock + path := filepath.Join(tmpDir, "workspace.toml") + if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + // loadConfig also tries to resolve agents_path; create an agents dir so + // later code that may stat it succeeds. (resolveSOPSPath itself doesn't, + // but Load doesn't fail on missing agents either — guard anyway.) + _ = os.MkdirAll(filepath.Join(tmpDir, "agents"), 0o755) + return path +} + +func TestResolveSOPSPath_FlagWins(t *testing.T) { + t.Setenv("OWNPULSE_INFRA_PATH", "/should/be/ignored") + got := resolveSOPSPath("/explicit/file.yaml") + if got.resolved != "/explicit/file.yaml" { + t.Fatalf("resolved = %q, want explicit path", got.resolved) + } + if got.source != "flag" { + t.Errorf("source = %q, want flag", got.source) + } +} + +func TestResolveSOPSPath_EnvBeatsConfig(t *testing.T) { + // Set up a workspace config that WOULD be used if the env var weren't set, + // then assert the env var wins. + tmp := t.TempDir() + cloneRoot := filepath.Join(tmp, "checkouts") + if err := os.MkdirAll(filepath.Join(cloneRoot, "ownpulse-infra", "secrets", "ios"), 0o755); err != nil { + t.Fatal(err) + } + cfgPath := writeTempWorkspaceConfig(t, tmp, cloneRoot, true) + t.Setenv("OPDEV_CONFIG", cfgPath) + t.Setenv("OWNPULSE_INFRA_PATH", "/from/env") + + got := resolveSOPSPath("") + want := filepath.Join("/from/env", crashes.SOPSRelPath) + if got.resolved != want { + t.Fatalf("resolved = %q, want %q", got.resolved, want) + } + if got.source != "env" { + t.Errorf("source = %q, want env", got.source) + } +} + +func TestResolveSOPSPath_WorkspaceConfigPath(t *testing.T) { + // Lay out a fake clone_root/ownpulse-infra/secrets/ios/appstore-connect.sops.yaml + // and assert resolveSOPSPath finds it via the workspace config. + tmp := t.TempDir() + cloneRoot := filepath.Join(tmp, "checkouts") + infraDir := filepath.Join(cloneRoot, "ownpulse-infra") + secretDir := filepath.Join(infraDir, "secrets", "ios") + if err := os.MkdirAll(secretDir, 0o755); err != nil { + t.Fatal(err) + } + secretPath := filepath.Join(secretDir, "appstore-connect.sops.yaml") + if err := os.WriteFile(secretPath, []byte("stub"), 0o600); err != nil { + t.Fatal(err) + } + + cfgPath := writeTempWorkspaceConfig(t, tmp, cloneRoot, true) + t.Setenv("OPDEV_CONFIG", cfgPath) + t.Setenv("OWNPULSE_INFRA_PATH", "") + + got := resolveSOPSPath("") + if got.resolved != secretPath { + t.Fatalf("resolved = %q, want %q", got.resolved, secretPath) + } + if got.source != "workspace-config" { + t.Errorf("source = %q, want workspace-config", got.source) + } +} + +func TestResolveSOPSPath_ConfigMissingRepo(t *testing.T) { + // Workspace config exists but doesn't register ownpulse-infra. + tmp := t.TempDir() + cloneRoot := filepath.Join(tmp, "checkouts") + if err := os.MkdirAll(cloneRoot, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := writeTempWorkspaceConfig(t, tmp, cloneRoot, false) + t.Setenv("OPDEV_CONFIG", cfgPath) + t.Setenv("OWNPULSE_INFRA_PATH", "") + + got := resolveSOPSPath("") + if got.resolved != "" { + t.Fatalf("resolved = %q, want empty", got.resolved) + } + if !strings.Contains(got.configProblem, "not registered") { + t.Errorf("configProblem = %q, want mention of 'not registered'", got.configProblem) + } +} + +func TestResolveSOPSPath_ConfigCheckoutMissing(t *testing.T) { + // Workspace config registers ownpulse-infra but the secrets file doesn't + // exist on disk. resolveSOPSPath should report the expected path and not + // claim success. + tmp := t.TempDir() + cloneRoot := filepath.Join(tmp, "checkouts") + if err := os.MkdirAll(cloneRoot, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := writeTempWorkspaceConfig(t, tmp, cloneRoot, true) + t.Setenv("OPDEV_CONFIG", cfgPath) + t.Setenv("OWNPULSE_INFRA_PATH", "") + + got := resolveSOPSPath("") + if got.resolved != "" { + t.Fatalf("resolved = %q, want empty (file missing)", got.resolved) + } + wantAttempt := filepath.Join(cloneRoot, "ownpulse-infra", crashes.SOPSRelPath) + if got.configAttempt != wantAttempt { + t.Errorf("configAttempt = %q, want %q", got.configAttempt, wantAttempt) + } + if got.configProblem == "" { + t.Error("configProblem should describe the missing file") + } +}