go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/pbutil/artifact.go (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package pbutil
    16  
    17  import (
    18  	"fmt"
    19  	"net/url"
    20  	"regexp"
    21  
    22  	"go.chromium.org/luci/common/errors"
    23  )
    24  
    25  const (
    26  	// Unicode character classes L, M, N, P, S, Zs are the "graphic" type code points,
    27  	// so a good approximation of all the printable characters.
    28  	// Also accept '.' as the first character to enable upload of files starting with .
    29  	artifactIDPattern = `(?:[[:word:]]|\.)([\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]{0,254}[[:word:]])?`
    30  )
    31  
    32  var (
    33  	artifactIDRe                  = regexpf("^%s$", artifactIDPattern)
    34  	invocationArtifactNamePattern = fmt.Sprintf("invocations/(%s)/artifacts/(.+)", invocationIDPattern)
    35  	testResultArtifactNamePattern = fmt.Sprintf("invocations/(%s)/tests/([^/]+)/results/(%s)/artifacts/(.+)", invocationIDPattern, resultIDPattern)
    36  	invocationArtifactNameRe      = regexpf("^%s$", invocationArtifactNamePattern)
    37  	testResultArtifactNameRe      = regexpf("^%s$", testResultArtifactNamePattern)
    38  	artifactNameRe                = regexpf("^%s|%s$", testResultArtifactNamePattern, invocationArtifactNamePattern)
    39  	textArtifactContentTypeRe     = regexpf("^text/*")
    40  )
    41  
    42  // ValidateArtifactID returns a non-nil error if id is invalid.
    43  func ValidateArtifactID(id string) error {
    44  	return validateWithRe(artifactIDRe, id)
    45  }
    46  
    47  // ValidateArtifactName returns a non-nil error if name is invalid.
    48  func ValidateArtifactName(name string) error {
    49  	return validateWithRe(artifactNameRe, name)
    50  }
    51  
    52  // ParseArtifactName extracts the invocation ID, unescaped test id, result ID
    53  // and artifact ID.
    54  // The testID and resultID are empty if this is an invocation-level artifact.
    55  func ParseArtifactName(name string) (invocationID, testID, resultID, artifactID string, err error) {
    56  	if name == "" {
    57  		err = unspecified()
    58  		return
    59  	}
    60  
    61  	unescape := func(escaped string, re *regexp.Regexp) (string, error) {
    62  		unescaped, err := url.PathUnescape(escaped)
    63  		if err != nil {
    64  			return "", errors.Annotate(err, "%q", escaped).Err()
    65  		}
    66  
    67  		if err := validateWithRe(re, unescaped); err != nil {
    68  			return "", errors.Annotate(err, "%q", unescaped).Err()
    69  		}
    70  
    71  		return unescaped, nil
    72  	}
    73  
    74  	unescapeTestID := func(escaped string) (string, error) {
    75  		unescaped, err := url.PathUnescape(escaped)
    76  		if err != nil {
    77  			return "", errors.Annotate(err, "%q", escaped).Err()
    78  		}
    79  
    80  		if err := ValidateTestID(unescaped); err != nil {
    81  			return "", errors.Annotate(err, "%q", unescaped).Err()
    82  		}
    83  
    84  		return unescaped, nil
    85  	}
    86  
    87  	if m := invocationArtifactNameRe.FindStringSubmatch(name); m != nil {
    88  		invocationID = m[1]
    89  		artifactID, err = unescape(m[2], artifactIDRe)
    90  		err = errors.Annotate(err, "artifact ID").Err()
    91  		return
    92  	}
    93  
    94  	if m := testResultArtifactNameRe.FindStringSubmatch(name); m != nil {
    95  		invocationID = m[1]
    96  		if testID, err = unescapeTestID(m[2]); err != nil {
    97  			err = errors.Annotate(err, "test ID").Err()
    98  			return
    99  		}
   100  		resultID = m[3]
   101  		artifactID, err = unescape(m[4], artifactIDRe)
   102  		err = errors.Annotate(err, "artifact ID").Err()
   103  		return
   104  	}
   105  
   106  	err = doesNotMatch(artifactNameRe)
   107  	return
   108  }
   109  
   110  // InvocationArtifactName synthesizes a name of an invocation-level artifact.
   111  // Does not validate IDs, use ValidateInvocationID and ValidateArtifactID.
   112  func InvocationArtifactName(invocationID, artifactID string) string {
   113  	return fmt.Sprintf("invocations/%s/artifacts/%s", invocationID, url.PathEscape(artifactID))
   114  }
   115  
   116  // TestResultArtifactName synthesizes a name of an test-result-level artifact.
   117  // Does not validate IDs, use ValidateInvocationID, ValidateTestID,
   118  // ValidateResultID and ValidateArtifactID.
   119  func TestResultArtifactName(invocationID, testID, resulID, artifactID string) string {
   120  	return fmt.Sprintf("invocations/%s/tests/%s/results/%s/artifacts/%s", invocationID, url.PathEscape(testID), resulID, url.PathEscape(artifactID))
   121  }
   122  
   123  // IsTextArtifact returns true if the content type represents a text-based artifact.
   124  // Note: As the artifact content type field is optional, it is possible that
   125  // a text artifact was uploaded to ResultDB without a content type. In such case,
   126  // this function will return false.
   127  //
   128  // We rather miss some text artifact than wrongly classify a non-text artifact as
   129  // text artifact.
   130  func IsTextArtifact(contentType string) bool {
   131  	return textArtifactContentTypeRe.MatchString(contentType)
   132  }