feat(template): runner mode for the reader (depth, error limit, YAML restrictions)
Context gains MaxDepth, ErrorLimit and RunnerYAML, which package action sets to read an action.yml the way the C# runner's loader does: the nesting limit of TemplateMemory (skipped values count), the ten errors it keeps, and the restrictions of its YAML reader (anchors, tags, 32-bit hex and octal integers, a second document, an empty file, a collection used as a key). Numbers read as strings show as .NET's G15. Error.Exception marks a .NET exception reported without a position. DefinitionInfo.WithContexts adds contexts to a definition. workflow gains HasStatusFunction and Step.EnvExpression/WithExpression, which hold env and with when the whole value is an expression (accepted in a composite action by the runner). Nothing changes without the options.
- commit
- c8bd86b39c31e117aab7ea87cf22a0d5b68cfa1c
- author
- brice · · 2026-10-13 13:38 UTC
- committer
- brice · · 2026-10-18 14:59 UTC
- parent
- 28f9d6e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +503 -17
M template/PORTING.md +13 -0
| @@ -65,6 +65,19 @@ parser omits, so the packages run unmodified. The last check, on a clean | |||
| 65 | 65 | - Mapping keys that are collections (`? [a] : b`) are reported as | |
| 66 | 66 | `Unexpected value ''`; the reference reads `undefined` there. | |
| 67 | 67 | ||
| 68 | + | ## Runner mode (package action) | |
| 69 | + | ||
| 70 | + | `Context` has four options that only package action sets, to read an | |
| 71 | + | `action.yml` as the C# runner's loader does (`action/PORTING.md` lists what is | |
| 72 | + | checked against the runner's own code): `MaxDepth` (`TemplateMemory`'s | |
| 73 | + | `maxDepth`, counting skipped values too), `ErrorLimit` (the runner keeps ten), | |
| 74 | + | `RunnerYAML` (anchors and aliases, tags other than `!!bool/!!float/!!int/!!null`, | |
| 75 | + | 32-bit limits of `0x`/`0o` integers, a second document, an empty file and a | |
| 76 | + | collection used as a key all end the read with the runner's message; a number | |
| 77 | + | read as a string is shown as .NET's `G15`, `DotNetNumberString`). Without them | |
| 78 | + | nothing changes. `Error.Exception` marks an error that is a .NET exception the | |
| 79 | + | runner reports without a position. | |
| 80 | + | ||
| 68 | 81 | ## To check against the C# runner / recordings | |
| 69 | 82 | ||
| 70 | 83 | - [ ] A string with `${{` inside a non-expression place (`name:` of a | |
| @@ -65,6 +65,19 @@ parser omits, so the packages run unmodified. The last check, on a clean | |||||
| 65 | - Mapping keys that are collections (`? [a] : b`) are reported as | 65 | - Mapping keys that are collections (`? [a] : b`) are reported as | ||
| 66 | `Unexpected value ''`; the reference reads `undefined` there. | 66 | `Unexpected value ''`; the reference reads `undefined` there. | ||
| 67 | 67 | ||||
| 68 | + | ## Runner mode (package action) | |||
| 69 | + | ||||
| 70 | + | `Context` has four options that only package action sets, to read an | |||
| 71 | + | `action.yml` as the C# runner's loader does (`action/PORTING.md` lists what is | |||
| 72 | + | checked against the runner's own code): `MaxDepth` (`TemplateMemory`'s | |||
| 73 | + | `maxDepth`, counting skipped values too), `ErrorLimit` (the runner keeps ten), | |||
| 74 | + | `RunnerYAML` (anchors and aliases, tags other than `!!bool/!!float/!!int/!!null`, | |||
| 75 | + | 32-bit limits of `0x`/`0o` integers, a second document, an empty file and a | |||
| 76 | + | collection used as a key all end the read with the runner's message; a number | |||
| 77 | + | read as a string is shown as .NET's `G15`, `DotNetNumberString`). Without them | |||
| 78 | + | nothing changes. `Error.Exception` marks an error that is a .NET exception the | |||
| 79 | + | runner reports without a position. | |||
| 80 | + | ||||
| 68 | ## To check against the C# runner / recordings | 81 | ## To check against the C# runner / recordings | ||
| 69 | 82 | ||||
| 70 | - [ ] A string with `${{` inside a non-expression place (`name:` of a | 83 | - [ ] A string with `${{` inside a non-expression place (`name:` of a | ||
M template/context.go +46 -2
| @@ -21,6 +21,14 @@ type Error struct { | |||
| 21 | 21 | // Raw is the message without the "file (Line: l, Col: c): " prefix. | |
| 22 | 22 | Raw string | |
| 23 | 23 | ||
| 24 | + | // Exception is the name of the .NET exception class when the error is an | |
| 25 | + | // exception that the C# runner reports without a position, such as | |
| 26 | + | // "System.ArgumentException"; Raw is then the message the exception was | |
| 27 | + | // built with. The runner prints Exception.ToString(), which starts with the | |
| 28 | + | // class and the message formatted by .NET (see [Error.Error]) and goes on | |
| 29 | + | // with a stack trace that is not reproduced. | |
| 30 | + | Exception string | |
| 31 | + | ||
| 24 | 32 | // fileOnly makes the prefix the file name alone, for errors of the YAML | |
| 25 | 33 | // parser, which the reference reports without a position. | |
| 26 | 34 | fileOnly bool | |
| @@ -34,6 +42,15 @@ func (e *Error) Unwrap() error { return e.cause } | |||
| 34 | 42 | // Error returns the message with its prefix, as the reference does: | |
| 35 | 43 | // "file.yml (Line: 3, Col: 5): message". | |
| 36 | 44 | func (e *Error) Error() string { | |
| 45 | + | if e.Exception != "" { | |
| 46 | + | switch e.Exception { | |
| 47 | + | case "System.ArgumentNullException": | |
| 48 | + | return e.Exception + ": Value cannot be null. (Parameter '" + e.Raw + "')" | |
| 49 | + | case "System.ArgumentOutOfRangeException": | |
| 50 | + | return e.Exception + ": Specified argument was out of the range of valid values. (Parameter '" + e.Raw + "')" | |
| 51 | + | } | |
| 52 | + | return e.Exception + ": " + e.Raw | |
| 53 | + | } | |
| 37 | 54 | pos := e.Pos | |
| 38 | 55 | if e.fileOnly { | |
| 39 | 56 | pos = Pos{} | |
| @@ -64,9 +81,25 @@ func (m message) Error() string { return string(m) } | |||
| 64 | 81 | ||
| 65 | 82 | // Context collects the errors of a read and carries its schema. | |
| 66 | 83 | type Context struct { | |
| 84 | + | // MaxDepth bounds the nesting of sequences and mappings that [ReadYAML] | |
| 85 | + | // accepts. A deeper document ends the read with "Maximum object depth | |
| 86 | + | // exceeded", as the runner's TemplateMemory does. Zero means no limit. | |
| 87 | + | MaxDepth int | |
| 88 | + | // ErrorLimit is the number of errors kept; later ones are dropped. Zero | |
| 89 | + | // means [MaxErrors]. The runner keeps ten when it loads an action.yml. | |
| 90 | + | ErrorLimit int | |
| 91 | + | // RunnerYAML makes the YAML reader as strict as the C# runner's when it | |
| 92 | + | // loads an action.yml: an anchor is an error ("Anchors are not currently | |
| 93 | + | // supported. Remove the anchor 'x'", and so is the alias that needs it), | |
| 94 | + | // and so is every explicit tag but !!bool, !!float, !!int and !!null | |
| 95 | + | // ("Unexpected tag 'tag:yaml.org,2002:str'": the runner knows no !!str). | |
| 96 | + | // Such an error is reported alone, without the schema errors that the | |
| 97 | + | // runner would have found in the part of the document before it. | |
| 98 | + | RunnerYAML bool | |
| 99 | + | ||
| 67 | 100 | schema *Schema | |
| 68 | 101 | errors []*Error | |
| 69 | - | // Dropped counts the errors beyond [MaxErrors]. | |
| 102 | + | // Dropped counts the errors beyond the limit. | |
| 70 | 103 | dropped int | |
| 71 | 104 | } | |
| 72 | 105 | ||
| @@ -96,6 +129,13 @@ func (c *Context) ErrorAt(tok Token, msg string) { | |||
| 96 | 129 | c.add(e) | |
| 97 | 130 | } | |
| 98 | 131 | ||
| 132 | + | // ErrorException records an exception the C# runner reports without a | |
| 133 | + | // position: class is the .NET class name ("System.ArgumentException") and msg | |
| 134 | + | // the message the exception was built with. | |
| 135 | + | func (c *Context) ErrorException(class, msg string) { | |
| 136 | + | c.add(&Error{Raw: msg, Exception: class}) | |
| 137 | + | } | |
| 138 | + | ||
| 99 | 139 | // ErrorInFile records an error that is about a whole file. | |
| 100 | 140 | func (c *Context) ErrorInFile(file, msg string) { c.add(&Error{File: file, Raw: msg}) } | |
| 101 | 141 | ||
| @@ -105,7 +145,11 @@ func (c *Context) ErrorAtPos(file string, p Pos, msg string) { | |||
| 105 | 145 | } | |
| 106 | 146 | ||
| 107 | 147 | func (c *Context) add(e *Error) { | |
| 108 | - | if len(c.errors) >= MaxErrors { | |
| 148 | + | limit := c.ErrorLimit | |
| 149 | + | if limit <= 0 { | |
| 150 | + | limit = MaxErrors | |
| 151 | + | } | |
| 152 | + | if len(c.errors) >= limit { | |
| 109 | 153 | c.dropped++ | |
| 110 | 154 | return | |
| 111 | 155 | } | |
| @@ -21,6 +21,14 @@ type Error struct { | |||||
| 21 | // Raw is the message without the "file (Line: l, Col: c): " prefix. | 21 | // Raw is the message without the "file (Line: l, Col: c): " prefix. | ||
| 22 | Raw string | 22 | Raw string | ||
| 23 | 23 | ||||
| 24 | + | // Exception is the name of the .NET exception class when the error is an | |||
| 25 | + | // exception that the C# runner reports without a position, such as | |||
| 26 | + | // "System.ArgumentException"; Raw is then the message the exception was | |||
| 27 | + | // built with. The runner prints Exception.ToString(), which starts with the | |||
| 28 | + | // class and the message formatted by .NET (see [Error.Error]) and goes on | |||
| 29 | + | // with a stack trace that is not reproduced. | |||
| 30 | + | Exception string | |||
| 31 | + | ||||
| 24 | // fileOnly makes the prefix the file name alone, for errors of the YAML | 32 | // fileOnly makes the prefix the file name alone, for errors of the YAML | ||
| 25 | // parser, which the reference reports without a position. | 33 | // parser, which the reference reports without a position. | ||
| 26 | fileOnly bool | 34 | fileOnly bool | ||
| @@ -34,6 +42,15 @@ func (e *Error) Unwrap() error { return e.cause } | |||||
| 34 | // Error returns the message with its prefix, as the reference does: | 42 | // Error returns the message with its prefix, as the reference does: | ||
| 35 | // "file.yml (Line: 3, Col: 5): message". | 43 | // "file.yml (Line: 3, Col: 5): message". | ||
| 36 | func (e *Error) Error() string { | 44 | func (e *Error) Error() string { | ||
| 45 | + | if e.Exception != "" { | |||
| 46 | + | switch e.Exception { | |||
| 47 | + | case "System.ArgumentNullException": | |||
| 48 | + | return e.Exception + ": Value cannot be null. (Parameter '" + e.Raw + "')" | |||
| 49 | + | case "System.ArgumentOutOfRangeException": | |||
| 50 | + | return e.Exception + ": Specified argument was out of the range of valid values. (Parameter '" + e.Raw + "')" | |||
| 51 | + | } | |||
| 52 | + | return e.Exception + ": " + e.Raw | |||
| 53 | + | } | |||
| 37 | pos := e.Pos | 54 | pos := e.Pos | ||
| 38 | if e.fileOnly { | 55 | if e.fileOnly { | ||
| 39 | pos = Pos{} | 56 | pos = Pos{} | ||
| @@ -64,9 +81,25 @@ func (m message) Error() string { return string(m) } | |||||
| 64 | 81 | ||||
| 65 | // Context collects the errors of a read and carries its schema. | 82 | // Context collects the errors of a read and carries its schema. | ||
| 66 | type Context struct { | 83 | type Context struct { | ||
| 84 | + | // MaxDepth bounds the nesting of sequences and mappings that [ReadYAML] | |||
| 85 | + | // accepts. A deeper document ends the read with "Maximum object depth | |||
| 86 | + | // exceeded", as the runner's TemplateMemory does. Zero means no limit. | |||
| 87 | + | MaxDepth int | |||
| 88 | + | // ErrorLimit is the number of errors kept; later ones are dropped. Zero | |||
| 89 | + | // means [MaxErrors]. The runner keeps ten when it loads an action.yml. | |||
| 90 | + | ErrorLimit int | |||
| 91 | + | // RunnerYAML makes the YAML reader as strict as the C# runner's when it | |||
| 92 | + | // loads an action.yml: an anchor is an error ("Anchors are not currently | |||
| 93 | + | // supported. Remove the anchor 'x'", and so is the alias that needs it), | |||
| 94 | + | // and so is every explicit tag but !!bool, !!float, !!int and !!null | |||
| 95 | + | // ("Unexpected tag 'tag:yaml.org,2002:str'": the runner knows no !!str). | |||
| 96 | + | // Such an error is reported alone, without the schema errors that the | |||
| 97 | + | // runner would have found in the part of the document before it. | |||
| 98 | + | RunnerYAML bool | |||
| 99 | + | ||||
| 67 | schema *Schema | 100 | schema *Schema | ||
| 68 | errors []*Error | 101 | errors []*Error | ||
| 69 | - | // Dropped counts the errors beyond [MaxErrors]. | 102 | + | // Dropped counts the errors beyond the limit. |
| 70 | dropped int | 103 | dropped int | ||
| 71 | } | 104 | } | ||
| 72 | 105 | ||||
| @@ -96,6 +129,13 @@ func (c *Context) ErrorAt(tok Token, msg string) { | |||||
| 96 | c.add(e) | 129 | c.add(e) | ||
| 97 | } | 130 | } | ||
| 98 | 131 | ||||
| 132 | + | // ErrorException records an exception the C# runner reports without a | |||
| 133 | + | // position: class is the .NET class name ("System.ArgumentException") and msg | |||
| 134 | + | // the message the exception was built with. | |||
| 135 | + | func (c *Context) ErrorException(class, msg string) { | |||
| 136 | + | c.add(&Error{Raw: msg, Exception: class}) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 99 | // ErrorInFile records an error that is about a whole file. | 139 | // ErrorInFile records an error that is about a whole file. | ||
| 100 | func (c *Context) ErrorInFile(file, msg string) { c.add(&Error{File: file, Raw: msg}) } | 140 | func (c *Context) ErrorInFile(file, msg string) { c.add(&Error{File: file, Raw: msg}) } | ||
| 101 | 141 | ||||
| @@ -105,7 +145,11 @@ func (c *Context) ErrorAtPos(file string, p Pos, msg string) { | |||||
| 105 | } | 145 | } | ||
| 106 | 146 | ||||
| 107 | func (c *Context) add(e *Error) { | 147 | func (c *Context) add(e *Error) { | ||
| 108 | - | if len(c.errors) >= MaxErrors { | 148 | + | limit := c.ErrorLimit |
| 149 | + | if limit <= 0 { | |||
| 150 | + | limit = MaxErrors | |||
| 151 | + | } | |||
| 152 | + | if len(c.errors) >= limit { | |||
| 109 | c.dropped++ | 153 | c.dropped++ | ||
| 110 | return | 154 | return | ||
| 111 | } | 155 | } | ||
M template/definfo.go +10 -0
| @@ -51,6 +51,16 @@ func (di *DefinitionInfo) child(name string) *DefinitionInfo { | |||
| 51 | 51 | return newDefinitionInfo(di.schema, di, di.schema.get(name)) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | // WithContexts returns a copy of di that also allows the named contexts and | |
| 55 | + | // functions ("vars", "hashFiles(1,255)"). The runner checks the "if" of a | |
| 56 | + | // composite step against a longer list than the manifest schema declares. | |
| 57 | + | func (di *DefinitionInfo) WithContexts(entries ...string) *DefinitionInfo { | |
| 58 | + | cp := *di | |
| 59 | + | cp.allowed = append(append([]string(nil), di.allowed...), entries...) | |
| 60 | + | cp.named, cp.funcs = splitAllowedContext(cp.allowed) | |
| 61 | + | return &cp | |
| 62 | + | } | |
| 63 | + | ||
| 54 | 64 | // AllowedContext returns the raw entries: context names and function | |
| 55 | 65 | // signatures such as "hashFiles(1,255)". | |
| 56 | 66 | func (di *DefinitionInfo) AllowedContext() []string { return di.allowed } | |
| @@ -51,6 +51,16 @@ func (di *DefinitionInfo) child(name string) *DefinitionInfo { | |||||
| 51 | return newDefinitionInfo(di.schema, di, di.schema.get(name)) | 51 | return newDefinitionInfo(di.schema, di, di.schema.get(name)) | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | + | // WithContexts returns a copy of di that also allows the named contexts and | |||
| 55 | + | // functions ("vars", "hashFiles(1,255)"). The runner checks the "if" of a | |||
| 56 | + | // composite step against a longer list than the manifest schema declares. | |||
| 57 | + | func (di *DefinitionInfo) WithContexts(entries ...string) *DefinitionInfo { | |||
| 58 | + | cp := *di | |||
| 59 | + | cp.allowed = append(append([]string(nil), di.allowed...), entries...) | |||
| 60 | + | cp.named, cp.funcs = splitAllowedContext(cp.allowed) | |||
| 61 | + | return &cp | |||
| 62 | + | } | |||
| 63 | + | ||||
| 54 | // AllowedContext returns the raw entries: context names and function | 64 | // AllowedContext returns the raw entries: context names and function | ||
| 55 | // signatures such as "hashFiles(1,255)". | 65 | // signatures such as "hashFiles(1,255)". | ||
| 56 | func (di *DefinitionInfo) AllowedContext() []string { return di.allowed } | 66 | func (di *DefinitionInfo) AllowedContext() []string { return di.allowed } | ||
A template/limits_test.go +143 -0
| @@ -0,0 +1,143 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "math" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // The options of Context that the C# runner's loader needs (package action): | |
| 12 | + | // nesting limit, error limit and the runner's YAML restrictions. | |
| 13 | + | ||
| 14 | + | func readWith(t *testing.T, set func(*Context), src string) []string { | |
| 15 | + | t.Helper() | |
| 16 | + | schema, err := WorkflowSchema() | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | ctx := NewContext(schema) | |
| 21 | + | set(ctx) | |
| 22 | + | ReadYAML(ctx, "workflow-root-strict", "f.yml", []byte(src)) | |
| 23 | + | return ctx.Messages() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestMaxDepth(t *testing.T) { | |
| 27 | + | nest := func(n int) string { return "x: " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\n" } | |
| 28 | + | set := func(c *Context) { c.MaxDepth = 10 } | |
| 29 | + | // Like TemplateMemory.IncrementDepth: the root mapping is level one, so | |
| 30 | + | // nine brackets fit and ten do not. The unexpected key "x" is skipped, and | |
| 31 | + | // skipping counts levels too. | |
| 32 | + | got := readWith(t, set, nest(9)) | |
| 33 | + | for _, m := range got { | |
| 34 | + | if strings.Contains(m, "depth") { | |
| 35 | + | t.Errorf("9 brackets: %q", got) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | got = readWith(t, set, nest(10)) | |
| 39 | + | if len(got) == 0 || got[len(got)-1] != "f.yml: Maximum object depth exceeded" { | |
| 40 | + | t.Errorf("10 brackets: %q", got) | |
| 41 | + | } | |
| 42 | + | // No limit by default. | |
| 43 | + | for _, m := range readWith(t, func(*Context) {}, nest(500)) { | |
| 44 | + | if strings.Contains(m, "depth") { | |
| 45 | + | t.Errorf("default limit: %q", m) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestErrorLimit(t *testing.T) { | |
| 51 | + | var b strings.Builder | |
| 52 | + | for i := 0; i < 30; i++ { | |
| 53 | + | b.WriteString("k" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ": 1\n") | |
| 54 | + | } | |
| 55 | + | if got := readWith(t, func(c *Context) { c.ErrorLimit = 10 }, b.String()); len(got) != 10 { | |
| 56 | + | t.Errorf("%d errors with a limit of 10", len(got)) | |
| 57 | + | } | |
| 58 | + | if got := readWith(t, func(*Context) {}, b.String()); len(got) < 30 { | |
| 59 | + | t.Errorf("%d errors without a limit", len(got)) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestRunnerYAML(t *testing.T) { | |
| 64 | + | runner := func(c *Context) { c.RunnerYAML = true } | |
| 65 | + | tests := []struct { | |
| 66 | + | name, src, want string | |
| 67 | + | }{ | |
| 68 | + | {"anchor", "on: &a push\n", "f.yml: Anchors are not currently supported. Remove the anchor 'a'"}, | |
| 69 | + | {"alias", "a: &a [1]\nb: *a\n", "f.yml: Anchors are not currently supported. Remove the anchor 'a'"}, | |
| 70 | + | {"!!str is unknown to the runner", "on: !!str push\n", "f.yml: Unexpected tag 'tag:yaml.org,2002:str'"}, | |
| 71 | + | {"!!int on a string", "on: !!int push\n", "f.yml: The value 'push' on line 1 and column 5 is invalid for the type 'tag:yaml.org,2002:int'"}, | |
| 72 | + | {"tag on a quoted scalar", "on: !!int '1'\n", "f.yml: The scalar style 'SingleQuoted' on line 1 and column 5 is not valid with the tag 'tag:yaml.org,2002:int'"}, | |
| 73 | + | {"second document", "on: push\n---\non: pull_request\n", "f.yml: Expected stream end parse event"}, | |
| 74 | + | {"empty", "", "f.yml: Expected document start parse event"}, | |
| 75 | + | {"collection key", "? [a]\n: b\n", "f.yml: Expected mapping end"}, | |
| 76 | + | } | |
| 77 | + | for _, tc := range tests { | |
| 78 | + | got := readWith(t, runner, tc.src) | |
| 79 | + | found := false | |
| 80 | + | for _, m := range got { | |
| 81 | + | found = found || m == tc.want | |
| 82 | + | } | |
| 83 | + | if !found { | |
| 84 | + | t.Errorf("%s: %q, want %q", tc.name, got, tc.want) | |
| 85 | + | } | |
| 86 | + | // None of this is an error without the option (except an empty file | |
| 87 | + | // and a collection key, which have other messages). | |
| 88 | + | } | |
| 89 | + | for _, src := range []string{"on: &a push\n", "on: !!str push\n", "on: !!int 1\n"} { | |
| 90 | + | for _, m := range readWith(t, func(*Context) {}, src) { | |
| 91 | + | if strings.Contains(m, "Anchors") || strings.Contains(m, "Unexpected tag") { | |
| 92 | + | t.Errorf("%q without RunnerYAML: %q", src, m) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | // A valid tag passes. | |
| 97 | + | for _, src := range []string{"on: !!null\n", "on: !!int 12\n", "on: !!float 1.5\n", "on: !!bool true\n", "on: !!int 0x1F\n"} { | |
| 98 | + | for _, m := range readWith(t, runner, src) { | |
| 99 | + | if strings.Contains(m, "tag") || strings.Contains(m, "invalid for the type") { | |
| 100 | + | t.Errorf("%q: %q", src, m) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestExceptionError(t *testing.T) { | |
| 107 | + | schema, _ := WorkflowSchema() | |
| 108 | + | ctx := NewContext(schema) | |
| 109 | + | ctx.ErrorException("System.ArgumentNullException", "msg") | |
| 110 | + | ctx.ErrorException("System.ArgumentOutOfRangeException", "msg") | |
| 111 | + | ctx.ErrorException("System.NotSupportedException", "msg") | |
| 112 | + | want := []string{ | |
| 113 | + | "System.ArgumentNullException: Value cannot be null. (Parameter 'msg')", | |
| 114 | + | "System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'msg')", | |
| 115 | + | "System.NotSupportedException: msg", | |
| 116 | + | } | |
| 117 | + | got := ctx.Messages() | |
| 118 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |
| 119 | + | t.Errorf("got %q", got) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestDotNetNumberString(t *testing.T) { | |
| 124 | + | for _, tc := range []struct { | |
| 125 | + | in float64 | |
| 126 | + | want string | |
| 127 | + | }{ | |
| 128 | + | {0, "0"}, {1, "1"}, {1.5, "1.5"}, {-5, "-5"}, {0.1, "0.1"}, {0.30000000000000004, "0.3"}, | |
| 129 | + | {100000, "100000"}, {1e14, "100000000000000"}, {1e15, "1E+15"}, {123456789012345678, "1.23456789012346E+17"}, | |
| 130 | + | {1e20, "1E+20"}, {1e21, "1E+21"}, {0.0001, "0.0001"}, {0.00001, "1E-05"}, {1e-6, "1E-06"}, {1.5e-7, "1.5E-07"}, | |
| 131 | + | {12345.6789, "12345.6789"}, {999999999999999, "999999999999999"}, {9999999999999999, "1E+16"}, | |
| 132 | + | } { | |
| 133 | + | if got := DotNetNumberString(tc.in); got != tc.want { | |
| 134 | + | t.Errorf("DotNetNumberString(%v) = %q, want %q", tc.in, got, tc.want) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if got := DotNetNumberString(math.Copysign(0, -1)); got != "-0" { | |
| 138 | + | t.Errorf("negative zero: %q", got) | |
| 139 | + | } | |
| 140 | + | if DotNetNumberString(math.Inf(1)) != "Infinity" || DotNetNumberString(math.Inf(-1)) != "-Infinity" || DotNetNumberString(math.NaN()) != "NaN" { | |
| 141 | + | t.Error("non-finite numbers") | |
| 142 | + | } | |
| 143 | + | } | |
| @@ -0,0 +1,143 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "math" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // The options of Context that the C# runner's loader needs (package action): | |||
| 12 | + | // nesting limit, error limit and the runner's YAML restrictions. | |||
| 13 | + | ||||
| 14 | + | func readWith(t *testing.T, set func(*Context), src string) []string { | |||
| 15 | + | t.Helper() | |||
| 16 | + | schema, err := WorkflowSchema() | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | ctx := NewContext(schema) | |||
| 21 | + | set(ctx) | |||
| 22 | + | ReadYAML(ctx, "workflow-root-strict", "f.yml", []byte(src)) | |||
| 23 | + | return ctx.Messages() | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestMaxDepth(t *testing.T) { | |||
| 27 | + | nest := func(n int) string { return "x: " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\n" } | |||
| 28 | + | set := func(c *Context) { c.MaxDepth = 10 } | |||
| 29 | + | // Like TemplateMemory.IncrementDepth: the root mapping is level one, so | |||
| 30 | + | // nine brackets fit and ten do not. The unexpected key "x" is skipped, and | |||
| 31 | + | // skipping counts levels too. | |||
| 32 | + | got := readWith(t, set, nest(9)) | |||
| 33 | + | for _, m := range got { | |||
| 34 | + | if strings.Contains(m, "depth") { | |||
| 35 | + | t.Errorf("9 brackets: %q", got) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | got = readWith(t, set, nest(10)) | |||
| 39 | + | if len(got) == 0 || got[len(got)-1] != "f.yml: Maximum object depth exceeded" { | |||
| 40 | + | t.Errorf("10 brackets: %q", got) | |||
| 41 | + | } | |||
| 42 | + | // No limit by default. | |||
| 43 | + | for _, m := range readWith(t, func(*Context) {}, nest(500)) { | |||
| 44 | + | if strings.Contains(m, "depth") { | |||
| 45 | + | t.Errorf("default limit: %q", m) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestErrorLimit(t *testing.T) { | |||
| 51 | + | var b strings.Builder | |||
| 52 | + | for i := 0; i < 30; i++ { | |||
| 53 | + | b.WriteString("k" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ": 1\n") | |||
| 54 | + | } | |||
| 55 | + | if got := readWith(t, func(c *Context) { c.ErrorLimit = 10 }, b.String()); len(got) != 10 { | |||
| 56 | + | t.Errorf("%d errors with a limit of 10", len(got)) | |||
| 57 | + | } | |||
| 58 | + | if got := readWith(t, func(*Context) {}, b.String()); len(got) < 30 { | |||
| 59 | + | t.Errorf("%d errors without a limit", len(got)) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestRunnerYAML(t *testing.T) { | |||
| 64 | + | runner := func(c *Context) { c.RunnerYAML = true } | |||
| 65 | + | tests := []struct { | |||
| 66 | + | name, src, want string | |||
| 67 | + | }{ | |||
| 68 | + | {"anchor", "on: &a push\n", "f.yml: Anchors are not currently supported. Remove the anchor 'a'"}, | |||
| 69 | + | {"alias", "a: &a [1]\nb: *a\n", "f.yml: Anchors are not currently supported. Remove the anchor 'a'"}, | |||
| 70 | + | {"!!str is unknown to the runner", "on: !!str push\n", "f.yml: Unexpected tag 'tag:yaml.org,2002:str'"}, | |||
| 71 | + | {"!!int on a string", "on: !!int push\n", "f.yml: The value 'push' on line 1 and column 5 is invalid for the type 'tag:yaml.org,2002:int'"}, | |||
| 72 | + | {"tag on a quoted scalar", "on: !!int '1'\n", "f.yml: The scalar style 'SingleQuoted' on line 1 and column 5 is not valid with the tag 'tag:yaml.org,2002:int'"}, | |||
| 73 | + | {"second document", "on: push\n---\non: pull_request\n", "f.yml: Expected stream end parse event"}, | |||
| 74 | + | {"empty", "", "f.yml: Expected document start parse event"}, | |||
| 75 | + | {"collection key", "? [a]\n: b\n", "f.yml: Expected mapping end"}, | |||
| 76 | + | } | |||
| 77 | + | for _, tc := range tests { | |||
| 78 | + | got := readWith(t, runner, tc.src) | |||
| 79 | + | found := false | |||
| 80 | + | for _, m := range got { | |||
| 81 | + | found = found || m == tc.want | |||
| 82 | + | } | |||
| 83 | + | if !found { | |||
| 84 | + | t.Errorf("%s: %q, want %q", tc.name, got, tc.want) | |||
| 85 | + | } | |||
| 86 | + | // None of this is an error without the option (except an empty file | |||
| 87 | + | // and a collection key, which have other messages). | |||
| 88 | + | } | |||
| 89 | + | for _, src := range []string{"on: &a push\n", "on: !!str push\n", "on: !!int 1\n"} { | |||
| 90 | + | for _, m := range readWith(t, func(*Context) {}, src) { | |||
| 91 | + | if strings.Contains(m, "Anchors") || strings.Contains(m, "Unexpected tag") { | |||
| 92 | + | t.Errorf("%q without RunnerYAML: %q", src, m) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | // A valid tag passes. | |||
| 97 | + | for _, src := range []string{"on: !!null\n", "on: !!int 12\n", "on: !!float 1.5\n", "on: !!bool true\n", "on: !!int 0x1F\n"} { | |||
| 98 | + | for _, m := range readWith(t, runner, src) { | |||
| 99 | + | if strings.Contains(m, "tag") || strings.Contains(m, "invalid for the type") { | |||
| 100 | + | t.Errorf("%q: %q", src, m) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestExceptionError(t *testing.T) { | |||
| 107 | + | schema, _ := WorkflowSchema() | |||
| 108 | + | ctx := NewContext(schema) | |||
| 109 | + | ctx.ErrorException("System.ArgumentNullException", "msg") | |||
| 110 | + | ctx.ErrorException("System.ArgumentOutOfRangeException", "msg") | |||
| 111 | + | ctx.ErrorException("System.NotSupportedException", "msg") | |||
| 112 | + | want := []string{ | |||
| 113 | + | "System.ArgumentNullException: Value cannot be null. (Parameter 'msg')", | |||
| 114 | + | "System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'msg')", | |||
| 115 | + | "System.NotSupportedException: msg", | |||
| 116 | + | } | |||
| 117 | + | got := ctx.Messages() | |||
| 118 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |||
| 119 | + | t.Errorf("got %q", got) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestDotNetNumberString(t *testing.T) { | |||
| 124 | + | for _, tc := range []struct { | |||
| 125 | + | in float64 | |||
| 126 | + | want string | |||
| 127 | + | }{ | |||
| 128 | + | {0, "0"}, {1, "1"}, {1.5, "1.5"}, {-5, "-5"}, {0.1, "0.1"}, {0.30000000000000004, "0.3"}, | |||
| 129 | + | {100000, "100000"}, {1e14, "100000000000000"}, {1e15, "1E+15"}, {123456789012345678, "1.23456789012346E+17"}, | |||
| 130 | + | {1e20, "1E+20"}, {1e21, "1E+21"}, {0.0001, "0.0001"}, {0.00001, "1E-05"}, {1e-6, "1E-06"}, {1.5e-7, "1.5E-07"}, | |||
| 131 | + | {12345.6789, "12345.6789"}, {999999999999999, "999999999999999"}, {9999999999999999, "1E+16"}, | |||
| 132 | + | } { | |||
| 133 | + | if got := DotNetNumberString(tc.in); got != tc.want { | |||
| 134 | + | t.Errorf("DotNetNumberString(%v) = %q, want %q", tc.in, got, tc.want) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | if got := DotNetNumberString(math.Copysign(0, -1)); got != "-0" { | |||
| 138 | + | t.Errorf("negative zero: %q", got) | |||
| 139 | + | } | |||
| 140 | + | if DotNetNumberString(math.Inf(1)) != "Infinity" || DotNetNumberString(math.Inf(-1)) != "-Infinity" || DotNetNumberString(math.NaN()) != "NaN" { | |||
| 141 | + | t.Error("non-finite numbers") | |||
| 142 | + | } | |||
| 143 | + | } | |||
M template/number.go +57 -0
| @@ -53,3 +53,60 @@ func JSNumberString(x float64) string { | |||
| 53 | 53 | } | |
| 54 | 54 | return sign + digits[:1] + "." + digits[1:] + "e" + esign + strconv.Itoa(e) | |
| 55 | 55 | } | |
| 56 | + | ||
| 57 | + | // DotNetNumberString formats x like .NET's Double.ToString("G15", | |
| 58 | + | // CultureInfo.InvariantCulture), which is what the C# runner's NumberToken | |
| 59 | + | // shows (and what a number becomes when it is read as a string): at most 15 | |
| 60 | + | // significant digits, positional when the decimal exponent is at least -4 and | |
| 61 | + | // below 15, "E+XX"/"E-XX" with at least two exponent digits otherwise, "-0" for | |
| 62 | + | // negative zero. | |
| 63 | + | func DotNetNumberString(x float64) string { | |
| 64 | + | switch { | |
| 65 | + | case math.IsNaN(x): | |
| 66 | + | return "NaN" | |
| 67 | + | case math.IsInf(x, 1): | |
| 68 | + | return "Infinity" | |
| 69 | + | case math.IsInf(x, -1): | |
| 70 | + | return "-Infinity" | |
| 71 | + | case x == 0: | |
| 72 | + | if math.Signbit(x) { | |
| 73 | + | return "-0" | |
| 74 | + | } | |
| 75 | + | return "0" | |
| 76 | + | } | |
| 77 | + | sign := "" | |
| 78 | + | if x < 0 { | |
| 79 | + | sign = "-" | |
| 80 | + | x = -x | |
| 81 | + | } | |
| 82 | + | s := strconv.FormatFloat(x, 'e', 14, 64) | |
| 83 | + | mant, expStr, _ := strings.Cut(s, "e") | |
| 84 | + | exp, _ := strconv.Atoi(expStr) | |
| 85 | + | digits := strings.TrimRight(strings.Replace(mant, ".", "", 1), "0") | |
| 86 | + | if digits == "" { | |
| 87 | + | digits = "0" | |
| 88 | + | } | |
| 89 | + | if exp >= 15 || exp < -4 { | |
| 90 | + | out := digits[:1] | |
| 91 | + | if len(digits) > 1 { | |
| 92 | + | out += "." + digits[1:] | |
| 93 | + | } | |
| 94 | + | esign := "+" | |
| 95 | + | if exp < 0 { | |
| 96 | + | esign = "-" | |
| 97 | + | exp = -exp | |
| 98 | + | } | |
| 99 | + | e := strconv.Itoa(exp) | |
| 100 | + | if len(e) < 2 { | |
| 101 | + | e = "0" + e | |
| 102 | + | } | |
| 103 | + | return sign + out + "E" + esign + e | |
| 104 | + | } | |
| 105 | + | if exp < 0 { | |
| 106 | + | return sign + "0." + strings.Repeat("0", -exp-1) + digits | |
| 107 | + | } | |
| 108 | + | if len(digits) <= exp+1 { | |
| 109 | + | return sign + digits + strings.Repeat("0", exp+1-len(digits)) | |
| 110 | + | } | |
| 111 | + | return sign + digits[:exp+1] + "." + digits[exp+1:] | |
| 112 | + | } | |
| @@ -53,3 +53,60 @@ func JSNumberString(x float64) string { | |||||
| 53 | } | 53 | } | ||
| 54 | return sign + digits[:1] + "." + digits[1:] + "e" + esign + strconv.Itoa(e) | 54 | return sign + digits[:1] + "." + digits[1:] + "e" + esign + strconv.Itoa(e) | ||
| 55 | } | 55 | } | ||
| 56 | + | ||||
| 57 | + | // DotNetNumberString formats x like .NET's Double.ToString("G15", | |||
| 58 | + | // CultureInfo.InvariantCulture), which is what the C# runner's NumberToken | |||
| 59 | + | // shows (and what a number becomes when it is read as a string): at most 15 | |||
| 60 | + | // significant digits, positional when the decimal exponent is at least -4 and | |||
| 61 | + | // below 15, "E+XX"/"E-XX" with at least two exponent digits otherwise, "-0" for | |||
| 62 | + | // negative zero. | |||
| 63 | + | func DotNetNumberString(x float64) string { | |||
| 64 | + | switch { | |||
| 65 | + | case math.IsNaN(x): | |||
| 66 | + | return "NaN" | |||
| 67 | + | case math.IsInf(x, 1): | |||
| 68 | + | return "Infinity" | |||
| 69 | + | case math.IsInf(x, -1): | |||
| 70 | + | return "-Infinity" | |||
| 71 | + | case x == 0: | |||
| 72 | + | if math.Signbit(x) { | |||
| 73 | + | return "-0" | |||
| 74 | + | } | |||
| 75 | + | return "0" | |||
| 76 | + | } | |||
| 77 | + | sign := "" | |||
| 78 | + | if x < 0 { | |||
| 79 | + | sign = "-" | |||
| 80 | + | x = -x | |||
| 81 | + | } | |||
| 82 | + | s := strconv.FormatFloat(x, 'e', 14, 64) | |||
| 83 | + | mant, expStr, _ := strings.Cut(s, "e") | |||
| 84 | + | exp, _ := strconv.Atoi(expStr) | |||
| 85 | + | digits := strings.TrimRight(strings.Replace(mant, ".", "", 1), "0") | |||
| 86 | + | if digits == "" { | |||
| 87 | + | digits = "0" | |||
| 88 | + | } | |||
| 89 | + | if exp >= 15 || exp < -4 { | |||
| 90 | + | out := digits[:1] | |||
| 91 | + | if len(digits) > 1 { | |||
| 92 | + | out += "." + digits[1:] | |||
| 93 | + | } | |||
| 94 | + | esign := "+" | |||
| 95 | + | if exp < 0 { | |||
| 96 | + | esign = "-" | |||
| 97 | + | exp = -exp | |||
| 98 | + | } | |||
| 99 | + | e := strconv.Itoa(exp) | |||
| 100 | + | if len(e) < 2 { | |||
| 101 | + | e = "0" + e | |||
| 102 | + | } | |||
| 103 | + | return sign + out + "E" + esign + e | |||
| 104 | + | } | |||
| 105 | + | if exp < 0 { | |||
| 106 | + | return sign + "0." + strings.Repeat("0", -exp-1) + digits | |||
| 107 | + | } | |||
| 108 | + | if len(digits) <= exp+1 { | |||
| 109 | + | return sign + digits + strings.Repeat("0", exp+1-len(digits)) | |||
| 110 | + | } | |||
| 111 | + | return sign + digits[:exp+1] + "." + digits[exp+1:] | |||
| 112 | + | } | |||
M template/reader.go +103 -6
| @@ -23,7 +23,7 @@ func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token) | |||
| 23 | 23 | ctx.add(e) | |
| 24 | 24 | return nil | |
| 25 | 25 | } | |
| 26 | - | res := readYAMLDocument(file, content) | |
| 26 | + | res := readYAMLDocument(file, content, ctx.RunnerYAML) | |
| 27 | 27 | for _, e := range res.errs { | |
| 28 | 28 | ctx.add(e) | |
| 29 | 29 | } | |
| @@ -41,10 +41,18 @@ func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token) | |||
| 41 | 41 | }() | |
| 42 | 42 | rd := &reader{ctx: ctx, schema: ctx.schema, file: file} | |
| 43 | 43 | if res.root == nil { | |
| 44 | + | if ctx.RunnerYAML { | |
| 45 | + | // YamlDotNet produces no DocumentStart event for an empty stream. | |
| 46 | + | fatalf("Expected document start parse event") | |
| 47 | + | } | |
| 44 | 48 | fatalf("Expected a scalar value, a sequence, or a mapping") | |
| 45 | 49 | } | |
| 46 | 50 | def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType)) | |
| 47 | - | return rd.readValue(res.root, def) | |
| 51 | + | root = rd.readValue(res.root, def) | |
| 52 | + | for _, e := range res.after { | |
| 53 | + | ctx.add(e) | |
| 54 | + | } | |
| 55 | + | return root | |
| 48 | 56 | } | |
| 49 | 57 | ||
| 50 | 58 | type reader struct { | |
| @@ -54,16 +62,81 @@ type reader struct { | |||
| 54 | 62 | // noExpressions is set when reading the result of an evaluation, whose | |
| 55 | 63 | // strings are text even if they contain ${{. | |
| 56 | 64 | noExpressions bool | |
| 65 | + | // depth is the nesting of the collections being read, see Context.MaxDepth. | |
| 66 | + | depth int | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // enter counts one more level of nesting. Like TemplateMemory.IncrementDepth | |
| 70 | + | // it fails when the levels already entered reach the limit, so a limit of 100 | |
| 71 | + | // allows 100 nested collections. | |
| 72 | + | func (r *reader) enter() { | |
| 73 | + | if r.ctx.MaxDepth <= 0 { | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | if r.depth >= r.ctx.MaxDepth { | |
| 77 | + | fatalf("Maximum object depth exceeded") | |
| 78 | + | } | |
| 79 | + | r.depth++ | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // text is the string a literal becomes when the schema wants a string, and | |
| 83 | + | // the text of a key that is not a string: Token.String, except that a number | |
| 84 | + | // is shown as the runner's NumberToken shows it in runner mode. | |
| 85 | + | func (r *reader) text(t Token) string { | |
| 86 | + | if n, ok := t.(*Number); ok && r.ctx.RunnerYAML { | |
| 87 | + | return DotNetNumberString(n.Value) | |
| 88 | + | } | |
| 89 | + | return t.String() | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (r *reader) leave() { | |
| 93 | + | if r.ctx.MaxDepth > 0 { | |
| 94 | + | r.depth-- | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // skip walks a value that is not kept (the value of an unexpected key, the | |
| 99 | + | // items of a collection the schema does not allow) and counts its nesting, | |
| 100 | + | // as the reference's SkipValue does. | |
| 101 | + | func (r *reader) skip(n *rnode) { | |
| 102 | + | if n == nil { | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | if n.fault != "" { | |
| 106 | + | fatalf("%s", n.fault) | |
| 107 | + | } | |
| 108 | + | if r.ctx.MaxDepth <= 0 { | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | switch n.kind { | |
| 112 | + | case rSeq: | |
| 113 | + | r.enter() | |
| 114 | + | for _, it := range n.items { | |
| 115 | + | r.skip(it) | |
| 116 | + | } | |
| 117 | + | r.leave() | |
| 118 | + | case rMap: | |
| 119 | + | r.enter() | |
| 120 | + | for i := range n.keys { | |
| 121 | + | r.skip(n.keys[i]) | |
| 122 | + | r.skip(n.vals[i]) | |
| 123 | + | } | |
| 124 | + | r.leave() | |
| 125 | + | } | |
| 57 | 126 | } | |
| 58 | 127 | ||
| 59 | 128 | func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} } | |
| 60 | 129 | ||
| 61 | 130 | func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |
| 131 | + | if n.fault != "" { | |
| 132 | + | fatalf("%s", n.fault) | |
| 133 | + | } | |
| 62 | 134 | switch n.kind { | |
| 63 | 135 | case rLiteral: | |
| 64 | 136 | scalar := r.parseScalar(n, n.lit, def) | |
| 65 | 137 | return r.validate(scalar, def) | |
| 66 | 138 | case rSeq: | |
| 139 | + | r.enter() | |
| 67 | 140 | seq := NewSequence(r.origin(n)) | |
| 68 | 141 | defs := def.schema.definitionsOfType(def.Definition, DefSequence) | |
| 69 | 142 | if len(defs) > 0 { | |
| @@ -73,10 +146,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |||
| 73 | 146 | } | |
| 74 | 147 | } else { | |
| 75 | 148 | r.ctx.ErrorAt(seq, "A sequence was not expected") | |
| 149 | + | for _, it := range n.items { | |
| 150 | + | r.skip(it) | |
| 151 | + | } | |
| 76 | 152 | } | |
| 77 | 153 | seq.def = def | |
| 154 | + | r.leave() | |
| 78 | 155 | return seq | |
| 79 | 156 | } | |
| 157 | + | r.enter() | |
| 80 | 158 | m := NewMapping(r.origin(n)) | |
| 81 | 159 | defs := def.schema.definitionsOfType(def.Definition, DefMapping) | |
| 82 | 160 | if len(defs) > 0 { | |
| @@ -89,10 +167,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |||
| 89 | 167 | } | |
| 90 | 168 | } else { | |
| 91 | 169 | r.ctx.ErrorAt(m, "A mapping was not expected") | |
| 170 | + | for i := range n.keys { | |
| 171 | + | r.skip(n.keys[i]) | |
| 172 | + | r.skip(n.vals[i]) | |
| 173 | + | } | |
| 92 | 174 | } | |
| 93 | 175 | if m.def == nil { | |
| 94 | 176 | m.def = def | |
| 95 | 177 | } | |
| 178 | + | r.leave() | |
| 96 | 179 | return m | |
| 97 | 180 | } | |
| 98 | 181 | ||
| @@ -100,7 +183,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |||
| 100 | 183 | // error and reads as an empty string. | |
| 101 | 184 | func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token { | |
| 102 | 185 | k := n.keys[i] | |
| 186 | + | if k.fault != "" { | |
| 187 | + | fatalf("%s", k.fault) | |
| 188 | + | } | |
| 103 | 189 | if k.badKey { | |
| 190 | + | if r.ctx.RunnerYAML { | |
| 191 | + | // The runner's loop over the keys ends at the first one that is not | |
| 192 | + | // a literal, and then expects the end of the mapping. | |
| 193 | + | fatalf("Expected mapping end") | |
| 194 | + | } | |
| 104 | 195 | r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''") | |
| 105 | 196 | } | |
| 106 | 197 | return r.parseScalar(k, k.lit, def) | |
| @@ -123,17 +214,19 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo | |||
| 123 | 214 | m.Add(keyScalar, r.readValue(val, def.child("any"))) | |
| 124 | 215 | } else { | |
| 125 | 216 | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | |
| 217 | + | r.skip(val) | |
| 126 | 218 | } | |
| 127 | 219 | continue | |
| 128 | 220 | } | |
| 129 | 221 | nextKey, ok := keyScalar.(*String) | |
| 130 | 222 | if !ok { | |
| 131 | - | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | |
| 223 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: r.text(keyScalar)} | |
| 132 | 224 | } | |
| 133 | 225 | if nextKey.Value != "" { | |
| 134 | 226 | up := strings.ToUpper(nextKey.Value) | |
| 135 | 227 | if upperKeys[up] { | |
| 136 | 228 | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | |
| 229 | + | r.skip(val) | |
| 137 | 230 | continue | |
| 138 | 231 | } | |
| 139 | 232 | upperKeys[up] = true | |
| @@ -143,6 +236,7 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo | |||
| 143 | 236 | if prop != nil { | |
| 144 | 237 | if backgroundStepProperties[nextKey.Value] { | |
| 145 | 238 | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | |
| 239 | + | r.skip(val) | |
| 146 | 240 | continue | |
| 147 | 241 | } | |
| 148 | 242 | nd := def.child(prop.Type) | |
| @@ -161,6 +255,7 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo | |||
| 161 | 255 | continue | |
| 162 | 256 | } | |
| 163 | 257 | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | |
| 258 | + | r.skip(val) | |
| 164 | 259 | } | |
| 165 | 260 | if len(defs) == 1 { | |
| 166 | 261 | m.def = newDefinitionInfo(def.schema, def, defs[0]) | |
| @@ -206,17 +301,19 @@ func (r *reader) mappingAllLoose(n *rnode, m *Mapping, def, keyDef, valDef *Defi | |||
| 206 | 301 | m.Add(keyScalar, r.readValue(val, valDef)) | |
| 207 | 302 | } else { | |
| 208 | 303 | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | |
| 304 | + | r.skip(val) | |
| 209 | 305 | } | |
| 210 | 306 | continue | |
| 211 | 307 | } | |
| 212 | 308 | nextKey, ok := keyScalar.(*String) | |
| 213 | 309 | if !ok { | |
| 214 | - | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | |
| 310 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: r.text(keyScalar)} | |
| 215 | 311 | } | |
| 216 | 312 | if nextKey.Value != "" { | |
| 217 | 313 | up := strings.ToUpper(nextKey.Value) | |
| 218 | 314 | if upperKeys[up] { | |
| 219 | 315 | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | |
| 316 | + | r.skip(val) | |
| 220 | 317 | continue | |
| 221 | 318 | } | |
| 222 | 319 | upperKeys[up] = true | |
| @@ -239,7 +336,7 @@ func (r *reader) validate(scalar Token, def *DefinitionInfo) Token { | |||
| 239 | 336 | } | |
| 240 | 337 | } | |
| 241 | 338 | if scalar.Kind() != KindString { | |
| 242 | - | sl := &String{tokenBase: *scalar.base(), Value: scalar.String()} | |
| 339 | + | sl := &String{tokenBase: *scalar.base(), Value: r.text(scalar)} | |
| 243 | 340 | for _, d := range def.schema.scalarDefinitions(def.Definition) { | |
| 244 | 341 | if d.isMatch(sl) { | |
| 245 | 342 | sl.def = newDefinitionInfo(def.schema, def, d) | |
| @@ -247,7 +344,7 @@ func (r *reader) validate(scalar Token, def *DefinitionInfo) Token { | |||
| 247 | 344 | } | |
| 248 | 345 | } | |
| 249 | 346 | } | |
| 250 | - | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String())) | |
| 347 | + | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", r.text(scalar))) | |
| 251 | 348 | case KindBasicExpression: | |
| 252 | 349 | if len(def.allowed) == 0 { | |
| 253 | 350 | r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context") | |
| @@ -23,7 +23,7 @@ func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token) | |||||
| 23 | ctx.add(e) | 23 | ctx.add(e) | ||
| 24 | return nil | 24 | return nil | ||
| 25 | } | 25 | } | ||
| 26 | - | res := readYAMLDocument(file, content) | 26 | + | res := readYAMLDocument(file, content, ctx.RunnerYAML) |
| 27 | for _, e := range res.errs { | 27 | for _, e := range res.errs { | ||
| 28 | ctx.add(e) | 28 | ctx.add(e) | ||
| 29 | } | 29 | } | ||
| @@ -41,10 +41,18 @@ func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token) | |||||
| 41 | }() | 41 | }() | ||
| 42 | rd := &reader{ctx: ctx, schema: ctx.schema, file: file} | 42 | rd := &reader{ctx: ctx, schema: ctx.schema, file: file} | ||
| 43 | if res.root == nil { | 43 | if res.root == nil { | ||
| 44 | + | if ctx.RunnerYAML { | |||
| 45 | + | // YamlDotNet produces no DocumentStart event for an empty stream. | |||
| 46 | + | fatalf("Expected document start parse event") | |||
| 47 | + | } | |||
| 44 | fatalf("Expected a scalar value, a sequence, or a mapping") | 48 | fatalf("Expected a scalar value, a sequence, or a mapping") | ||
| 45 | } | 49 | } | ||
| 46 | def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType)) | 50 | def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType)) | ||
| 47 | - | return rd.readValue(res.root, def) | 51 | + | root = rd.readValue(res.root, def) |
| 52 | + | for _, e := range res.after { | |||
| 53 | + | ctx.add(e) | |||
| 54 | + | } | |||
| 55 | + | return root | |||
| 48 | } | 56 | } | ||
| 49 | 57 | ||||
| 50 | type reader struct { | 58 | type reader struct { | ||
| @@ -54,16 +62,81 @@ type reader struct { | |||||
| 54 | // noExpressions is set when reading the result of an evaluation, whose | 62 | // noExpressions is set when reading the result of an evaluation, whose | ||
| 55 | // strings are text even if they contain ${{. | 63 | // strings are text even if they contain ${{. | ||
| 56 | noExpressions bool | 64 | noExpressions bool | ||
| 65 | + | // depth is the nesting of the collections being read, see Context.MaxDepth. | |||
| 66 | + | depth int | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // enter counts one more level of nesting. Like TemplateMemory.IncrementDepth | |||
| 70 | + | // it fails when the levels already entered reach the limit, so a limit of 100 | |||
| 71 | + | // allows 100 nested collections. | |||
| 72 | + | func (r *reader) enter() { | |||
| 73 | + | if r.ctx.MaxDepth <= 0 { | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | if r.depth >= r.ctx.MaxDepth { | |||
| 77 | + | fatalf("Maximum object depth exceeded") | |||
| 78 | + | } | |||
| 79 | + | r.depth++ | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // text is the string a literal becomes when the schema wants a string, and | |||
| 83 | + | // the text of a key that is not a string: Token.String, except that a number | |||
| 84 | + | // is shown as the runner's NumberToken shows it in runner mode. | |||
| 85 | + | func (r *reader) text(t Token) string { | |||
| 86 | + | if n, ok := t.(*Number); ok && r.ctx.RunnerYAML { | |||
| 87 | + | return DotNetNumberString(n.Value) | |||
| 88 | + | } | |||
| 89 | + | return t.String() | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (r *reader) leave() { | |||
| 93 | + | if r.ctx.MaxDepth > 0 { | |||
| 94 | + | r.depth-- | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // skip walks a value that is not kept (the value of an unexpected key, the | |||
| 99 | + | // items of a collection the schema does not allow) and counts its nesting, | |||
| 100 | + | // as the reference's SkipValue does. | |||
| 101 | + | func (r *reader) skip(n *rnode) { | |||
| 102 | + | if n == nil { | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | if n.fault != "" { | |||
| 106 | + | fatalf("%s", n.fault) | |||
| 107 | + | } | |||
| 108 | + | if r.ctx.MaxDepth <= 0 { | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | switch n.kind { | |||
| 112 | + | case rSeq: | |||
| 113 | + | r.enter() | |||
| 114 | + | for _, it := range n.items { | |||
| 115 | + | r.skip(it) | |||
| 116 | + | } | |||
| 117 | + | r.leave() | |||
| 118 | + | case rMap: | |||
| 119 | + | r.enter() | |||
| 120 | + | for i := range n.keys { | |||
| 121 | + | r.skip(n.keys[i]) | |||
| 122 | + | r.skip(n.vals[i]) | |||
| 123 | + | } | |||
| 124 | + | r.leave() | |||
| 125 | + | } | |||
| 57 | } | 126 | } | ||
| 58 | 127 | ||||
| 59 | func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} } | 128 | func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} } | ||
| 60 | 129 | ||||
| 61 | func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | 130 | func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | ||
| 131 | + | if n.fault != "" { | |||
| 132 | + | fatalf("%s", n.fault) | |||
| 133 | + | } | |||
| 62 | switch n.kind { | 134 | switch n.kind { | ||
| 63 | case rLiteral: | 135 | case rLiteral: | ||
| 64 | scalar := r.parseScalar(n, n.lit, def) | 136 | scalar := r.parseScalar(n, n.lit, def) | ||
| 65 | return r.validate(scalar, def) | 137 | return r.validate(scalar, def) | ||
| 66 | case rSeq: | 138 | case rSeq: | ||
| 139 | + | r.enter() | |||
| 67 | seq := NewSequence(r.origin(n)) | 140 | seq := NewSequence(r.origin(n)) | ||
| 68 | defs := def.schema.definitionsOfType(def.Definition, DefSequence) | 141 | defs := def.schema.definitionsOfType(def.Definition, DefSequence) | ||
| 69 | if len(defs) > 0 { | 142 | if len(defs) > 0 { | ||
| @@ -73,10 +146,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |||||
| 73 | } | 146 | } | ||
| 74 | } else { | 147 | } else { | ||
| 75 | r.ctx.ErrorAt(seq, "A sequence was not expected") | 148 | r.ctx.ErrorAt(seq, "A sequence was not expected") | ||
| 149 | + | for _, it := range n.items { | |||
| 150 | + | r.skip(it) | |||
| 151 | + | } | |||
| 76 | } | 152 | } | ||
| 77 | seq.def = def | 153 | seq.def = def | ||
| 154 | + | r.leave() | |||
| 78 | return seq | 155 | return seq | ||
| 79 | } | 156 | } | ||
| 157 | + | r.enter() | |||
| 80 | m := NewMapping(r.origin(n)) | 158 | m := NewMapping(r.origin(n)) | ||
| 81 | defs := def.schema.definitionsOfType(def.Definition, DefMapping) | 159 | defs := def.schema.definitionsOfType(def.Definition, DefMapping) | ||
| 82 | if len(defs) > 0 { | 160 | if len(defs) > 0 { | ||
| @@ -89,10 +167,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |||||
| 89 | } | 167 | } | ||
| 90 | } else { | 168 | } else { | ||
| 91 | r.ctx.ErrorAt(m, "A mapping was not expected") | 169 | r.ctx.ErrorAt(m, "A mapping was not expected") | ||
| 170 | + | for i := range n.keys { | |||
| 171 | + | r.skip(n.keys[i]) | |||
| 172 | + | r.skip(n.vals[i]) | |||
| 173 | + | } | |||
| 92 | } | 174 | } | ||
| 93 | if m.def == nil { | 175 | if m.def == nil { | ||
| 94 | m.def = def | 176 | m.def = def | ||
| 95 | } | 177 | } | ||
| 178 | + | r.leave() | |||
| 96 | return m | 179 | return m | ||
| 97 | } | 180 | } | ||
| 98 | 181 | ||||
| @@ -100,7 +183,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |||||
| 100 | // error and reads as an empty string. | 183 | // error and reads as an empty string. | ||
| 101 | func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token { | 184 | func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token { | ||
| 102 | k := n.keys[i] | 185 | k := n.keys[i] | ||
| 186 | + | if k.fault != "" { | |||
| 187 | + | fatalf("%s", k.fault) | |||
| 188 | + | } | |||
| 103 | if k.badKey { | 189 | if k.badKey { | ||
| 190 | + | if r.ctx.RunnerYAML { | |||
| 191 | + | // The runner's loop over the keys ends at the first one that is not | |||
| 192 | + | // a literal, and then expects the end of the mapping. | |||
| 193 | + | fatalf("Expected mapping end") | |||
| 194 | + | } | |||
| 104 | r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''") | 195 | r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''") | ||
| 105 | } | 196 | } | ||
| 106 | return r.parseScalar(k, k.lit, def) | 197 | return r.parseScalar(k, k.lit, def) | ||
| @@ -123,17 +214,19 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo | |||||
| 123 | m.Add(keyScalar, r.readValue(val, def.child("any"))) | 214 | m.Add(keyScalar, r.readValue(val, def.child("any"))) | ||
| 124 | } else { | 215 | } else { | ||
| 125 | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | 216 | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | ||
| 217 | + | r.skip(val) | |||
| 126 | } | 218 | } | ||
| 127 | continue | 219 | continue | ||
| 128 | } | 220 | } | ||
| 129 | nextKey, ok := keyScalar.(*String) | 221 | nextKey, ok := keyScalar.(*String) | ||
| 130 | if !ok { | 222 | if !ok { | ||
| 131 | - | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | 223 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: r.text(keyScalar)} |
| 132 | } | 224 | } | ||
| 133 | if nextKey.Value != "" { | 225 | if nextKey.Value != "" { | ||
| 134 | up := strings.ToUpper(nextKey.Value) | 226 | up := strings.ToUpper(nextKey.Value) | ||
| 135 | if upperKeys[up] { | 227 | if upperKeys[up] { | ||
| 136 | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | 228 | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | ||
| 229 | + | r.skip(val) | |||
| 137 | continue | 230 | continue | ||
| 138 | } | 231 | } | ||
| 139 | upperKeys[up] = true | 232 | upperKeys[up] = true | ||
| @@ -143,6 +236,7 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo | |||||
| 143 | if prop != nil { | 236 | if prop != nil { | ||
| 144 | if backgroundStepProperties[nextKey.Value] { | 237 | if backgroundStepProperties[nextKey.Value] { | ||
| 145 | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | 238 | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | ||
| 239 | + | r.skip(val) | |||
| 146 | continue | 240 | continue | ||
| 147 | } | 241 | } | ||
| 148 | nd := def.child(prop.Type) | 242 | nd := def.child(prop.Type) | ||
| @@ -161,6 +255,7 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo | |||||
| 161 | continue | 255 | continue | ||
| 162 | } | 256 | } | ||
| 163 | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | 257 | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | ||
| 258 | + | r.skip(val) | |||
| 164 | } | 259 | } | ||
| 165 | if len(defs) == 1 { | 260 | if len(defs) == 1 { | ||
| 166 | m.def = newDefinitionInfo(def.schema, def, defs[0]) | 261 | m.def = newDefinitionInfo(def.schema, def, defs[0]) | ||
| @@ -206,17 +301,19 @@ func (r *reader) mappingAllLoose(n *rnode, m *Mapping, def, keyDef, valDef *Defi | |||||
| 206 | m.Add(keyScalar, r.readValue(val, valDef)) | 301 | m.Add(keyScalar, r.readValue(val, valDef)) | ||
| 207 | } else { | 302 | } else { | ||
| 208 | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | 303 | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | ||
| 304 | + | r.skip(val) | |||
| 209 | } | 305 | } | ||
| 210 | continue | 306 | continue | ||
| 211 | } | 307 | } | ||
| 212 | nextKey, ok := keyScalar.(*String) | 308 | nextKey, ok := keyScalar.(*String) | ||
| 213 | if !ok { | 309 | if !ok { | ||
| 214 | - | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | 310 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: r.text(keyScalar)} |
| 215 | } | 311 | } | ||
| 216 | if nextKey.Value != "" { | 312 | if nextKey.Value != "" { | ||
| 217 | up := strings.ToUpper(nextKey.Value) | 313 | up := strings.ToUpper(nextKey.Value) | ||
| 218 | if upperKeys[up] { | 314 | if upperKeys[up] { | ||
| 219 | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | 315 | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | ||
| 316 | + | r.skip(val) | |||
| 220 | continue | 317 | continue | ||
| 221 | } | 318 | } | ||
| 222 | upperKeys[up] = true | 319 | upperKeys[up] = true | ||
| @@ -239,7 +336,7 @@ func (r *reader) validate(scalar Token, def *DefinitionInfo) Token { | |||||
| 239 | } | 336 | } | ||
| 240 | } | 337 | } | ||
| 241 | if scalar.Kind() != KindString { | 338 | if scalar.Kind() != KindString { | ||
| 242 | - | sl := &String{tokenBase: *scalar.base(), Value: scalar.String()} | 339 | + | sl := &String{tokenBase: *scalar.base(), Value: r.text(scalar)} |
| 243 | for _, d := range def.schema.scalarDefinitions(def.Definition) { | 340 | for _, d := range def.schema.scalarDefinitions(def.Definition) { | ||
| 244 | if d.isMatch(sl) { | 341 | if d.isMatch(sl) { | ||
| 245 | sl.def = newDefinitionInfo(def.schema, def, d) | 342 | sl.def = newDefinitionInfo(def.schema, def, d) | ||
| @@ -247,7 +344,7 @@ func (r *reader) validate(scalar Token, def *DefinitionInfo) Token { | |||||
| 247 | } | 344 | } | ||
| 248 | } | 345 | } | ||
| 249 | } | 346 | } | ||
| 250 | - | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String())) | 347 | + | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", r.text(scalar))) |
| 251 | case KindBasicExpression: | 348 | case KindBasicExpression: | ||
| 252 | if len(def.allowed) == 0 { | 349 | if len(def.allowed) == 0 { | ||
| 253 | r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context") | 350 | r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context") | ||
M template/scalar_ref_test.go +1 -1
| @@ -23,7 +23,7 @@ func TestPlainScalarsAgainstReference(t *testing.T) { | |||
| 23 | 23 | t.Fatal(err) | |
| 24 | 24 | } | |
| 25 | 25 | for src, w := range want { | |
| 26 | - | res := readYAMLDocument("f.yml", []byte("k: "+src+"\n")) | |
| 26 | + | res := readYAMLDocument("f.yml", []byte("k: "+src+"\n"), false) | |
| 27 | 27 | if w == "ERR" { | |
| 28 | 28 | continue | |
| 29 | 29 | } | |
| @@ -23,7 +23,7 @@ func TestPlainScalarsAgainstReference(t *testing.T) { | |||||
| 23 | t.Fatal(err) | 23 | t.Fatal(err) | ||
| 24 | } | 24 | } | ||
| 25 | for src, w := range want { | 25 | for src, w := range want { | ||
| 26 | - | res := readYAMLDocument("f.yml", []byte("k: "+src+"\n")) | 26 | + | res := readYAMLDocument("f.yml", []byte("k: "+src+"\n"), false) |
| 27 | if w == "ERR" { | 27 | if w == "ERR" { | ||
| 28 | continue | 28 | continue | ||
| 29 | } | 29 | } | ||
M template/yaml.go +120 -8
| @@ -51,12 +51,19 @@ type rnode struct { | |||
| 51 | 51 | block bool | |
| 52 | 52 | // badKey is set on a key that is a collection. | |
| 53 | 53 | badKey bool | |
| 54 | + | // fault is set in runner mode (Context.RunnerYAML) on a node the runner's | |
| 55 | + | // YAML reader refuses. The template reader stops with this message when it | |
| 56 | + | // reaches the node, so the errors found before it in the document are kept. | |
| 57 | + | fault string | |
| 54 | 58 | } | |
| 55 | 59 | ||
| 56 | 60 | // yamlResult is the outcome of reading a YAML document. | |
| 57 | 61 | type yamlResult struct { | |
| 58 | 62 | root *rnode // nil for an empty document | |
| 59 | 63 | errs []*Error | |
| 64 | + | // after holds errors to record once the document is read (runner mode: | |
| 65 | + | // the runner notices a second document only after reading the first). | |
| 66 | + | after []*Error | |
| 60 | 67 | } | |
| 61 | 68 | ||
| 62 | 69 | type yamlBuilder struct { | |
| @@ -65,11 +72,13 @@ type yamlBuilder struct { | |||
| 65 | 72 | errs []*Error | |
| 66 | 73 | stack map[*yaml.Node]bool | |
| 67 | 74 | over bool | |
| 75 | + | // runnerYAML selects the runner's restrictions, see Context.RunnerYAML. | |
| 76 | + | runnerYAML bool | |
| 68 | 77 | } | |
| 69 | 78 | ||
| 70 | 79 | // readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax | |
| 71 | 80 | // errors are returned in errs, with root nil. | |
| 72 | - | func readYAMLDocument(file string, content []byte) (res yamlResult) { | |
| 81 | + | func readYAMLDocument(file string, content []byte, runnerYAML bool) (res yamlResult) { | |
| 73 | 82 | defer func() { | |
| 74 | 83 | if r := recover(); r != nil { | |
| 75 | 84 | res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}} | |
| @@ -84,25 +93,32 @@ func readYAMLDocument(file string, content []byte) (res yamlResult) { | |||
| 84 | 93 | if err != nil { | |
| 85 | 94 | return yamlResult{errs: []*Error{yamlError(file, err)}} | |
| 86 | 95 | } | |
| 96 | + | var after []*Error | |
| 87 | 97 | var second yaml.Node | |
| 88 | 98 | if err := dec.Decode(&second); err == nil { | |
| 89 | - | e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"} | |
| 90 | - | e.Pos = Pos{second.Line, second.Column} | |
| 91 | - | e.fileOnly = true | |
| 92 | - | e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column) | |
| 93 | - | return yamlResult{errs: []*Error{e}} | |
| 99 | + | if runnerYAML { | |
| 100 | + | // The runner reads the first document, then fails on the event | |
| 101 | + | // that is not the end of the stream. | |
| 102 | + | after = []*Error{{File: file, fileOnly: true, Raw: "Expected stream end parse event"}} | |
| 103 | + | } else { | |
| 104 | + | e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"} | |
| 105 | + | e.Pos = Pos{second.Line, second.Column} | |
| 106 | + | e.fileOnly = true | |
| 107 | + | e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column) | |
| 108 | + | return yamlResult{errs: []*Error{e}} | |
| 109 | + | } | |
| 94 | 110 | } else if !errors.Is(err, io.EOF) { | |
| 95 | 111 | return yamlResult{errs: []*Error{yamlError(file, err)}} | |
| 96 | 112 | } | |
| 97 | 113 | if len(doc.Content) == 0 { | |
| 98 | 114 | return yamlResult{} | |
| 99 | 115 | } | |
| 100 | - | b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}} | |
| 116 | + | b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}, runnerYAML: runnerYAML} | |
| 101 | 117 | root := b.build(doc.Content[0], false) | |
| 102 | 118 | if b.over { | |
| 103 | 119 | return yamlResult{errs: b.errs} | |
| 104 | 120 | } | |
| 105 | - | return yamlResult{root: root, errs: b.errs} | |
| 121 | + | return yamlResult{root: root, errs: b.errs, after: after} | |
| 106 | 122 | } | |
| 107 | 123 | ||
| 108 | 124 | var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`) | |
| @@ -139,6 +155,14 @@ func (b *yamlBuilder) count() bool { | |||
| 139 | 155 | } | |
| 140 | 156 | ||
| 141 | 157 | func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode { | |
| 158 | + | r := b.build1(n, asKey) | |
| 159 | + | if b.runnerYAML && r.fault == "" && n.Kind != yaml.AliasNode { | |
| 160 | + | r.fault = runnerYAMLError(n) | |
| 161 | + | } | |
| 162 | + | return r | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (b *yamlBuilder) build1(n *yaml.Node, asKey bool) *rnode { | |
| 142 | 166 | if b.over || !b.count() { | |
| 143 | 167 | return &rnode{kind: rLiteral, lit: NewNull(Origin{})} | |
| 144 | 168 | } | |
| @@ -193,6 +217,94 @@ func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode { | |||
| 193 | 217 | return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)} | |
| 194 | 218 | } | |
| 195 | 219 | ||
| 220 | + | // runnerYAMLError returns the message of the runner's YAML reader | |
| 221 | + | // (Sdk/DTPipelines/Pipelines/ObjectTemplating/YamlObjectReader.cs) for a node | |
| 222 | + | // it refuses, or "". It checks anchors, and the tag and style of scalars. | |
| 223 | + | func runnerYAMLError(n *yaml.Node) string { | |
| 224 | + | if n.Anchor != "" { | |
| 225 | + | return fmt.Sprintf("Anchors are not currently supported. Remove the anchor '%s'", n.Anchor) | |
| 226 | + | } | |
| 227 | + | if n.Kind == yaml.ScalarNode && n.Style&(yaml.TaggedStyle|yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) == 0 { | |
| 228 | + | if msg := runnerIntegerError(n); msg != "" { | |
| 229 | + | return msg | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | if n.Kind != yaml.ScalarNode || n.Style&yaml.TaggedStyle == 0 { | |
| 233 | + | return "" | |
| 234 | + | } | |
| 235 | + | tag := n.LongTag() | |
| 236 | + | const yamlTag = "tag:yaml.org,2002:" | |
| 237 | + | // The runner's string tag is "tag:yaml.org,2002:string", which no YAML | |
| 238 | + | // document spells (!!str is "tag:yaml.org,2002:str"). | |
| 239 | + | if tag == yamlTag+"string" { | |
| 240 | + | return "" | |
| 241 | + | } | |
| 242 | + | plain := n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) == 0 | |
| 243 | + | if !plain { | |
| 244 | + | style := "SingleQuoted" | |
| 245 | + | switch { | |
| 246 | + | case n.Style&yaml.DoubleQuotedStyle != 0: | |
| 247 | + | style = "DoubleQuoted" | |
| 248 | + | case n.Style&yaml.LiteralStyle != 0: | |
| 249 | + | style = "Literal" | |
| 250 | + | case n.Style&yaml.FoldedStyle != 0: | |
| 251 | + | style = "Folded" | |
| 252 | + | } | |
| 253 | + | return fmt.Sprintf("The scalar style '%s' on line %d and column %d is not valid with the tag '%s'", style, n.Line, n.Column, tag) | |
| 254 | + | } | |
| 255 | + | switch tag { | |
| 256 | + | case yamlTag + "bool", yamlTag + "float", yamlTag + "int", yamlTag + "null": | |
| 257 | + | default: | |
| 258 | + | return fmt.Sprintf("Unexpected tag '%s'", tag) | |
| 259 | + | } | |
| 260 | + | t := resolvePlain(Origin{}, n.Value) | |
| 261 | + | ok := false | |
| 262 | + | switch tag { | |
| 263 | + | case yamlTag + "bool": | |
| 264 | + | ok = t.Kind() == KindBoolean | |
| 265 | + | case yamlTag + "null": | |
| 266 | + | ok = t.Kind() == KindNull | |
| 267 | + | case yamlTag + "float": | |
| 268 | + | ok = t.Kind() == KindNumber | |
| 269 | + | case yamlTag + "int": | |
| 270 | + | ok = t.Kind() == KindNumber && (reInt.MatchString(n.Value) || reOct.MatchString(n.Value) || reHex.MatchString(n.Value)) | |
| 271 | + | } | |
| 272 | + | if !ok { | |
| 273 | + | return fmt.Sprintf("The value '%s' on line %d and column %d is invalid for the type '%s'", n.Value, n.Line, n.Column, tag) | |
| 274 | + | } | |
| 275 | + | return "" | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // runnerIntegerError is the runner's refusal of a plain 0x or 0o integer that | |
| 279 | + | // does not fit 32 bits (Int32.TryParse with AllowHexSpecifier, which accepts up | |
| 280 | + | // to FFFFFFFF as a negative number, and Convert.ToInt32(s, 8)). The tag is | |
| 281 | + | // empty in the message, as the plain scalar has none. | |
| 282 | + | func runnerIntegerError(n *yaml.Node) string { | |
| 283 | + | v := n.Value | |
| 284 | + | var limit uint64 = 1<<32 - 1 | |
| 285 | + | var base int | |
| 286 | + | switch { | |
| 287 | + | case reHex.MatchString(v): | |
| 288 | + | base = 16 | |
| 289 | + | case reOct.MatchString(v): | |
| 290 | + | base = 8 | |
| 291 | + | default: | |
| 292 | + | return "" | |
| 293 | + | } | |
| 294 | + | var acc uint64 | |
| 295 | + | for _, c := range strings.ToLower(v[2:]) { | |
| 296 | + | d := uint64(c - '0') | |
| 297 | + | if c >= 'a' { | |
| 298 | + | d = uint64(c-'a') + 10 | |
| 299 | + | } | |
| 300 | + | acc = acc*uint64(base) + d | |
| 301 | + | if acc > limit { | |
| 302 | + | return fmt.Sprintf("The value '%s' on line %d and column %d is invalid for the type ''", v, n.Line, n.Column) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | return "" | |
| 306 | + | } | |
| 307 | + | ||
| 196 | 308 | // resolveScalar gives a scalar its type the way the YAML 1.2 core schema of | |
| 197 | 309 | // the JavaScript yaml library does: only plain scalars are resolved, and an | |
| 198 | 310 | // explicit !!tag overrides the guess. | |
| @@ -51,12 +51,19 @@ type rnode struct { | |||||
| 51 | block bool | 51 | block bool | ||
| 52 | // badKey is set on a key that is a collection. | 52 | // badKey is set on a key that is a collection. | ||
| 53 | badKey bool | 53 | badKey bool | ||
| 54 | + | // fault is set in runner mode (Context.RunnerYAML) on a node the runner's | |||
| 55 | + | // YAML reader refuses. The template reader stops with this message when it | |||
| 56 | + | // reaches the node, so the errors found before it in the document are kept. | |||
| 57 | + | fault string | |||
| 54 | } | 58 | } | ||
| 55 | 59 | ||||
| 56 | // yamlResult is the outcome of reading a YAML document. | 60 | // yamlResult is the outcome of reading a YAML document. | ||
| 57 | type yamlResult struct { | 61 | type yamlResult struct { | ||
| 58 | root *rnode // nil for an empty document | 62 | root *rnode // nil for an empty document | ||
| 59 | errs []*Error | 63 | errs []*Error | ||
| 64 | + | // after holds errors to record once the document is read (runner mode: | |||
| 65 | + | // the runner notices a second document only after reading the first). | |||
| 66 | + | after []*Error | |||
| 60 | } | 67 | } | ||
| 61 | 68 | ||||
| 62 | type yamlBuilder struct { | 69 | type yamlBuilder struct { | ||
| @@ -65,11 +72,13 @@ type yamlBuilder struct { | |||||
| 65 | errs []*Error | 72 | errs []*Error | ||
| 66 | stack map[*yaml.Node]bool | 73 | stack map[*yaml.Node]bool | ||
| 67 | over bool | 74 | over bool | ||
| 75 | + | // runnerYAML selects the runner's restrictions, see Context.RunnerYAML. | |||
| 76 | + | runnerYAML bool | |||
| 68 | } | 77 | } | ||
| 69 | 78 | ||||
| 70 | // readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax | 79 | // readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax | ||
| 71 | // errors are returned in errs, with root nil. | 80 | // errors are returned in errs, with root nil. | ||
| 72 | - | func readYAMLDocument(file string, content []byte) (res yamlResult) { | 81 | + | func readYAMLDocument(file string, content []byte, runnerYAML bool) (res yamlResult) { |
| 73 | defer func() { | 82 | defer func() { | ||
| 74 | if r := recover(); r != nil { | 83 | if r := recover(); r != nil { | ||
| 75 | res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}} | 84 | res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}} | ||
| @@ -84,25 +93,32 @@ func readYAMLDocument(file string, content []byte) (res yamlResult) { | |||||
| 84 | if err != nil { | 93 | if err != nil { | ||
| 85 | return yamlResult{errs: []*Error{yamlError(file, err)}} | 94 | return yamlResult{errs: []*Error{yamlError(file, err)}} | ||
| 86 | } | 95 | } | ||
| 96 | + | var after []*Error | |||
| 87 | var second yaml.Node | 97 | var second yaml.Node | ||
| 88 | if err := dec.Decode(&second); err == nil { | 98 | if err := dec.Decode(&second); err == nil { | ||
| 89 | - | e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"} | 99 | + | if runnerYAML { |
| 90 | - | e.Pos = Pos{second.Line, second.Column} | 100 | + | // The runner reads the first document, then fails on the event |
| 91 | - | e.fileOnly = true | 101 | + | // that is not the end of the stream. |
| 92 | - | e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column) | 102 | + | after = []*Error{{File: file, fileOnly: true, Raw: "Expected stream end parse event"}} |
| 93 | - | return yamlResult{errs: []*Error{e}} | 103 | + | } else { |
| 104 | + | e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"} | |||
| 105 | + | e.Pos = Pos{second.Line, second.Column} | |||
| 106 | + | e.fileOnly = true | |||
| 107 | + | e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column) | |||
| 108 | + | return yamlResult{errs: []*Error{e}} | |||
| 109 | + | } | |||
| 94 | } else if !errors.Is(err, io.EOF) { | 110 | } else if !errors.Is(err, io.EOF) { | ||
| 95 | return yamlResult{errs: []*Error{yamlError(file, err)}} | 111 | return yamlResult{errs: []*Error{yamlError(file, err)}} | ||
| 96 | } | 112 | } | ||
| 97 | if len(doc.Content) == 0 { | 113 | if len(doc.Content) == 0 { | ||
| 98 | return yamlResult{} | 114 | return yamlResult{} | ||
| 99 | } | 115 | } | ||
| 100 | - | b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}} | 116 | + | b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}, runnerYAML: runnerYAML} |
| 101 | root := b.build(doc.Content[0], false) | 117 | root := b.build(doc.Content[0], false) | ||
| 102 | if b.over { | 118 | if b.over { | ||
| 103 | return yamlResult{errs: b.errs} | 119 | return yamlResult{errs: b.errs} | ||
| 104 | } | 120 | } | ||
| 105 | - | return yamlResult{root: root, errs: b.errs} | 121 | + | return yamlResult{root: root, errs: b.errs, after: after} |
| 106 | } | 122 | } | ||
| 107 | 123 | ||||
| 108 | var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`) | 124 | var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`) | ||
| @@ -139,6 +155,14 @@ func (b *yamlBuilder) count() bool { | |||||
| 139 | } | 155 | } | ||
| 140 | 156 | ||||
| 141 | func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode { | 157 | func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode { | ||
| 158 | + | r := b.build1(n, asKey) | |||
| 159 | + | if b.runnerYAML && r.fault == "" && n.Kind != yaml.AliasNode { | |||
| 160 | + | r.fault = runnerYAMLError(n) | |||
| 161 | + | } | |||
| 162 | + | return r | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (b *yamlBuilder) build1(n *yaml.Node, asKey bool) *rnode { | |||
| 142 | if b.over || !b.count() { | 166 | if b.over || !b.count() { | ||
| 143 | return &rnode{kind: rLiteral, lit: NewNull(Origin{})} | 167 | return &rnode{kind: rLiteral, lit: NewNull(Origin{})} | ||
| 144 | } | 168 | } | ||
| @@ -193,6 +217,94 @@ func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode { | |||||
| 193 | return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)} | 217 | return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)} | ||
| 194 | } | 218 | } | ||
| 195 | 219 | ||||
| 220 | + | // runnerYAMLError returns the message of the runner's YAML reader | |||
| 221 | + | // (Sdk/DTPipelines/Pipelines/ObjectTemplating/YamlObjectReader.cs) for a node | |||
| 222 | + | // it refuses, or "". It checks anchors, and the tag and style of scalars. | |||
| 223 | + | func runnerYAMLError(n *yaml.Node) string { | |||
| 224 | + | if n.Anchor != "" { | |||
| 225 | + | return fmt.Sprintf("Anchors are not currently supported. Remove the anchor '%s'", n.Anchor) | |||
| 226 | + | } | |||
| 227 | + | if n.Kind == yaml.ScalarNode && n.Style&(yaml.TaggedStyle|yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) == 0 { | |||
| 228 | + | if msg := runnerIntegerError(n); msg != "" { | |||
| 229 | + | return msg | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | if n.Kind != yaml.ScalarNode || n.Style&yaml.TaggedStyle == 0 { | |||
| 233 | + | return "" | |||
| 234 | + | } | |||
| 235 | + | tag := n.LongTag() | |||
| 236 | + | const yamlTag = "tag:yaml.org,2002:" | |||
| 237 | + | // The runner's string tag is "tag:yaml.org,2002:string", which no YAML | |||
| 238 | + | // document spells (!!str is "tag:yaml.org,2002:str"). | |||
| 239 | + | if tag == yamlTag+"string" { | |||
| 240 | + | return "" | |||
| 241 | + | } | |||
| 242 | + | plain := n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) == 0 | |||
| 243 | + | if !plain { | |||
| 244 | + | style := "SingleQuoted" | |||
| 245 | + | switch { | |||
| 246 | + | case n.Style&yaml.DoubleQuotedStyle != 0: | |||
| 247 | + | style = "DoubleQuoted" | |||
| 248 | + | case n.Style&yaml.LiteralStyle != 0: | |||
| 249 | + | style = "Literal" | |||
| 250 | + | case n.Style&yaml.FoldedStyle != 0: | |||
| 251 | + | style = "Folded" | |||
| 252 | + | } | |||
| 253 | + | return fmt.Sprintf("The scalar style '%s' on line %d and column %d is not valid with the tag '%s'", style, n.Line, n.Column, tag) | |||
| 254 | + | } | |||
| 255 | + | switch tag { | |||
| 256 | + | case yamlTag + "bool", yamlTag + "float", yamlTag + "int", yamlTag + "null": | |||
| 257 | + | default: | |||
| 258 | + | return fmt.Sprintf("Unexpected tag '%s'", tag) | |||
| 259 | + | } | |||
| 260 | + | t := resolvePlain(Origin{}, n.Value) | |||
| 261 | + | ok := false | |||
| 262 | + | switch tag { | |||
| 263 | + | case yamlTag + "bool": | |||
| 264 | + | ok = t.Kind() == KindBoolean | |||
| 265 | + | case yamlTag + "null": | |||
| 266 | + | ok = t.Kind() == KindNull | |||
| 267 | + | case yamlTag + "float": | |||
| 268 | + | ok = t.Kind() == KindNumber | |||
| 269 | + | case yamlTag + "int": | |||
| 270 | + | ok = t.Kind() == KindNumber && (reInt.MatchString(n.Value) || reOct.MatchString(n.Value) || reHex.MatchString(n.Value)) | |||
| 271 | + | } | |||
| 272 | + | if !ok { | |||
| 273 | + | return fmt.Sprintf("The value '%s' on line %d and column %d is invalid for the type '%s'", n.Value, n.Line, n.Column, tag) | |||
| 274 | + | } | |||
| 275 | + | return "" | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // runnerIntegerError is the runner's refusal of a plain 0x or 0o integer that | |||
| 279 | + | // does not fit 32 bits (Int32.TryParse with AllowHexSpecifier, which accepts up | |||
| 280 | + | // to FFFFFFFF as a negative number, and Convert.ToInt32(s, 8)). The tag is | |||
| 281 | + | // empty in the message, as the plain scalar has none. | |||
| 282 | + | func runnerIntegerError(n *yaml.Node) string { | |||
| 283 | + | v := n.Value | |||
| 284 | + | var limit uint64 = 1<<32 - 1 | |||
| 285 | + | var base int | |||
| 286 | + | switch { | |||
| 287 | + | case reHex.MatchString(v): | |||
| 288 | + | base = 16 | |||
| 289 | + | case reOct.MatchString(v): | |||
| 290 | + | base = 8 | |||
| 291 | + | default: | |||
| 292 | + | return "" | |||
| 293 | + | } | |||
| 294 | + | var acc uint64 | |||
| 295 | + | for _, c := range strings.ToLower(v[2:]) { | |||
| 296 | + | d := uint64(c - '0') | |||
| 297 | + | if c >= 'a' { | |||
| 298 | + | d = uint64(c-'a') + 10 | |||
| 299 | + | } | |||
| 300 | + | acc = acc*uint64(base) + d | |||
| 301 | + | if acc > limit { | |||
| 302 | + | return fmt.Sprintf("The value '%s' on line %d and column %d is invalid for the type ''", v, n.Line, n.Column) | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | return "" | |||
| 306 | + | } | |||
| 307 | + | ||||
| 196 | // resolveScalar gives a scalar its type the way the YAML 1.2 core schema of | 308 | // resolveScalar gives a scalar its type the way the YAML 1.2 core schema of | ||
| 197 | // the JavaScript yaml library does: only plain scalars are resolved, and an | 309 | // the JavaScript yaml library does: only plain scalars are resolved, and an | ||
| 198 | // explicit !!tag overrides the guess. | 310 | // explicit !!tag overrides the guess. | ||
M workflow/convert_misc.go +5 -0
| @@ -106,6 +106,11 @@ func runsOnLabels(t template.Token) []string { | |||
| 106 | 106 | // statusFunctions are the functions that make a condition a status check. | |
| 107 | 107 | var statusFunctions = map[string]bool{"success": true, "failure": true, "cancelled": true, "always": true} | |
| 108 | 108 | ||
| 109 | + | // HasStatusFunction reports whether an expression calls success, failure, | |
| 110 | + | // cancelled or always anywhere in its syntax tree, which decides whether an | |
| 111 | + | // "if" condition is wrapped as "success() && (cond)". | |
| 112 | + | func HasStatusFunction(n expr.Node) bool { return hasStatusFunction(n) } | |
| 113 | + | ||
| 109 | 114 | func hasStatusFunction(n expr.Node) bool { | |
| 110 | 115 | switch n := n.(type) { | |
| 111 | 116 | case *expr.FunctionCall: | |
| @@ -106,6 +106,11 @@ func runsOnLabels(t template.Token) []string { | |||||
| 106 | // statusFunctions are the functions that make a condition a status check. | 106 | // statusFunctions are the functions that make a condition a status check. | ||
| 107 | var statusFunctions = map[string]bool{"success": true, "failure": true, "cancelled": true, "always": true} | 107 | var statusFunctions = map[string]bool{"success": true, "failure": true, "cancelled": true, "always": true} | ||
| 108 | 108 | ||||
| 109 | + | // HasStatusFunction reports whether an expression calls success, failure, | |||
| 110 | + | // cancelled or always anywhere in its syntax tree, which decides whether an | |||
| 111 | + | // "if" condition is wrapped as "success() && (cond)". | |||
| 112 | + | func HasStatusFunction(n expr.Node) bool { return hasStatusFunction(n) } | |||
| 113 | + | ||||
| 109 | func hasStatusFunction(n expr.Node) bool { | 114 | func hasStatusFunction(n expr.Node) bool { | ||
| 110 | switch n := n.(type) { | 115 | switch n := n.(type) { | ||
| 111 | case *expr.FunctionCall: | 116 | case *expr.FunctionCall: | ||
M workflow/model.go +5 -0
| @@ -152,6 +152,11 @@ type Step struct { | |||
| 152 | 152 | With *template.Mapping | |
| 153 | 153 | // Env is the step's "env" mapping, or nil. | |
| 154 | 154 | Env *template.Mapping | |
| 155 | + | // EnvExpression and WithExpression hold "env" and "with" when the whole | |
| 156 | + | // value is an expression (env: ${{ fromJSON(...) }}); Env and With are then | |
| 157 | + | // nil. Only package action fills them, for the steps of a composite action | |
| 158 | + | // (the C# runner accepts such a step there); Parse of a workflow does not. | |
| 159 | + | EnvExpression, WithExpression template.Token | |
| 155 | 160 | // ContinueOnError and TimeoutMinutes are tokens, or nil. | |
| 156 | 161 | ContinueOnError template.Token | |
| 157 | 162 | TimeoutMinutes template.Token | |
| @@ -152,6 +152,11 @@ type Step struct { | |||||
| 152 | With *template.Mapping | 152 | With *template.Mapping | ||
| 153 | // Env is the step's "env" mapping, or nil. | 153 | // Env is the step's "env" mapping, or nil. | ||
| 154 | Env *template.Mapping | 154 | Env *template.Mapping | ||
| 155 | + | // EnvExpression and WithExpression hold "env" and "with" when the whole | |||
| 156 | + | // value is an expression (env: ${{ fromJSON(...) }}); Env and With are then | |||
| 157 | + | // nil. Only package action fills them, for the steps of a composite action | |||
| 158 | + | // (the C# runner accepts such a step there); Parse of a workflow does not. | |||
| 159 | + | EnvExpression, WithExpression template.Token | |||
| 155 | // ContinueOnError and TimeoutMinutes are tokens, or nil. | 160 | // ContinueOnError and TimeoutMinutes are tokens, or nil. | ||
| 156 | ContinueOnError template.Token | 161 | ContinueOnError template.Token | ||
| 157 | TimeoutMinutes template.Token | 162 | TimeoutMinutes template.Token | ||