diff --git a/app/cli/pkg/action/workflow_run_describe.go b/app/cli/pkg/action/workflow_run_describe.go index 471aea657..c1532b5c5 100644 --- a/app/cli/pkg/action/workflow_run_describe.go +++ b/app/cli/pkg/action/workflow_run_describe.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import ( "fmt" "sort" "strings" + "unicode/utf8" pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" @@ -76,6 +77,7 @@ type PolicyEvaluationStatus struct { type Material struct { Name string `json:"name"` Value string `json:"value"` + RawValue []byte `json:"raw_value,omitempty"` Hash string `json:"hash"` Tag string `json:"tag"` Filename string `json:"filename"` @@ -312,9 +314,22 @@ func policyEvaluationPBToAction(in *pb.PolicyEvaluation) *PolicyEvaluation { } func materialPBToAction(in *pb.AttestationItem_Material) *Material { + // Prefer raw_value (binary-safe) when available. + // Fall back to deprecated string value field for compatibility with + // older control plane versions that don't populate raw_value. + var value string + if raw := in.GetRawValue(); len(raw) > 0 { + if utf8.Valid(raw) { + value = string(raw) + } + } else { + value = in.GetValue() //nolint:staticcheck // fallback for older servers + } + m := &Material{ Name: in.Name, - Value: in.Value, + Value: value, + RawValue: in.GetRawValue(), Type: in.Type, Hash: in.Hash, Tag: in.Tag, diff --git a/app/controlplane/api/controlplane/v1/response_messages.pb.go b/app/controlplane/api/controlplane/v1/response_messages.pb.go index 0fff67449..fa8b0b7c5 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.pb.go +++ b/app/controlplane/api/controlplane/v1/response_messages.pb.go @@ -2434,7 +2434,9 @@ func (x *AttestationItem_EnvVariable) GetValue() string { type AttestationItem_Material struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // This might be the raw value, the container image name, the filename and so on + // Deprecated: use raw_value instead. This field cannot represent binary content. + // + // Deprecated: Marked as deprecated in controlplane/v1/response_messages.proto. Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // filename of the artifact that was either uploaded or injected inline in "value" Filename string `protobuf:"bytes,8,opt,name=filename,proto3" json:"filename,omitempty"` @@ -2448,8 +2450,14 @@ type AttestationItem_Material struct { UploadedToCas bool `protobuf:"varint,6,opt,name=uploaded_to_cas,json=uploadedToCas,proto3" json:"uploaded_to_cas,omitempty"` // the content instead if inline EmbeddedInline bool `protobuf:"varint,7,opt,name=embedded_inline,json=embeddedInline,proto3" json:"embedded_inline,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Binary-safe material content. For inline artifacts, contains the raw bytes. + // For string materials, contains the UTF-8 encoded value. + // For non-inline materials (container images, uploaded artifacts), contains + // the filename or image reference as UTF-8. + // Clients should prefer this field over the deprecated string value field. + RawValue []byte `protobuf:"bytes,10,opt,name=raw_value,json=rawValue,proto3" json:"raw_value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AttestationItem_Material) Reset() { @@ -2489,6 +2497,7 @@ func (x *AttestationItem_Material) GetName() string { return "" } +// Deprecated: Marked as deprecated in controlplane/v1/response_messages.proto. func (x *AttestationItem_Material) GetValue() string { if x != nil { return x.Value @@ -2545,6 +2554,13 @@ func (x *AttestationItem_Material) GetEmbeddedInline() bool { return false } +func (x *AttestationItem_Material) GetRawValue() []byte { + if x != nil { + return x.RawValue + } + return nil +} + type WorkflowContractVersionItem_RawBody struct { state protoimpl.MessageState `protogen:"open.v1"` Body []byte `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` @@ -2693,7 +2709,7 @@ const file_controlplane_v1_response_messages_proto_rawDesc = "" + "\n" + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12;\n" + "\vreleased_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "releasedAt\"\xbb\v\n" + + "releasedAt\"\xdc\v\n" + "\x0fAttestationItem\x12\x1e\n" + "\benvelope\x18\x03 \x01(\fB\x02\x18\x01R\benvelope\x12\x16\n" + "\x06bundle\x18\n" + @@ -2720,17 +2736,19 @@ const file_controlplane_v1_response_messages_proto_rawDesc = "" + "\x10violations_count\x18\a \x01(\x05R\x0fviolationsCount\x1a7\n" + "\vEnvVariable\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value\x1a\xf9\x02\n" + + "\x05value\x18\x02 \x01(\tR\x05value\x1a\x9a\x03\n" + "\bMaterial\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value\x12\x1a\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\x05value\x18\x02 \x01(\tB\x02\x18\x01R\x05value\x12\x1a\n" + "\bfilename\x18\b \x01(\tR\bfilename\x12\x12\n" + "\x04type\x18\x03 \x01(\tR\x04type\x12\\\n" + "\vannotations\x18\x04 \x03(\v2:.controlplane.v1.AttestationItem.Material.AnnotationsEntryR\vannotations\x12\x10\n" + "\x03tag\x18\t \x01(\tR\x03tag\x12\x12\n" + "\x04hash\x18\x05 \x01(\tR\x04hash\x12&\n" + "\x0fuploaded_to_cas\x18\x06 \x01(\bR\ruploadedToCas\x12'\n" + - "\x0fembedded_inline\x18\a \x01(\bR\x0eembeddedInline\x1a>\n" + + "\x0fembedded_inline\x18\a \x01(\bR\x0eembeddedInline\x12\x1b\n" + + "\traw_value\x18\n" + + " \x01(\fR\brawValue\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"X\n" + diff --git a/app/controlplane/api/controlplane/v1/response_messages.proto b/app/controlplane/api/controlplane/v1/response_messages.proto index dc3e71779..e90a1a213 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.proto +++ b/app/controlplane/api/controlplane/v1/response_messages.proto @@ -130,8 +130,8 @@ message AttestationItem { message Material { string name = 1; - // This might be the raw value, the container image name, the filename and so on - string value = 2; + // Deprecated: use raw_value instead. This field cannot represent binary content. + string value = 2 [deprecated = true]; // filename of the artifact that was either uploaded or injected inline in "value" string filename = 8; // Material type, i.e ARTIFACT @@ -144,6 +144,12 @@ message AttestationItem { bool uploaded_to_cas = 6; // the content instead if inline bool embedded_inline = 7; + // Binary-safe material content. For inline artifacts, contains the raw bytes. + // For string materials, contains the UTF-8 encoded value. + // For non-inline materials (container images, uploaded artifacts), contains + // the filename or image reference as UTF-8. + // Clients should prefer this field over the deprecated string value field. + bytes raw_value = 10; } } diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts index 51e86aa40..ed9f68968 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts @@ -405,7 +405,11 @@ export interface AttestationItem_EnvVariable { export interface AttestationItem_Material { name: string; - /** This might be the raw value, the container image name, the filename and so on */ + /** + * Deprecated: use raw_value instead. This field cannot represent binary content. + * + * @deprecated + */ value: string; /** filename of the artifact that was either uploaded or injected inline in "value" */ filename: string; @@ -419,6 +423,14 @@ export interface AttestationItem_Material { uploadedToCas: boolean; /** the content instead if inline */ embeddedInline: boolean; + /** + * Binary-safe material content. For inline artifacts, contains the raw bytes. + * For string materials, contains the UTF-8 encoded value. + * For non-inline materials (container images, uploaded artifacts), contains + * the filename or image reference as UTF-8. + * Clients should prefer this field over the deprecated string value field. + */ + rawValue: Uint8Array; } export interface AttestationItem_Material_AnnotationsEntry { @@ -1931,6 +1943,7 @@ function createBaseAttestationItem_Material(): AttestationItem_Material { hash: "", uploadedToCas: false, embeddedInline: false, + rawValue: new Uint8Array(0), }; } @@ -1963,6 +1976,9 @@ export const AttestationItem_Material = { if (message.embeddedInline === true) { writer.uint32(56).bool(message.embeddedInline); } + if (message.rawValue.length !== 0) { + writer.uint32(82).bytes(message.rawValue); + } return writer; }, @@ -2039,6 +2055,13 @@ export const AttestationItem_Material = { message.embeddedInline = reader.bool(); continue; + case 10: + if (tag !== 82) { + break; + } + + message.rawValue = reader.bytes(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -2064,6 +2087,7 @@ export const AttestationItem_Material = { hash: isSet(object.hash) ? String(object.hash) : "", uploadedToCas: isSet(object.uploadedToCas) ? Boolean(object.uploadedToCas) : false, embeddedInline: isSet(object.embeddedInline) ? Boolean(object.embeddedInline) : false, + rawValue: isSet(object.rawValue) ? bytesFromBase64(object.rawValue) : new Uint8Array(0), }; }, @@ -2083,6 +2107,8 @@ export const AttestationItem_Material = { message.hash !== undefined && (obj.hash = message.hash); message.uploadedToCas !== undefined && (obj.uploadedToCas = message.uploadedToCas); message.embeddedInline !== undefined && (obj.embeddedInline = message.embeddedInline); + message.rawValue !== undefined && + (obj.rawValue = base64FromBytes(message.rawValue !== undefined ? message.rawValue : new Uint8Array(0))); return obj; }, @@ -2109,6 +2135,7 @@ export const AttestationItem_Material = { message.hash = object.hash ?? ""; message.uploadedToCas = object.uploadedToCas ?? false; message.embeddedInline = object.embeddedInline ?? false; + message.rawValue = object.rawValue ?? new Uint8Array(0); return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.Material.jsonschema.json index 35934092d..e68a61639 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.Material.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.Material.jsonschema.json @@ -7,6 +7,11 @@ "description": "the content instead if inline", "type": "boolean" }, + "^(raw_value)$": { + "description": "Binary-safe material content. For inline artifacts, contains the raw bytes.\n For string materials, contains the UTF-8 encoded value.\n For non-inline materials (container images, uploaded artifacts), contains\n the filename or image reference as UTF-8.\n Clients should prefer this field over the deprecated string value field.", + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "type": "string" + }, "^(uploaded_to_cas)$": { "description": "it's been uploaded to an actual CAS backend", "type": "boolean" @@ -36,6 +41,11 @@ "name": { "type": "string" }, + "rawValue": { + "description": "Binary-safe material content. For inline artifacts, contains the raw bytes.\n For string materials, contains the UTF-8 encoded value.\n For non-inline materials (container images, uploaded artifacts), contains\n the filename or image reference as UTF-8.\n Clients should prefer this field over the deprecated string value field.", + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "type": "string" + }, "tag": { "description": "in the case of a container image, the tag of the attested image", "type": "string" @@ -49,7 +59,7 @@ "type": "boolean" }, "value": { - "description": "This might be the raw value, the container image name, the filename and so on", + "description": "Deprecated: use raw_value instead. This field cannot represent binary content.", "type": "string" } }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.Material.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.Material.schema.json index abd84119c..078d282b6 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.Material.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.Material.schema.json @@ -7,6 +7,11 @@ "description": "the content instead if inline", "type": "boolean" }, + "^(rawValue)$": { + "description": "Binary-safe material content. For inline artifacts, contains the raw bytes.\n For string materials, contains the UTF-8 encoded value.\n For non-inline materials (container images, uploaded artifacts), contains\n the filename or image reference as UTF-8.\n Clients should prefer this field over the deprecated string value field.", + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "type": "string" + }, "^(uploadedToCas)$": { "description": "it's been uploaded to an actual CAS backend", "type": "boolean" @@ -36,6 +41,11 @@ "name": { "type": "string" }, + "raw_value": { + "description": "Binary-safe material content. For inline artifacts, contains the raw bytes.\n For string materials, contains the UTF-8 encoded value.\n For non-inline materials (container images, uploaded artifacts), contains\n the filename or image reference as UTF-8.\n Clients should prefer this field over the deprecated string value field.", + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "type": "string" + }, "tag": { "description": "in the case of a container image, the tag of the attested image", "type": "string" @@ -49,7 +59,7 @@ "type": "boolean" }, "value": { - "description": "This might be the raw value, the container image name, the filename and so on", + "description": "Deprecated: use raw_value instead. This field cannot represent binary content.", "type": "string" } }, diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index ece70348e..75a1731c9 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -21,6 +21,7 @@ import ( "fmt" "sort" "time" + "unicode/utf8" "github.com/cenkalti/backoff/v4" cpAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" @@ -654,12 +655,18 @@ func extractMaterials(in []*chainloop.NormalizedMaterial) ([]*cpAPI.AttestationI Type: m.Type, Filename: m.Filename, Annotations: m.Annotations, - Value: m.Value, + RawValue: m.Value, UploadedToCas: m.UploadedToCAS, EmbeddedInline: m.EmbeddedInline, Tag: m.Tag, } + // Deprecated: maintained for backward compatibility with older CLI versions + // that read the string value field. Only populated when content is valid UTF-8. + if utf8.Valid(m.Value) { + materialItem.Value = string(m.Value) //nolint:staticcheck // deprecated field populated for backward compatibility + } + if m.Hash != nil { materialItem.Hash = m.Hash.String() } diff --git a/app/controlplane/internal/service/attestation_test.go b/app/controlplane/internal/service/attestation_test.go index 26c71700b..a696ec577 100644 --- a/app/controlplane/internal/service/attestation_test.go +++ b/app/controlplane/internal/service/attestation_test.go @@ -31,17 +31,17 @@ func TestExtractMaterials(t *testing.T) { want []*cpAPI.AttestationItem_Material }{ { - name: "different material types", + name: "different material types with UTF-8 content", input: []*chainloop.NormalizedMaterial{ { Name: "foo", Type: "STRING", - Value: "bar", + Value: []byte("bar"), }, { Name: "with_annotations", Type: "STRING", - Value: "bar", + Value: []byte("bar"), Annotations: map[string]string{ "foo": "bar", "bar": "baz", @@ -50,42 +50,69 @@ func TestExtractMaterials(t *testing.T) { { Name: "foo", Type: "ARTIFACT", - Value: "bar", + Value: []byte("bar"), Hash: &crv1.Hash{Algorithm: "sha256", Hex: "deadbeef"}, }, { Name: "image", Type: "CONTAINER_IMAGE", - Value: "docker.io/nginx", + Value: []byte("docker.io/nginx"), Hash: &crv1.Hash{Algorithm: "sha256", Hex: "deadbeef"}, }, }, want: []*cpAPI.AttestationItem_Material{ { - Name: "foo", - Type: "STRING", - Value: "bar", + Name: "foo", + Type: "STRING", + Value: "bar", + RawValue: []byte("bar"), }, { - Name: "with_annotations", - Type: "STRING", - Value: "bar", + Name: "with_annotations", + Type: "STRING", + Value: "bar", + RawValue: []byte("bar"), Annotations: map[string]string{ "foo": "bar", "bar": "baz", }, }, { - Name: "foo", - Type: "ARTIFACT", - Value: "bar", - Hash: "sha256:deadbeef", + Name: "foo", + Type: "ARTIFACT", + Value: "bar", + RawValue: []byte("bar"), + Hash: "sha256:deadbeef", }, { - Name: "image", - Type: "CONTAINER_IMAGE", - Value: "docker.io/nginx", - Hash: "sha256:deadbeef", + Name: "image", + Type: "CONTAINER_IMAGE", + Value: "docker.io/nginx", + RawValue: []byte("docker.io/nginx"), + Hash: "sha256:deadbeef", + }, + }, + }, + { + name: "binary content skips deprecated string value", + input: []*chainloop.NormalizedMaterial{ + { + Name: "binary-artifact", + Type: "ARTIFACT", + Filename: "data.bin", + Value: []byte{0xff, 0xfe, 0x00, 0x01}, + Hash: &crv1.Hash{Algorithm: "sha256", Hex: "deadbeef"}, + EmbeddedInline: true, + }, + }, + want: []*cpAPI.AttestationItem_Material{ + { + Name: "binary-artifact", + Type: "ARTIFACT", + Filename: "data.bin", + RawValue: []byte{0xff, 0xfe, 0x00, 0x01}, + Hash: "sha256:deadbeef", + EmbeddedInline: true, }, }, }, diff --git a/app/controlplane/pkg/biz/organization.go b/app/controlplane/pkg/biz/organization.go index e0a3cbb8c..20e23a63d 100644 --- a/app/controlplane/pkg/biz/organization.go +++ b/app/controlplane/pkg/biz/organization.go @@ -142,7 +142,6 @@ func (uc *OrganizationUseCase) CreateWithRandomName(ctx context.Context, opts .. uc.logger.Debugw("msg", "Org exists!", "name", name) continue } - uc.logger.Debugw("msg", "BOOM", "name", name, "err", err) return nil, err } diff --git a/app/controlplane/plugins/sdk/v1/helpers.go b/app/controlplane/plugins/sdk/v1/helpers.go index f85b2fbaf..7b4ba3e6f 100644 --- a/app/controlplane/plugins/sdk/v1/helpers.go +++ b/app/controlplane/plugins/sdk/v1/helpers.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import ( "fmt" "sort" "time" + "unicode/utf8" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" "github.com/jedib0t/go-pretty/v6/table" @@ -135,10 +136,12 @@ func (r *renderer) summaryTable(m *ChainloopMetadata, predicate chainloop.Normal for _, m := range materials { mt.AppendRow(table.Row{"Name", m.Name}) mt.AppendRow(table.Row{"Type", m.Type}) - value := m.Value + var value string // Override the value for the filename of the item uploaded if m.EmbeddedInline || m.UploadedToCAS { value = m.Filename + } else if utf8.Valid(m.Value) { + value = string(m.Value) } mt.AppendRow(table.Row{"Value", wrap.String(value, 100)}) if m.Hash != nil { diff --git a/app/controlplane/plugins/sdk/v1/plugin/api/fanout.pb.go b/app/controlplane/plugins/sdk/v1/plugin/api/fanout.pb.go index a6b831964..807320b93 100644 --- a/app/controlplane/plugins/sdk/v1/plugin/api/fanout.pb.go +++ b/app/controlplane/plugins/sdk/v1/plugin/api/fanout.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/app/controlplane/plugins/sdk/v1/plugin/api/fanout.proto b/app/controlplane/plugins/sdk/v1/plugin/api/fanout.proto index dda1c18b2..a717e439a 100644 --- a/app/controlplane/plugins/sdk/v1/plugin/api/fanout.proto +++ b/app/controlplane/plugins/sdk/v1/plugin/api/fanout.proto @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/app/controlplane/plugins/sdk/v1/plugin/api/fanout_grpc.pb.go b/app/controlplane/plugins/sdk/v1/plugin/api/fanout_grpc.pb.go index c9e21f461..74c4cda2a 100644 --- a/app/controlplane/plugins/sdk/v1/plugin/api/fanout_grpc.pb.go +++ b/app/controlplane/plugins/sdk/v1/plugin/api/fanout_grpc.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/app/controlplane/plugins/sdk/v1/plugin/api/translation.go b/app/controlplane/plugins/sdk/v1/plugin/api/translation.go index dbb126cee..be3df4b63 100644 --- a/app/controlplane/plugins/sdk/v1/plugin/api/translation.go +++ b/app/controlplane/plugins/sdk/v1/plugin/api/translation.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package api import ( "errors" + "unicode/utf8" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" @@ -148,11 +149,18 @@ func MetadataProtoToSDK(in *ExecuteRequest_Metadata) *sdk.ChainloopMetadata { } func MaterialSDKToProto(in *sdk.ExecuteMaterial) *ExecuteRequest_NormalizedMaterial { + // The fanout proto uses string for value since plugins expect text. + // Binary inline content is available in the Content bytes field instead. + var value string + if utf8.Valid(in.Value) { + value = string(in.Value) + } + return &ExecuteRequest_NormalizedMaterial{ Content: in.Content, Name: in.Name, Type: in.Type, - Value: in.Value, + Value: value, FileName: in.Filename, Hash: in.Hash.String(), UploadedToCas: in.UploadedToCAS, @@ -171,7 +179,7 @@ func MaterialProtoToSDK(in *ExecuteRequest_NormalizedMaterial) *sdk.ExecuteMater NormalizedMaterial: &chainloop.NormalizedMaterial{ Name: in.Name, Type: in.Type, - Value: in.Value, + Value: []byte(in.Value), Filename: in.FileName, UploadedToCAS: in.UploadedToCas, Hash: &hash, diff --git a/pkg/attestation/renderer/chainloop/chainloop.go b/pkg/attestation/renderer/chainloop/chainloop.go index ae46fe1a1..b1a7947f2 100644 --- a/pkg/attestation/renderer/chainloop/chainloop.go +++ b/pkg/attestation/renderer/chainloop/chainloop.go @@ -70,8 +70,8 @@ type NormalizedMaterial struct { Type string // filename of the artifact that was either uploaded or injected inline in "value" Filename string - // Inline content for an artifact or string material - Value string + // Inline content for an artifact or string material (binary-safe) + Value []byte // Hash of the Material Hash *crv1.Hash // Tag of the container image diff --git a/pkg/attestation/renderer/chainloop/chainloop_test.go b/pkg/attestation/renderer/chainloop/chainloop_test.go index b05cbb138..391e148ef 100644 --- a/pkg/attestation/renderer/chainloop/chainloop_test.go +++ b/pkg/attestation/renderer/chainloop/chainloop_test.go @@ -53,7 +53,7 @@ func TestExtractPredicate(t *testing.T) { }, { Name: "image", Type: "CONTAINER_IMAGE", - Value: "index.docker.io/bitnami/nginx", + Value: []byte("index.docker.io/bitnami/nginx"), Hash: &crv1.Hash{Algorithm: "sha256", Hex: "747ef335ea27a2faf08aa292a5bc5491aff50c6a94ee4ebcbbcd43cdeccccaf1"}, Annotations: map[string]string{ "another_annotation": "foo", @@ -77,7 +77,7 @@ func TestExtractPredicate(t *testing.T) { Filename: "inline-sbom.json", Hash: &crv1.Hash{Algorithm: "sha256", Hex: "16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c"}, EmbeddedInline: true, - Value: "hello inline!", + Value: []byte("hello inline!"), Annotations: map[string]string{ "chainloop.material.cas.inline": "true", "chainloop.material.name": "sbom", @@ -86,7 +86,7 @@ func TestExtractPredicate(t *testing.T) { }, { Name: "stringvar", Type: "STRING", - Value: "helloworld", + Value: []byte("helloworld"), Annotations: map[string]string{ "chainloop.material.name": "stringvar", "chainloop.material.type": "STRING", diff --git a/pkg/attestation/renderer/chainloop/v02.go b/pkg/attestation/renderer/chainloop/v02.go index 3d6b5a5b4..cf48aa3a6 100644 --- a/pkg/attestation/renderer/chainloop/v02.go +++ b/pkg/attestation/renderer/chainloop/v02.go @@ -508,7 +508,7 @@ func normalizeMaterial(material *intoto.ResourceDescriptor) (*NormalizedMaterial return nil, fmt.Errorf("material content not found") } - m.Value = string(material.Content) + m.Value = material.Content hash, ok := material.Digest["sha256"] if ok { m.Hash = &crv1.Hash{Algorithm: "sha256", Hex: hash} @@ -531,7 +531,7 @@ func normalizeMaterial(material *intoto.ResourceDescriptor) (*NormalizedMaterial } // In the case of container images for example the value is in the name field - m.Value = material.Name + m.Value = []byte(material.Name) if v, ok := mAnnotationsMap[v1.AnnotationMaterialCAS]; ok && v.GetBoolValue() { m.UploadedToCAS = true @@ -564,11 +564,11 @@ func normalizeMaterial(material *intoto.ResourceDescriptor) (*NormalizedMaterial // In the case of an artifact type or derivative the filename is set and the inline content if any if m.EmbeddedInline || m.UploadedToCAS { m.Filename = material.Name - m.Value = "" + m.Value = nil } if m.EmbeddedInline { - m.Value = string(material.Content) + m.Value = material.Content } return m, nil diff --git a/pkg/attestation/renderer/chainloop/v02_test.go b/pkg/attestation/renderer/chainloop/v02_test.go index 744687155..f7ccfdb3d 100644 --- a/pkg/attestation/renderer/chainloop/v02_test.go +++ b/pkg/attestation/renderer/chainloop/v02_test.go @@ -160,7 +160,7 @@ func TestNormalizeMaterial(t *testing.T) { want: &NormalizedMaterial{ Name: "foo", Type: "STRING", - Value: "bar", + Value: []byte("bar"), Annotations: map[string]string{ "chainloop.material.name": "foo", "chainloop.material.type": "STRING", @@ -251,7 +251,7 @@ func TestNormalizeMaterial(t *testing.T) { Name: "foo", Type: "ARTIFACT", Filename: "artifact.tgz", - Value: "this is an inline material", + Value: []byte("this is an inline material"), Hash: &crv1.Hash{Algorithm: "sha256", Hex: "deadbeef"}, EmbeddedInline: true, Annotations: map[string]string{ @@ -300,6 +300,102 @@ func TestNormalizeMaterial(t *testing.T) { } } +// TestBinaryContentStructRoundTrip verifies that binary (non-UTF-8) content in +// ResourceDescriptor.Content survives the structpb.Struct round-trip that happens +// during predicate() → extractPredicate(): json.Marshal → protojson.Unmarshal(Struct) → +// protojson.Marshal(Struct) → json.Unmarshal. +func TestBinaryContentStructRoundTrip(t *testing.T) { + testCases := []struct { + name string + content []byte + }{ + { + name: "null bytes and high bytes", + content: []byte{0xff, 0xfe, 0x00, 0x01, 0x80, 0x7f}, + }, + { + name: "ELF header", + content: []byte{0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00}, + }, + { + name: "all byte values", + content: func() []byte { + b := make([]byte, 256) + for i := range b { + b[i] = byte(i) + } + return b + }(), + }, + { + name: "valid UTF-8 text", + content: []byte("hello world"), + }, + { + name: "large binary payload", + content: func() []byte { + b := make([]byte, 64*1024) + for i := range b { + b[i] = byte(i % 251) + } + return b + }(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Build a minimal predicate with one inline artifact material + original := &ProvenancePredicateV02{ + ProvenancePredicateCommon: &ProvenancePredicateCommon{}, + Materials: []*intoto.ResourceDescriptor{ + { + Name: "binary.bin", + Annotations: mapToStruct(t, map[string]any{ + "chainloop.material.name": "test-binary", + "chainloop.material.type": "ARTIFACT", + "chainloop.material.cas.inline": true, + }), + Digest: map[string]string{"sha256": "deadbeef"}, + Content: tc.content, + }, + }, + } + + // === Step 1: predicate() path === + // json.Marshal base64-encodes []byte fields + predicateJSON, err := json.Marshal(original) + require.NoError(t, err) + + // protojson.Unmarshal stores the base64 string in Struct + predStruct := &structpb.Struct{} + err = protojson.Unmarshal(predicateJSON, predStruct) + require.NoError(t, err) + + // === Step 2: extractPredicate() path === + // protojson.Marshal outputs the Struct as JSON + roundTrippedJSON, err := protojson.Marshal(predStruct) + require.NoError(t, err) + + // json.Unmarshal decodes base64 back into []byte + var restored ProvenancePredicateV02 + err = json.Unmarshal(roundTrippedJSON, &restored) + require.NoError(t, err) + + require.Len(t, restored.Materials, 1) + assert.Equal(t, tc.content, restored.Materials[0].Content, + "binary content should survive structpb.Struct round-trip") + + // === Step 3: normalizeMaterial() === + normalized, err := normalizeMaterial(restored.Materials[0]) + require.NoError(t, err) + assert.Equal(t, tc.content, normalized.Value, + "binary content should survive normalization") + assert.True(t, normalized.EmbeddedInline) + }) + } +} + func TestStructValueToString(t *testing.T) { testCases := []struct { name string