diff --git a/cmd/inspect.go b/cmd/inspect.go index 8d2639e..9160c84 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -1,7 +1,25 @@ package cmd import ( + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "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" +) + +const ( + // sigstoreArtifactType is the OCI artifact type for sigstore bundles. + sigstoreArtifactType = "application/vnd.dev.sigstore.bundle.v0.3+json" + // inTotoArtifactType is the OCI artifact type for in-toto attestations. + inTotoArtifactType = "application/vnd.in-toto+json" ) var inspectCmd = &cobra.Command{ @@ -20,7 +38,188 @@ Displays information including: Example: ` blob inspect ghcr.io/acme/configs:v1.0.0 blob inspect --output json ghcr.io/acme/configs:v1.0.0`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: runInspect, +} + +// inspectOutput contains the inspect output data for JSON format. +type inspectOutput struct { + Ref string `json:"ref"` + ResolvedRef string `json:"resolved_ref,omitempty"` + Digest string `json:"digest"` + Created string `json:"created,omitempty"` + Files int `json:"files"` + Size sizeInfo `json:"size"` + Compression string `json:"compression"` + Signatures []referrerInfo `json:"signatures,omitempty"` + Attestations []referrerInfo `json:"attestations,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +// sizeInfo contains size information. +type sizeInfo struct { + Compressed uint64 `json:"compressed"` + Uncompressed uint64 `json:"uncompressed"` + Ratio float64 `json:"ratio"` +} + +// referrerInfo contains information about a signature or attestation. +type referrerInfo struct { + Digest string `json:"digest"` + ArtifactType string `json:"artifact_type"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +func runInspect(cmd *cobra.Command, args []string) error { + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + inputRef := args[0] + resolvedRef := cfg.ResolveAlias(inputRef) + + result, err := archive.Inspect(cmd.Context(), resolvedRef) + if err != nil { + return err + } + + compression := determineCompression(result.Index()) + + // Fetch referrers (signatures and attestations). + ctx := cmd.Context() + signatures, sigErr := result.Referrers(ctx, sigstoreArtifactType) + attestations, attErr := result.Referrers(ctx, inTotoArtifactType) + + output := buildInspectOutput(inputRef, resolvedRef, result, compression, signatures, attestations) + + if cfg.Quiet { return nil - }, + } + + // Warn on unexpected referrer errors (ignore ErrReferrersUnsupported). + // Placed after quiet check to respect --quiet flag. + warnReferrerError(sigErr, "signatures") + warnReferrerError(attErr, "attestations") + + if viper.GetString("output") == internalcfg.OutputJSON { + return inspectJSON(&output) + } + return inspectText(&output) +} + +// warnReferrerError prints a warning to stderr for unexpected referrer errors. +// ErrReferrersUnsupported is silently ignored since many registries don't support referrers. +func warnReferrerError(err error, kind string) { + if err == nil || errors.Is(err, blob.ErrReferrersUnsupported) { + return + } + fmt.Fprintf(os.Stderr, "Warning: failed to fetch %s: %v\n", kind, err) +} + +// determineCompression checks entries for compression type. +func determineCompression(index *blob.IndexView) string { + for entry := range index.Entries() { + if entry.Compression() == blob.CompressionZstd { + return "zstd" + } + } + return "none" +} + +// buildInspectOutput creates the output struct from inspection result. +func buildInspectOutput( + inputRef, resolvedRef string, + result *blob.InspectResult, + compression string, + signatures, attestations []blob.Referrer, +) inspectOutput { + output := inspectOutput{ + Ref: inputRef, + Digest: result.Digest(), + Files: result.FileCount(), + Compression: compression, + Annotations: result.Manifest().Annotations(), + Size: sizeInfo{ + Compressed: result.TotalCompressedSize(), + Uncompressed: result.TotalUncompressedSize(), + Ratio: result.CompressionRatio(), + }, + } + + if inputRef != resolvedRef { + output.ResolvedRef = resolvedRef + } + + if created := result.Created(); !created.IsZero() { + output.Created = created.Format(time.RFC3339) + } + + output.Signatures = convertReferrers(signatures) + output.Attestations = convertReferrers(attestations) + + return output +} + +// convertReferrers converts blob.Referrer slice to referrerInfo slice. +func convertReferrers(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 +} + +func inspectJSON(output *inspectOutput) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(output) +} + +func inspectText(output *inspectOutput) error { + fmt.Printf("Reference: %s\n", output.Ref) + if output.ResolvedRef != "" { + fmt.Printf("Resolved: %s\n", output.ResolvedRef) + } + fmt.Printf("Digest: %s\n", output.Digest) + fmt.Printf("Files: %d\n", output.Files) + fmt.Printf("Size: %s (%s uncompressed)\n", + archive.FormatSize(output.Size.Compressed), + archive.FormatSize(output.Size.Uncompressed)) + fmt.Printf("Compression: %s\n", output.Compression) + if output.Created != "" { + fmt.Printf("Created: %s\n", output.Created) + } + + if len(output.Signatures) > 0 { + fmt.Println() + fmt.Println("Signatures:") + for _, sig := range output.Signatures { + fmt.Printf(" %s\n", sig.Digest) + } + } + + if len(output.Attestations) > 0 { + fmt.Println() + fmt.Println("Attestations:") + for _, att := range output.Attestations { + fmt.Printf(" %s\n", att.Digest) + } + } + + if len(output.Annotations) > 0 { + fmt.Println() + fmt.Println("Annotations:") + for k, v := range output.Annotations { + fmt.Printf(" %s: %s\n", k, v) + } + } + + return nil } diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go new file mode 100644 index 0000000..bb6c26b --- /dev/null +++ b/cmd/inspect_test.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "testing" + + "github.com/meigma/blob" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInspectCmd_NilConfig(t *testing.T) { + // Reset viper and restore after test to avoid affecting other tests. + viper.Reset() + t.Cleanup(viper.Reset) + + ctx := context.Background() + + inspectCmd.SetContext(ctx) + err := inspectCmd.RunE(inspectCmd, []string{"ghcr.io/test:v1"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "configuration not loaded") +} + +func TestConvertReferrers_Nil(t *testing.T) { + got := convertReferrers(nil) + assert.Nil(t, got) +} + +func TestConvertReferrers_Empty(t *testing.T) { + got := convertReferrers([]blob.Referrer{}) + assert.Nil(t, got) +} + +func TestConvertReferrers_Populated(t *testing.T) { + refs := []blob.Referrer{ + { + Digest: "sha256:abc123", + ArtifactType: "application/vnd.dev.sigstore.bundle.v0.3+json", + Annotations: map[string]string{"key": "value"}, + }, + { + Digest: "sha256:def456", + ArtifactType: "application/vnd.in-toto+json", + }, + } + + got := convertReferrers(refs) + + require.Len(t, got, 2) + assert.Equal(t, "sha256:abc123", got[0].Digest) + assert.Equal(t, "application/vnd.dev.sigstore.bundle.v0.3+json", got[0].ArtifactType) + assert.Equal(t, map[string]string{"key": "value"}, got[0].Annotations) + assert.Equal(t, "sha256:def456", got[1].Digest) + assert.Nil(t, got[1].Annotations) +} + +func TestWarnReferrerError_Nil(t *testing.T) { + // Capture stderr + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + warnReferrerError(nil, "signatures") + + w.Close() + os.Stderr = oldStderr + + var buf bytes.Buffer + buf.ReadFrom(r) + + assert.Empty(t, buf.String()) +} + +func TestWarnReferrerError_Unsupported(t *testing.T) { + // Capture stderr + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + warnReferrerError(blob.ErrReferrersUnsupported, "signatures") + + w.Close() + os.Stderr = oldStderr + + var buf bytes.Buffer + buf.ReadFrom(r) + + // ErrReferrersUnsupported should be silently ignored + assert.Empty(t, buf.String()) +} + +func TestWarnReferrerError_OtherError(t *testing.T) { + // Capture stderr + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + testErr := errors.New("authentication failed") + warnReferrerError(testErr, "signatures") + + w.Close() + os.Stderr = oldStderr + + var buf bytes.Buffer + buf.ReadFrom(r) + + // Other errors should produce a warning + got := buf.String() + assert.Contains(t, got, "Warning:") + assert.Contains(t, got, "signatures") + assert.Contains(t, got, "authentication failed") +} + +func TestInspectText_Basic(t *testing.T) { + output := &inspectOutput{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123def456", + Files: 42, + Compression: "zstd", + Size: sizeInfo{ + Compressed: 1024, + Uncompressed: 2048, + Ratio: 0.5, + }, + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := inspectText(output) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + got := buf.String() + assert.Contains(t, got, "Reference: ghcr.io/test:v1") + assert.Contains(t, got, "Digest: sha256:abc123def456") + assert.Contains(t, got, "Files: 42") + assert.Contains(t, got, "Compression: zstd") + assert.Contains(t, got, "1.0K") + assert.Contains(t, got, "2.0K uncompressed") + assert.NotContains(t, got, "Resolved:") + assert.NotContains(t, got, "Signatures:") + assert.NotContains(t, got, "Attestations:") + assert.NotContains(t, got, "Annotations:") +} + +func TestInspectText_WithResolvedRef(t *testing.T) { + output := &inspectOutput{ + Ref: "test", + ResolvedRef: "ghcr.io/acme/test:latest", + Digest: "sha256:abc123", + Files: 1, + Compression: "none", + Size: sizeInfo{Compressed: 100, Uncompressed: 100}, + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := inspectText(output) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + got := buf.String() + assert.Contains(t, got, "Reference: test") + assert.Contains(t, got, "Resolved: ghcr.io/acme/test:latest") +} + +func TestInspectText_WithSections(t *testing.T) { + output := &inspectOutput{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Files: 1, + Compression: "zstd", + Created: "2024-01-15T10:30:00Z", + Size: sizeInfo{Compressed: 100, Uncompressed: 200}, + Signatures: []referrerInfo{ + {Digest: "sha256:sig1"}, + }, + Attestations: []referrerInfo{ + {Digest: "sha256:att1"}, + }, + Annotations: map[string]string{ + "org.example.key": "value", + }, + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := inspectText(output) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + got := buf.String() + assert.Contains(t, got, "Created: 2024-01-15T10:30:00Z") + assert.Contains(t, got, "Signatures:") + assert.Contains(t, got, "sha256:sig1") + assert.Contains(t, got, "Attestations:") + assert.Contains(t, got, "sha256:att1") + assert.Contains(t, got, "Annotations:") + assert.Contains(t, got, "org.example.key: value") +} + +func TestInspectJSON(t *testing.T) { + output := &inspectOutput{ + Ref: "ghcr.io/test:v1", + ResolvedRef: "ghcr.io/resolved:v1", + Digest: "sha256:abc123", + Created: "2024-01-15T10:30:00Z", + Files: 42, + Compression: "zstd", + Size: sizeInfo{ + Compressed: 1024, + Uncompressed: 2048, + Ratio: 0.5, + }, + Signatures: []referrerInfo{ + {Digest: "sha256:sig1", ArtifactType: "sigstore"}, + }, + Annotations: map[string]string{"key": "value"}, + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := inspectJSON(output) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + var got inspectOutput + err = json.Unmarshal(buf.Bytes(), &got) + require.NoError(t, err) + + assert.Equal(t, "ghcr.io/test:v1", got.Ref) + assert.Equal(t, "ghcr.io/resolved:v1", got.ResolvedRef) + assert.Equal(t, "sha256:abc123", got.Digest) + assert.Equal(t, "2024-01-15T10:30:00Z", got.Created) + assert.Equal(t, 42, got.Files) + assert.Equal(t, "zstd", got.Compression) + assert.Equal(t, uint64(1024), got.Size.Compressed) + assert.Equal(t, uint64(2048), got.Size.Uncompressed) + assert.Equal(t, 0.5, got.Size.Ratio) + require.Len(t, got.Signatures, 1) + assert.Equal(t, "sha256:sig1", got.Signatures[0].Digest) + assert.Equal(t, map[string]string{"key": "value"}, got.Annotations) +} + +func TestInspectJSON_OmitsEmpty(t *testing.T) { + output := &inspectOutput{ + Ref: "ghcr.io/test:v1", + Digest: "sha256:abc123", + Files: 1, + Compression: "none", + Size: sizeInfo{Compressed: 100, Uncompressed: 100}, + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := inspectJSON(output) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + jsonStr := buf.String() + assert.NotContains(t, jsonStr, "resolved_ref") + assert.NotContains(t, jsonStr, "created") + assert.NotContains(t, jsonStr, "signatures") + assert.NotContains(t, jsonStr, "attestations") + assert.NotContains(t, jsonStr, "annotations") +}