diff --git a/cmd/errors.go b/cmd/errors.go new file mode 100644 index 0000000..00544af --- /dev/null +++ b/cmd/errors.go @@ -0,0 +1,19 @@ +package cmd + +// ExitError is an error that carries a specific exit code. +// The main function should check for this error type and exit with the code. +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { + if e.Err != nil { + return e.Err.Error() + } + return "" +} + +func (e *ExitError) Unwrap() error { + return e.Err +} diff --git a/cmd/sign.go b/cmd/sign.go index c6b1ab2..bf44c53 100644 --- a/cmd/sign.go +++ b/cmd/sign.go @@ -1,7 +1,20 @@ package cmd import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/meigma/blob" + "github.com/meigma/blob/policy/sigstore" + "github.com/meigma/blob/registry/oras" "github.com/spf13/cobra" + "github.com/spf13/viper" + + internalcfg "github.com/meigma/blob-cli/internal/config" ) var signCmd = &cobra.Command{ @@ -13,14 +26,208 @@ Signs the specified archive reference using Sigstore. By default, uses keyless signing which authenticates via OIDC. A private key can be specified for key-based signing instead.`, Example: ` blob sign ghcr.io/acme/configs:v1.0.0 - blob sign --key cosign.key ghcr.io/acme/configs:v1.0.0`, + blob sign --key cosign.key ghcr.io/acme/configs:v1.0.0 + blob sign --output-signature ghcr.io/acme/configs:v1.0.0 > sig.json`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, + RunE: runSign, } func init() { signCmd.Flags().String("key", "", "sign with a private key instead of keyless") signCmd.Flags().Bool("output-signature", false, "print signature to stdout instead of uploading") } + +// signResult contains the result of a sign operation. +type signResult struct { + Ref string `json:"ref"` + ResolvedRef string `json:"resolved_ref,omitempty"` + SignatureDigest string `json:"signature_digest,omitempty"` + Status string `json:"status"` +} + +// signFlags holds the parsed command flags. +type signFlags struct { + keyPath string + outputSignature bool +} + +func runSign(cmd *cobra.Command, args []string) error { + // 1. Get config from context + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + // 2. Parse arguments + inputRef := args[0] + + // 3. Parse flags + flags, err := parseSignFlags(cmd) + if err != nil { + return err + } + + // 4. Resolve alias + resolvedRef := cfg.ResolveAlias(inputRef) + + // 5. Build signer + signer, err := buildSigner(flags) + if err != nil { + return fmt.Errorf("creating signer: %w", err) + } + + // 6. Handle two output modes + ctx := cmd.Context() + var result signResult + result.Ref = inputRef + if inputRef != resolvedRef { + result.ResolvedRef = resolvedRef + } + + if flags.outputSignature { + // Output mode: sign and print to stdout + return signToStdout(ctx, resolvedRef, signer) + } + + // Normal mode: sign and upload + client, err := blob.NewClient(blob.WithDockerConfig()) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + sigDigest, err := client.Sign(ctx, resolvedRef, signer) + if err != nil { + return fmt.Errorf("signing archive: %w", err) + } + + result.SignatureDigest = sigDigest + result.Status = "success" + + return outputSignResult(cfg, &result) +} + +// parseSignFlags extracts and validates flags from the command. +func parseSignFlags(cmd *cobra.Command) (signFlags, error) { + var flags signFlags + var err error + + flags.keyPath, err = cmd.Flags().GetString("key") + if err != nil { + return flags, fmt.Errorf("reading key flag: %w", err) + } + + flags.outputSignature, err = cmd.Flags().GetBool("output-signature") + if err != nil { + return flags, fmt.Errorf("reading output-signature flag: %w", err) + } + + return flags, nil +} + +// buildSigner creates a signer based on the flags. +func buildSigner(flags signFlags) (*sigstore.Signer, error) { + if flags.keyPath != "" { + // Key-based signing + pemData, err := os.ReadFile(flags.keyPath) + if err != nil { + return nil, fmt.Errorf("reading key file: %w", err) + } + + // Password from BLOB_KEY_PASSWORD env var (optional, for encrypted keys) + var password []byte + if pwd := os.Getenv("BLOB_KEY_PASSWORD"); pwd != "" { + password = []byte(pwd) + } + + return sigstore.NewSigner( + sigstore.WithPrivateKeyPEM(pemData, password), + sigstore.WithRekor("https://rekor.sigstore.dev"), + ) + } + + // Keyless signing (default) + return sigstore.NewSigner( + sigstore.WithEphemeralKey(), + sigstore.WithFulcio("https://fulcio.sigstore.dev"), + sigstore.WithRekor("https://rekor.sigstore.dev"), + sigstore.WithAmbientCredentials(), + ) +} + +// signToStdout fetches the manifest and signs it, writing the signature bundle to stdout. +func signToStdout(ctx context.Context, ref string, signer *sigstore.Signer) error { + // Extract and validate the reference portion (tag or digest) + reference := extractReference(ref) + if reference == "" { + return fmt.Errorf("invalid reference %q: must include a tag or digest", ref) + } + + // Create OCI client to fetch raw manifest bytes + ociClient := oras.New(oras.WithDockerConfig()) + + // Resolve the reference to get the descriptor + desc, err := ociClient.Resolve(ctx, ref, reference) + if err != nil { + return fmt.Errorf("resolving reference: %w", err) + } + + // Fetch the raw manifest bytes (not re-serialized) + _, rawManifest, err := ociClient.FetchManifest(ctx, ref, &desc) + if err != nil { + return fmt.Errorf("fetching manifest: %w", err) + } + + // Sign the raw manifest bytes + sig, err := signer.Sign(ctx, rawManifest) + if err != nil { + return fmt.Errorf("signing manifest: %w", err) + } + + // Write signature bundle to stdout + _, err = os.Stdout.Write(sig.Data) + if err != nil { + return fmt.Errorf("writing signature: %w", err) + } + + return nil +} + +// extractReference extracts the tag or digest portion from a reference string. +func extractReference(ref string) string { + // Find @ for digest references + if idx := strings.LastIndex(ref, "@"); idx != -1 { + return ref[idx+1:] + } + // Find : for tag references (after the last /) + lastSlash := strings.LastIndex(ref, "/") + if idx := strings.LastIndex(ref[lastSlash+1:], ":"); idx != -1 { + return ref[lastSlash+1+idx+1:] + } + return "" +} + +// outputSignResult formats and outputs the sign result. +func outputSignResult(cfg *internalcfg.Config, result *signResult) error { + if cfg.Quiet { + return nil + } + if viper.GetString("output") == internalcfg.OutputJSON { + return signJSON(result) + } + return signText(result) +} + +func signJSON(result *signResult) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func signText(result *signResult) error { + fmt.Printf("Signed %s\n", result.Ref) + if result.ResolvedRef != "" { + fmt.Printf(" Resolved: %s\n", result.ResolvedRef) + } + fmt.Printf("Signature: %s\n", result.SignatureDigest) + return nil +} diff --git a/cmd/sign_test.go b/cmd/sign_test.go new file mode 100644 index 0000000..a8c46cc --- /dev/null +++ b/cmd/sign_test.go @@ -0,0 +1,174 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "testing" + + "github.com/meigma/blob/policy/sigstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractReference(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "tag reference", + input: "ghcr.io/acme/configs:v1.0.0", + want: "v1.0.0", + }, + { + name: "digest reference", + input: "ghcr.io/acme/configs@sha256:abc123", + want: "sha256:abc123", + }, + { + name: "tag with port", + input: "localhost:5000/repo:latest", + want: "latest", + }, + { + name: "no tag or digest", + input: "ghcr.io/acme/configs", + want: "", + }, + { + name: "nested path with tag", + input: "ghcr.io/acme/team/configs:v2", + want: "v2", + }, + { + name: "digest takes precedence over tag-like chars", + input: "ghcr.io/acme/configs:v1@sha256:abc123", + want: "sha256:abc123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractReference(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSignText(t *testing.T) { + tests := []struct { + name string + result signResult + wantOutput string + }{ + { + name: "basic sign", + result: signResult{ + Ref: "ghcr.io/test:v1", + SignatureDigest: "sha256:abc123", + Status: "success", + }, + wantOutput: "Signed ghcr.io/test:v1\nSignature: sha256:abc123\n", + }, + { + name: "sign with resolved ref", + result: signResult{ + Ref: "myalias:v1", + ResolvedRef: "ghcr.io/acme/configs:v1", + SignatureDigest: "sha256:def456", + Status: "success", + }, + wantOutput: "Signed myalias:v1\n Resolved: ghcr.io/acme/configs:v1\nSignature: sha256:def456\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := signText(&tt.result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + assert.Equal(t, tt.wantOutput, buf.String()) + }) + } +} + +func TestSignToStdout_InvalidReference(t *testing.T) { + // signToStdout should return a clear error when reference has no tag or digest + ctx := context.Background() + + // Create a mock signer (won't be used since we fail early) + signer, err := sigstore.NewSigner(sigstore.WithEphemeralKey()) + require.NoError(t, err) + + err = signToStdout(ctx, "ghcr.io/acme/configs", signer) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid reference") + assert.Contains(t, err.Error(), "must include a tag or digest") +} + +func TestSignJSON(t *testing.T) { + tests := []struct { + name string + result signResult + }{ + { + name: "basic sign", + result: signResult{ + Ref: "ghcr.io/test:v1", + SignatureDigest: "sha256:abc123", + Status: "success", + }, + }, + { + name: "sign with resolved ref", + result: signResult{ + Ref: "myalias:v1", + ResolvedRef: "ghcr.io/acme/configs:v1", + SignatureDigest: "sha256:def456", + Status: "success", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := signJSON(&tt.result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + // Parse the JSON and verify fields + var got signResult + err = json.Unmarshal(buf.Bytes(), &got) + require.NoError(t, err) + assert.Equal(t, tt.result.Ref, got.Ref) + assert.Equal(t, tt.result.ResolvedRef, got.ResolvedRef) + assert.Equal(t, tt.result.SignatureDigest, got.SignatureDigest) + assert.Equal(t, tt.result.Status, got.Status) + }) + } +} diff --git a/cmd/verify.go b/cmd/verify.go index fd70440..0d33160 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -1,7 +1,24 @@ package cmd import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/meigma/blob" "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/meigma/blob-cli/internal/archive" + internalcfg "github.com/meigma/blob-cli/internal/config" + "github.com/meigma/blob-cli/internal/policy" +) + +const ( + // exitCodePolicyViolation is the exit code for verification failures. + exitCodePolicyViolation = 5 ) var verifyCmd = &cobra.Command{ @@ -11,18 +28,244 @@ var verifyCmd = &cobra.Command{ Checks that the archive meets the specified policy requirements for signatures and attestations. Policies can be specified as -YAML files or OPA Rego policies.`, +YAML files or OPA Rego policies. + +If no policies are specified (via flags or config), verification +succeeds with a warning that no verification was performed.`, Example: ` blob verify ghcr.io/acme/configs:v1.0.0 blob verify --policy policy.yaml ghcr.io/acme/configs:v1.0.0 - blob verify --policy-rego custom.rego ghcr.io/acme/configs:v1.0.0`, + blob verify --policy-rego custom.rego ghcr.io/acme/configs:v1.0.0 + blob verify --no-default-policy --policy policy.yaml ghcr.io/acme/configs:v1.0.0`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, + RunE: runVerify, } func init() { verifyCmd.Flags().StringArray("policy", nil, "policy file for verification (repeatable)") verifyCmd.Flags().String("policy-rego", "", "OPA Rego policy file") - verifyCmd.Flags().String("policy-bundle", "", "OPA bundle for policy evaluation") + verifyCmd.Flags().Bool("no-default-policy", false, "skip policies from config file") +} + +// verifyResult contains the result of a verify operation. +type verifyResult struct { + Ref string `json:"ref"` + ResolvedRef string `json:"resolved_ref,omitempty"` + Digest string `json:"digest"` + Verified bool `json:"verified"` + Status string `json:"status"` // "verified", "no_policies" + PoliciesApplied int `json:"policies_applied"` + Signatures []referrerInfo `json:"signatures,omitempty"` + Attestations []referrerInfo `json:"attestations,omitempty"` +} + +// verifyFlags holds the parsed command flags. +type verifyFlags struct { + policyFiles []string + policyRego string + noDefaultPolicy bool +} + +func runVerify(cmd *cobra.Command, args []string) error { + // 1. Get config from context + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + // 2. Parse arguments + inputRef := args[0] + + // 3. Parse flags + flags, err := parseVerifyFlags(cmd) + if err != nil { + return err + } + + // 4. Resolve alias + resolvedRef := cfg.ResolveAlias(inputRef) + + // 5. Build policies from config + flags + policies, err := policy.BuildPolicies( + cfg, + resolvedRef, + flags.policyFiles, + flags.policyRego, + flags.noDefaultPolicy, + ) + if err != nil { + return fmt.Errorf("building policies: %w", err) + } + + // 6. Build result + result := verifyResult{ + Ref: inputRef, + PoliciesApplied: len(policies), + } + if inputRef != resolvedRef { + result.ResolvedRef = resolvedRef + } + + // 7. Handle no-policies case + if len(policies) == 0 { + // No policies - vacuous success with warning + inspectResult, inspectErr := archive.Inspect(cmd.Context(), resolvedRef) + if inspectErr != nil { + return fmt.Errorf("inspecting archive: %w", inspectErr) + } + + result.Digest = inspectResult.Digest() + result.Verified = false + result.Status = "no_policies" + + // Fetch referrers for signatures/attestations + populateReferrers(cmd.Context(), inspectResult, &result) + + // Output warning and result + if !cfg.Quiet && viper.GetString("output") != internalcfg.OutputJSON { + fmt.Fprintln(os.Stderr, "Warning: No policies applied - archive not verified") + } + + return outputVerifyResult(cfg, &result) + } + + // 8. Create client with policies for verification + clientOpts := []blob.Option{blob.WithDockerConfig()} + for _, p := range policies { + clientOpts = append(clientOpts, blob.WithPolicy(p)) + } + client, err := blob.NewClient(clientOpts...) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + // 9. Verify by calling Inspect (which triggers policy evaluation) + ctx := cmd.Context() + inspectResult, err := client.Inspect(ctx, resolvedRef) + if err != nil { + if errors.Is(err, blob.ErrPolicyViolation) { + return &ExitError{ + Code: exitCodePolicyViolation, + Err: fmt.Errorf("verification failed: %w", err), + } + } + return fmt.Errorf("verifying archive: %w", err) + } + + // 10. Verification succeeded + result.Digest = inspectResult.Digest() + result.Verified = true + result.Status = "verified" + + // Fetch referrers for signatures/attestations + populateReferrers(ctx, inspectResult, &result) + + return outputVerifyResult(cfg, &result) +} + +// parseVerifyFlags extracts and validates flags from the command. +func parseVerifyFlags(cmd *cobra.Command) (verifyFlags, error) { + var flags verifyFlags + var err error + + flags.policyFiles, err = cmd.Flags().GetStringArray("policy") + if err != nil { + return flags, fmt.Errorf("reading policy flag: %w", err) + } + + flags.policyRego, err = cmd.Flags().GetString("policy-rego") + if err != nil { + return flags, fmt.Errorf("reading policy-rego flag: %w", err) + } + + flags.noDefaultPolicy, err = cmd.Flags().GetBool("no-default-policy") + if err != nil { + return flags, fmt.Errorf("reading no-default-policy flag: %w", err) + } + + return flags, nil +} + +// populateReferrers fetches signatures and attestations and adds them to the result. +func populateReferrers(ctx context.Context, inspectResult *blob.InspectResult, result *verifyResult) { + signatures, sigErr := inspectResult.Referrers(ctx, sigstoreArtifactType) + if sigErr == nil { + result.Signatures = convertBlobReferrers(signatures) + } else if !errors.Is(sigErr, blob.ErrReferrersUnsupported) { + fmt.Fprintf(os.Stderr, "Warning: failed to fetch signatures: %v\n", sigErr) + } + + attestations, attErr := inspectResult.Referrers(ctx, inTotoArtifactType) + if attErr == nil { + result.Attestations = convertBlobReferrers(attestations) + } else if !errors.Is(attErr, blob.ErrReferrersUnsupported) { + fmt.Fprintf(os.Stderr, "Warning: failed to fetch attestations: %v\n", attErr) + } +} + +// convertBlobReferrers converts blob.Referrer slice to referrerInfo slice. +func convertBlobReferrers(refs []blob.Referrer) []referrerInfo { + if len(refs) == 0 { + return nil + } + result := make([]referrerInfo, len(refs)) + for i, r := range refs { + result[i] = referrerInfo{ + Digest: r.Digest, + ArtifactType: r.ArtifactType, + Annotations: r.Annotations, + } + } + return result +} + +// outputVerifyResult formats and outputs the verify result. +func outputVerifyResult(cfg *internalcfg.Config, result *verifyResult) error { + if cfg.Quiet { + return nil + } + if viper.GetString("output") == internalcfg.OutputJSON { + return verifyJSON(result) + } + return verifyText(result) +} + +func verifyJSON(result *verifyResult) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func verifyText(result *verifyResult) error { + if result.Verified { + fmt.Printf("Verified %s\n", result.Ref) + } else { + fmt.Printf("%s\n", result.Ref) + } + + if result.ResolvedRef != "" { + fmt.Printf("Resolved: %s\n", result.ResolvedRef) + } + fmt.Printf("Digest: %s\n", result.Digest) + + if result.Verified { + fmt.Printf("Policies: %d applied\n", result.PoliciesApplied) + } + + if len(result.Signatures) > 0 { + fmt.Println() + fmt.Println("Signatures:") + for _, sig := range result.Signatures { + fmt.Printf(" %s\n", sig.Digest) + } + } + + if len(result.Attestations) > 0 { + fmt.Println() + fmt.Println("Attestations:") + for _, att := range result.Attestations { + fmt.Printf(" %s\n", att.Digest) + } + } + + return nil } diff --git a/cmd/verify_test.go b/cmd/verify_test.go new file mode 100644 index 0000000..0621748 --- /dev/null +++ b/cmd/verify_test.go @@ -0,0 +1,294 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExitError(t *testing.T) { + t.Run("wraps error", func(t *testing.T) { + inner := errors.New("policy failed") + exitErr := &ExitError{Code: 5, Err: inner} + + assert.Equal(t, 5, exitErr.Code) + assert.Equal(t, "policy failed", exitErr.Error()) + assert.ErrorIs(t, exitErr, inner) + }) + + t.Run("nil inner error", func(t *testing.T) { + exitErr := &ExitError{Code: 5, Err: nil} + + assert.Equal(t, 5, exitErr.Code) + assert.Equal(t, "", exitErr.Error()) + }) + + t.Run("errors.As works", func(t *testing.T) { + inner := errors.New("verification failed") + exitErr := &ExitError{Code: exitCodePolicyViolation, Err: inner} + + var target *ExitError + assert.True(t, errors.As(exitErr, &target)) + assert.Equal(t, exitCodePolicyViolation, target.Code) + }) +} + +func TestVerifyText(t *testing.T) { + tests := []struct { + name string + result verifyResult + wantContain []string + }{ + { + name: "verified with policies", + result: verifyResult{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Verified: true, + Status: "verified", + PoliciesApplied: 2, + }, + wantContain: []string{ + "Verified ghcr.io/test:v1", + "Digest: sha256:abc123", + "Policies: 2 applied", + }, + }, + { + name: "unverified - no policies", + result: verifyResult{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Verified: false, + Status: "no_policies", + PoliciesApplied: 0, + }, + wantContain: []string{ + "ghcr.io/test:v1", + "Digest: sha256:abc123", + }, + }, + { + name: "with signatures", + result: verifyResult{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Verified: true, + Status: "verified", + PoliciesApplied: 1, + Signatures: []referrerInfo{ + {Digest: "sha256:sig1"}, + }, + }, + wantContain: []string{ + "Signatures:", + "sha256:sig1", + }, + }, + { + name: "with attestations", + result: verifyResult{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Verified: true, + Status: "verified", + PoliciesApplied: 1, + Attestations: []referrerInfo{ + {Digest: "sha256:att1"}, + }, + }, + wantContain: []string{ + "Attestations:", + "sha256:att1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := verifyText(&tt.result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + output := buf.String() + for _, want := range tt.wantContain { + assert.Contains(t, output, want) + } + }) + } +} + +func TestVerifyJSON(t *testing.T) { + tests := []struct { + name string + result verifyResult + }{ + { + name: "verified with policies", + result: verifyResult{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Verified: true, + Status: "verified", + PoliciesApplied: 2, + }, + }, + { + name: "no policies - status field clarity", + result: verifyResult{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Verified: false, + Status: "no_policies", + PoliciesApplied: 0, + }, + }, + { + name: "with signatures and attestations", + result: verifyResult{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Verified: true, + Status: "verified", + PoliciesApplied: 1, + Signatures: []referrerInfo{ + {Digest: "sha256:sig1", ArtifactType: "application/vnd.dev.sigstore.bundle.v0.3+json"}, + }, + Attestations: []referrerInfo{ + {Digest: "sha256:att1", ArtifactType: "application/vnd.in-toto+json"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := verifyJSON(&tt.result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + // Parse the JSON and verify fields + var got verifyResult + err = json.Unmarshal(buf.Bytes(), &got) + require.NoError(t, err) + assert.Equal(t, tt.result.Ref, got.Ref) + assert.Equal(t, tt.result.Digest, got.Digest) + assert.Equal(t, tt.result.Verified, got.Verified) + assert.Equal(t, tt.result.Status, got.Status) + assert.Equal(t, tt.result.PoliciesApplied, got.PoliciesApplied) + assert.Equal(t, len(tt.result.Signatures), len(got.Signatures)) + assert.Equal(t, len(tt.result.Attestations), len(got.Attestations)) + }) + } +} + +func TestVerifyJSON_StatusFieldClarity(t *testing.T) { + // This test ensures JSON consumers can distinguish between + // "no policies applied" and "verified with policies" scenarios + t.Run("no policies case has clear status", func(t *testing.T) { + result := verifyResult{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Verified: false, + Status: "no_policies", + PoliciesApplied: 0, + } + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := verifyJSON(&result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + // Parse and check both verified and status fields + var got map[string]interface{} + err = json.Unmarshal(buf.Bytes(), &got) + require.NoError(t, err) + + // verified=false combined with status="no_policies" clearly indicates + // that verification wasn't performed (not that it failed) + assert.Equal(t, false, got["verified"]) + assert.Equal(t, "no_policies", got["status"]) + assert.Equal(t, float64(0), got["policies_applied"]) + }) + + t.Run("verified case has clear status", func(t *testing.T) { + result := verifyResult{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Verified: true, + Status: "verified", + PoliciesApplied: 2, + } + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := verifyJSON(&result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + var got map[string]interface{} + err = json.Unmarshal(buf.Bytes(), &got) + require.NoError(t, err) + + assert.Equal(t, true, got["verified"]) + assert.Equal(t, "verified", got["status"]) + assert.Equal(t, float64(2), got["policies_applied"]) + }) +} + +func TestConvertBlobReferrers(t *testing.T) { + t.Run("nil input", func(t *testing.T) { + result := convertBlobReferrers(nil) + assert.Nil(t, result) + }) + + t.Run("empty input", func(t *testing.T) { + result := convertBlobReferrers(nil) + assert.Nil(t, result) + }) +} diff --git a/main.go b/main.go index b057bdd..23271db 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" @@ -10,6 +11,13 @@ import ( func main() { if err := cmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, "Error:", err) + + // Check for specific exit codes + var exitErr *cmd.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.Code) + } + os.Exit(1) } }