| // Copyright 2026 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package rsmsg |
| |
| import ( |
| "fmt" |
| "net/url" |
| "strings" |
| |
| "google.golang.org/protobuf/proto" |
| |
| rspb "google.golang.org/genproto/googleapis/devtools/resultstore/v2" |
| ) |
| |
| // IDTransformer defines a strategy for transforming ResultStore resource IDs. |
| type IDTransformer struct { |
| // ActionID transforms an action ID string. |
| ActionID func(string) string |
| // OtherID transforms non-action ID strings (Invocations, Targets, etc.). |
| OtherID func(string) string |
| } |
| |
| // Invocation updates the ID and Name of an Invocation using the provided strategy. |
| func (t *IDTransformer) Invocation(inv *rspb.Invocation) { |
| if inv == nil { |
| return |
| } |
| if inv.Id != nil { |
| inv.Id.InvocationId = t.OtherID(inv.Id.InvocationId) |
| } |
| if inv.Name != "" { |
| prefix, id := DecomposeInvocationName(inv.Name) |
| if id != nil { |
| id.InvocationId = t.OtherID(id.InvocationId) |
| inv.Name = ReconstructInvocationName(prefix, id) |
| } |
| } |
| } |
| |
| // Target updates the ID and Name of a Target using the provided strategy. |
| func (t *IDTransformer) Target(target *rspb.Target) { |
| if target == nil { |
| return |
| } |
| if target.Id != nil { |
| target.Id.InvocationId = t.OtherID(target.Id.InvocationId) |
| target.Id.TargetId = t.OtherID(target.Id.TargetId) |
| } |
| if target.Name != "" { |
| prefix, id := DecomposeTargetName(target.Name) |
| if id != nil { |
| id.InvocationId = t.OtherID(id.InvocationId) |
| id.TargetId = t.OtherID(id.TargetId) |
| target.Name = ReconstructTargetName(prefix, id) |
| } |
| } |
| } |
| |
| // Configuration updates the ID and Name of a Configuration using the provided strategy. |
| func (t *IDTransformer) Configuration(cfg *rspb.Configuration) { |
| if cfg == nil { |
| return |
| } |
| if cfg.Id != nil { |
| cfg.Id.InvocationId = t.OtherID(cfg.Id.InvocationId) |
| cfg.Id.ConfigurationId = t.OtherID(cfg.Id.ConfigurationId) |
| } |
| if cfg.Name != "" { |
| prefix, id := DecomposeConfigurationName(cfg.Name) |
| if id != nil { |
| id.InvocationId = t.OtherID(id.InvocationId) |
| id.ConfigurationId = t.OtherID(id.ConfigurationId) |
| cfg.Name = ReconstructConfigurationName(prefix, id) |
| } |
| } |
| } |
| |
| // ConfiguredTarget updates the ID and Name of a ConfiguredTarget using the provided strategy. |
| func (t *IDTransformer) ConfiguredTarget(ct *rspb.ConfiguredTarget) { |
| if ct == nil { |
| return |
| } |
| if ct.Id != nil { |
| ct.Id.InvocationId = t.OtherID(ct.Id.InvocationId) |
| ct.Id.TargetId = t.OtherID(ct.Id.TargetId) |
| ct.Id.ConfigurationId = t.OtherID(ct.Id.ConfigurationId) |
| } |
| if ct.Name != "" { |
| prefix, id := DecomposeConfiguredTargetName(ct.Name) |
| if id != nil { |
| id.InvocationId = t.OtherID(id.InvocationId) |
| id.TargetId = t.OtherID(id.TargetId) |
| id.ConfigurationId = t.OtherID(id.ConfigurationId) |
| ct.Name = ReconstructConfiguredTargetName(prefix, id) |
| } |
| } |
| } |
| |
| // Action updates the ID and Name of an Action using the provided strategy. |
| func (t *IDTransformer) Action(a *rspb.Action) { |
| if a == nil { |
| return |
| } |
| if a.Id != nil { |
| a.Id.InvocationId = t.OtherID(a.Id.InvocationId) |
| a.Id.TargetId = t.OtherID(a.Id.TargetId) |
| a.Id.ConfigurationId = t.OtherID(a.Id.ConfigurationId) |
| a.Id.ActionId = t.ActionID(a.Id.ActionId) |
| } |
| if a.Name != "" { |
| prefix, id := DecomposeActionName(a.Name) |
| if id != nil { |
| id.InvocationId = t.OtherID(id.InvocationId) |
| id.TargetId = t.OtherID(id.TargetId) |
| id.ConfigurationId = t.OtherID(id.ConfigurationId) |
| id.ActionId = t.ActionID(id.ActionId) |
| a.Name = ReconstructActionName(prefix, id) |
| } |
| } |
| } |
| |
| // ResourceIDs updates IDs and hierarchical names within a ResultStore |
| // UploadRequest to ensure they are compatible with the backend API requirements. |
| func (t *IDTransformer) ResourceIDs(req *rspb.UploadRequest) { |
| if req.Id != nil { |
| req.Id.TargetId = t.OtherID(req.Id.TargetId) |
| req.Id.ConfigurationId = t.OtherID(req.Id.ConfigurationId) |
| req.Id.ActionId = t.ActionID(req.Id.ActionId) |
| } |
| t.Invocation(req.GetInvocation()) |
| t.Target(req.GetTarget()) |
| t.Configuration(req.GetConfiguration()) |
| t.ConfiguredTarget(req.GetConfiguredTarget()) |
| t.Action(req.GetAction()) |
| } |
| |
| // mangler is the default IDTransformer that hex-encodes IDs for ResultStore compatibility. |
| var mangler = &IDTransformer{ |
| ActionID: MangleActionID, |
| OtherID: MangleOtherID, |
| } |
| |
| // MangleActionID: unescape -> encode with '_' |
| func MangleActionID(s string) string { |
| return EncodeID(UnescapeID(s), "_") |
| } |
| |
| // MangleOtherID: unescape -> encode with '%' |
| func MangleOtherID(s string) string { |
| return EncodeID(UnescapeID(s), "%") |
| } |
| |
| // MangleInvocation updates the ID and Name of an Invocation using the mangler strategy. |
| func MangleInvocation(inv *rspb.Invocation) { |
| mangler.Invocation(inv) |
| } |
| |
| // MangleTarget updates the ID and Name of a Target using the mangler strategy. |
| func MangleTarget(t *rspb.Target) { |
| mangler.Target(t) |
| } |
| |
| // MangleConfiguration updates the ID and Name of a Configuration using the mangler strategy. |
| func MangleConfiguration(cfg *rspb.Configuration) { |
| mangler.Configuration(cfg) |
| } |
| |
| // MangleConfiguredTarget updates the ID and Name of a ConfiguredTarget using the mangler strategy. |
| func MangleConfiguredTarget(ct *rspb.ConfiguredTarget) { |
| mangler.ConfiguredTarget(ct) |
| } |
| |
| // MangleAction updates the ID and Name of an Action using the mangler strategy. |
| func MangleAction(a *rspb.Action) { |
| mangler.Action(a) |
| } |
| |
| // MangleResourceIDs updates IDs and hierarchical names within a ResultStore |
| // UploadRequest using the mangler strategy. |
| func MangleResourceIDs(req *rspb.UploadRequest) { |
| mangler.ResourceIDs(req) |
| } |
| |
| // UnescapeID removes URL encoding from an ID string if present. |
| func UnescapeID(s string) string { |
| if strings.Contains(s, "%") { |
| if u, err := url.PathUnescape(s); err == nil { |
| return u |
| } |
| } |
| return s |
| } |
| |
| // EncodeID hex-encodes characters that are not alphanumeric or '-'. |
| // This custom encoding is necessary because the ResultStore API has strict |
| // requirements for resource IDs: only 'a-z, A-Z, 0-9, _, and -' are allowed. |
| // |
| // Unlike standard URL encoding (e.g. net/url PathEscape), this function: |
| // 1. Escapes characters like '.' and '/' which are often allowed in URLs but |
| // restricted by ResultStore. |
| // 2. Supports a custom escapeChar (typically '%' or '_'). Actions use '_' |
| // to avoid conflicts with URL-encoded names in certain contexts, while |
| // other resources use standard '%'. |
| // |
| // This implementation matches the legacy behavior required for compatibility |
| // with existing Fuchsia build-to-ResultStore integrations. |
| // |
| // The escape character itself is always escaped (e.g., '%' -> '%25' or '_' -> '__'). |
| func EncodeID(s string, escapeChar string) string { |
| var sb strings.Builder |
| for i := 0; i < len(s); i++ { |
| c := s[i] |
| if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' { |
| sb.WriteByte(c) |
| } else if escapeChar == "%" && c == '_' { |
| // Underscore is safe for % encoding. |
| sb.WriteByte(c) |
| } else if string(c) == escapeChar { |
| // Escape the escape character itself. |
| if escapeChar == "_" { |
| sb.WriteString("__") |
| } else { |
| sb.WriteString(fmt.Sprintf("%s%02X", escapeChar, c)) |
| } |
| } else if escapeChar == "%" && (c == '.' || c == '/') { |
| // Specific overrides for % encoding to match legacy behavior. |
| sb.WriteString(fmt.Sprintf("%%%02X", c)) |
| } else { |
| // Generic escaping for everything else. |
| sb.WriteString(fmt.Sprintf("%s%02X", escapeChar, c)) |
| } |
| } |
| return sb.String() |
| } |
| |
| // DedupeStrings returns a copy of the slice with duplicate strings removed, |
| // preserving the original order of the first occurrence. |
| func DedupeStrings(elems []string) []string { |
| seen := make(map[string]struct{}, len(elems)) |
| deduped := make([]string, 0, len(elems)) |
| for _, s := range elems { |
| if _, ok := seen[s]; !ok { |
| deduped = append(deduped, s) |
| seen[s] = struct{}{} |
| } |
| } |
| return deduped |
| } |
| |
| // DedupeInvocationAttributes dedupes slices within InvocationAttributes |
| // (like Labels and Users) in-place. |
| func DedupeInvocationAttributes(attr *rspb.InvocationAttributes) { |
| if attr == nil { |
| return |
| } |
| attr.Labels = DedupeStrings(attr.Labels) |
| attr.Users = DedupeStrings(attr.Users) |
| } |
| |
| // ComposeInvocation assembles a final authoritative Invocation resource by merging |
| // dynamic metadata from a build tool and supplementary proxy-level defaults. |
| // It ensures the ProjectId is set and that all metadata is deduped. |
| // |
| // Precedence: Tool-provided metadata (like InvocationId) is prioritized as the |
| // authoritative base, with proxy defaults merged in. |
| func ComposeInvocation(tool, partial *rspb.Invocation, projectID string) *rspb.Invocation { |
| merged := &rspb.Invocation{ |
| Id: &rspb.Invocation_Id{}, |
| } |
| |
| // 1. Start with metadata from the build tool's invocation, if provided. |
| // This establishes the "authoritative" base state from the tool. |
| if tool != nil { |
| if tool.Id != nil && tool.Id.InvocationId != "" { |
| merged.Id.InvocationId = tool.Id.InvocationId |
| } |
| proto.Merge(merged, tool) |
| } |
| |
| // 2. Then merge proxy-level partial data (defaults, user info, etc.). |
| if partial != nil { |
| proto.Merge(merged, partial) |
| } |
| |
| // 3. Ensure attributes exist and set the targeted project. |
| if merged.InvocationAttributes == nil { |
| merged.InvocationAttributes = &rspb.InvocationAttributes{} |
| } |
| if projectID != "" { |
| merged.InvocationAttributes.ProjectId = projectID |
| } |
| |
| // 4. Ensure build tool labels are preserved if they were overwritten by proto.Merge. |
| // (Note: proto.Merge on slices appends; we dedupe below to clean up). |
| if tool != nil && tool.InvocationAttributes != nil { |
| merged.InvocationAttributes.Labels = append(merged.InvocationAttributes.Labels, tool.InvocationAttributes.Labels...) |
| } |
| |
| // 5. Clean up the resulting metadata. |
| DedupeInvocationAttributes(merged.InvocationAttributes) |
| |
| // 6. Default to BUILDING status if none provided. |
| if merged.StatusAttributes == nil { |
| merged.StatusAttributes = &rspb.StatusAttributes{ |
| Status: rspb.Status_BUILDING, |
| } |
| } |
| |
| return merged |
| } |