blob: b53a27b31c29581b42204327ddbfb591e9e595f6 [file] [edit]
// 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
}