refnames.go RawHistoryFolder
1// SPDX-License-Identifier: MIT
2
3package action
4
5import (
6 "fmt"
7 "unicode/utf16"
8
9 "bnema.dev/ermite-actions/expr"
10)
11
12// refNames is a port of the runner's ReferenceNameBuilder
13// (Sdk/DTPipelines/Pipelines/ObjectTemplating/ReferenceNameBuilder.cs), which
14// validates the ids written in a composite action and makes the ones the file
15// leaves out. It is not package workflow's idBuilder: the runner's accepts ids
16// that start with "__", lets an id of 100 characters or more through, counts
17// UTF-16 code units, and words its error without the reference parser's
18// doubled "and".
19type refNames struct {
20 name []uint16
21 distinct map[string]bool // OrdinalIgnoreCase folds
22}
23
24const maxNodeNameLength = 100
25
26func newRefNames() *refNames { return &refNames{distinct: map[string]bool{}} }
27
28// add records name and reports whether it was new (HashSet.Add with
29// OrdinalIgnoreCase). A name with non-ASCII characters can get here when it has
30// 100 characters or more, and "é...é" then collides with "É...É".
31func (b *refNames) add(name string) bool {
32 k := expr.OrdinalFold(name)
33 if b.distinct[k] {
34 return false
35 }
36 b.distinct[k] = true
37 return true
38}
39
40// appendSegment is AppendSegment.
41func (b *refNames) appendSegment(value string) {
42 if value == "" {
43 return
44 }
45 units := utf16.Encode([]rune(value))
46 if len(b.name) == 0 {
47 first := units[0]
48 legal := first >= 'a' && first <= 'z' || first >= 'A' && first <= 'Z' || first == '_'
49 if !legal && (first >= '0' && first <= '9' || first == '-') {
50 b.name = append(b.name, '_')
51 }
52 } else {
53 b.name = append(b.name, '_')
54 }
55 for _, c := range units {
56 switch {
57 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-':
58 b.name = append(b.name, c)
59 default:
60 b.name = append(b.name, '_')
61 }
62 }
63}
64
65// build is Build: the name made from the segments ("job" when there are none)
66// made unique with a numeric suffix.
67func (b *refNames) build() string {
68 original := "job"
69 if len(b.name) > 0 {
70 original = string(utf16.Decode(b.name))
71 }
72 for attempt := 1; ; attempt++ {
73 suffix := ""
74 if attempt > 1 {
75 if attempt >= 1000 {
76 throwf(exInvalidOp, "Unable to create a unique name")
77 }
78 suffix = fmt.Sprintf("_%d", attempt)
79 }
80 // original holds only ASCII: appendSegment replaced everything else.
81 candidate := original[:min(len(original), maxNodeNameLength-len(suffix))] + suffix
82 if b.add(candidate) {
83 b.name = b.name[:0]
84 return candidate
85 }
86 }
87}
88
89// tryAddKnownName is TryAddKnownName: it returns the error text, or "". Note
90// the runner's condition: an invalid name is only refused when it is shorter
91// than 100 characters.
92func (b *refNames) tryAddKnownName(value string) string {
93 if !validName(value) && utf16Len(value) < maxNodeNameLength {
94 return fmt.Sprintf("The identifier '%s' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and must be less than %d characters.", value, maxNodeNameLength)
95 }
96 if !b.add(value) {
97 return fmt.Sprintf("The identifier '%s' may not be used more than once within the same scope.", value)
98 }
99 return ""
100}
101
102// validName is NameValidation.IsValid(name, allowHyphens: true).
103func validName(name string) bool {
104 for i, c := range utf16.Encode([]rune(name)) {
105 switch {
106 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_':
107 case i > 0 && (c >= '0' && c <= '9' || c == '-'):
108 default:
109 return false
110 }
111 }
112 return true
113}