From 2de54902f357751fbcc27c83b5a89f20fda6364b Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:42:05 -0300 Subject: [PATCH 01/37] fix(templates): substitute template options as the reference does An unknown ${templateOption:NAME} now becomes the empty string, only a word-character NAME (with JS whitespace around it) is matched, and an option default is filled only for a "string" or "boolean" option whose default is truthy, as TS replaceTemplatedValues and fetchTemplate do. An Object.prototype name is the one documented divergence. Co-Authored-By: Claude Opus 5.5 --- docs/DIVERGENCES.md | 6 + internal/config/varsub.go | 14 +- internal/config/varsub_held_test.go | 14 -- internal/config/varsub_test.go | 13 -- internal/templates/apply.go | 83 ++++++-- internal/templates/apply_pure_test.go | 39 +++- internal/templates/apply_test.go | 41 ---- .../testdata/ts-oracle/templates.json | 191 ++++++++++++++++++ internal/templates/ts_oracle_test.go | 23 +++ tools/tsoracle/build.mjs | 1 + tools/tsoracle/oracle.ts | 7 +- 11 files changed, 328 insertions(+), 104 deletions(-) create mode 100644 internal/templates/testdata/ts-oracle/templates.json create mode 100644 internal/templates/ts_oracle_test.go diff --git a/docs/DIVERGENCES.md b/docs/DIVERGENCES.md index a2235e6..37baa4f 100644 --- a/docs/DIVERGENCES.md +++ b/docs/DIVERGENCES.md @@ -72,6 +72,12 @@ touches a compared surface, reflected in the parity matrix. reads only the first pair and takes `B`. Variable expansion, stage lookup and the final stage name otherwise follow the reference (recorded in `internal/{docker,cli}/testdata/ts-oracle/dockerfile.json`). +- **An unknown `${templateOption:NAME}` is always replaced with the empty string.** TS + `replaceTemplatedValues` reads `options[NAME]` from a plain object, so a name that is not + an option but an `Object.prototype` property (`constructor`, `toString`, …) becomes the + inherited function's source text (`function Object() { [native code] }`) in the applied + template. This CLI treats such a name like every other unknown option + (`internal/templates/testdata/ts-oracle/templates.json`). - **The `devcontainer.metadata` label writes object values with their keys sorted.** The label is TS's `JSON.stringify` of the entries: each entry's properties in TS's pick order (`pickFeatureProperties` for a Feature, `pickConfigProperties` for the diff --git a/internal/config/varsub.go b/internal/config/varsub.go index cc6c002..4368fdc 100644 --- a/internal/config/varsub.go +++ b/internal/config/varsub.go @@ -19,15 +19,13 @@ const ( PhaseHost SubstitutionPhase = iota PhaseIdentity PhaseContainer - PhaseTemplate ) type SubstitutionContext struct { HostSubContext - IDLabels map[string]string - DevContainerID string - ContainerEnv map[string]string - TemplateOptions map[string]string + IDLabels map[string]string + DevContainerID string + ContainerEnv map[string]string } // VariableResolver applies devcontainer substitutions one explicit phase at a @@ -179,12 +177,6 @@ func (r *VariableResolver) resolveTag(ctx SubstitutionContext, phase Substitutio return "", false, nil } return resolveEnvTag(ctx.Platform == "win32", ctx.ContainerEnv, args, tag, ctx.ConfigFilePath) - case PhaseTemplate: - if variable != "templateOption" || len(args) != 1 { - return "", false, nil - } - value, ok := ctx.TemplateOptions[strings.TrimSpace(args[0])] - return value, ok, nil default: return "", false, fmt.Errorf("unknown substitution phase %d", phase) } diff --git a/internal/config/varsub_held_test.go b/internal/config/varsub_held_test.go index 5ac7580..b5cb8e9 100644 --- a/internal/config/varsub_held_test.go +++ b/internal/config/varsub_held_test.go @@ -35,17 +35,3 @@ func TestResolveContainerEnvIgnoresCaseOnlyOnWindows(t *testing.T) { } } } - -// Only ${templateOption:NAME} with a single name is a template option: TS -// replaceTemplatedValues matches /\${templateOption:\s*(\w+?)\s*}/g, which -// neither a second argument nor another variable fits, so both stay as written. -func TestResolveTemplateOptionTakesOneName(t *testing.T) { - ctx := SubstitutionContext{TemplateOptions: map[string]string{"a": "x"}} - got, err := NewVariableResolver().Resolve(ctx, PhaseTemplate, "${templateOption:a}|${templateOption:a:b}|${other:a}") - if err != nil { - t.Fatal(err) - } - if want := "x|${templateOption:a:b}|${other:a}"; got != want { - t.Errorf("Resolve = %q, want %q", got, want) - } -} diff --git a/internal/config/varsub_test.go b/internal/config/varsub_test.go index fb3eafb..be5e985 100644 --- a/internal/config/varsub_test.go +++ b/internal/config/varsub_test.go @@ -75,19 +75,6 @@ func TestVariableResolverIdentityStrings(t *testing.T) { } } -func TestSubstituteTemplateOptions(t *testing.T) { - options := map[string]string{"image": "ubuntu", "greeting": "hello"} - value := "${templateOption:image}:${templateOption: greeting }:${unknown}" - want := "ubuntu:hello:${unknown}" - got, err := NewVariableResolver().Resolve(SubstitutionContext{TemplateOptions: options}, PhaseTemplate, value) - if err != nil { - t.Fatal(err) - } - if got != want { - t.Errorf("template Resolve() = %q, want %q", got, want) - } -} - func TestSubstituteTags(t *testing.T) { upper := func(tag string) (string, bool, error) { return strings.ToUpper(tag), true, nil } diff --git a/internal/templates/apply.go b/internal/templates/apply.go index dd28273..add1141 100644 --- a/internal/templates/apply.go +++ b/internal/templates/apply.go @@ -7,10 +7,10 @@ import ( "maps" "os" "path/filepath" + "regexp" "strconv" "strings" - "github.com/devcontainers/cli/internal/config" "github.com/devcontainers/cli/internal/jsonc" "github.com/devcontainers/cli/internal/log" "github.com/devcontainers/cli/internal/oci" @@ -261,8 +261,9 @@ func escapeJSONPointer(s string) string { // applyOptionDefaults returns a copy of userOptions with any option declared in // the template's devcontainer-template.json filled in from its `default` when -// the user did not provide a value (matching the TS CLI). string and boolean -// option defaults are supported. +// the user did not provide a value, as TS fetchTemplate does +// (containerTemplatesOCI.ts): only an option of type "string" or "boolean" +// whose default is truthy gets one, as the default's JS string. func applyOptionDefaults(fsys pfs.FS, extractDir string, userOptions map[string]string, logger log.Logger) map[string]string { merged := make(map[string]string, len(userOptions)) maps.Copy(merged, userOptions) @@ -280,34 +281,72 @@ func applyOptionDefaults(fsys pfs.FS, extractDir string, userOptions map[string] if _, ok := merged[key]; ok { continue } - def, ok := raw.(map[string]any) - if !ok { + def, _ := raw.(map[string]any) + if t := def["type"]; t != "string" && t != "boolean" { continue } - switch d := def["default"].(type) { - case string: - merged[key] = d - case bool: - merged[key] = strconv.FormatBool(d) - default: + d := def["default"] + if !jsTruthy(d) { continue } + merged[key] = jsString(d) logger.Write(fmt.Sprintf("Using default value for %s --> %s", key, merged[key]), log.LevelTrace) } return merged } -// substituteTemplateOptions replaces ${templateOption:name} with the provided values. -func substituteTemplateOptions(content string, options map[string]string) string { - resolved, err := config.NewVariableResolver().Resolve(config.SubstitutionContext{ - TemplateOptions: options, - }, config.PhaseTemplate, content) - if err != nil { - return content +// jsTruthy is JavaScript truthiness for a decoded JSON value. +func jsTruthy(v any) bool { + switch x := v.(type) { + case nil: + return false + case bool: + return x + case string: + return x != "" + case float64: + return x != 0 } - result, ok := resolved.(string) - if !ok { - return content + return true +} + +// jsString is JavaScript's String(v) for a decoded JSON value. +func jsString(v any) string { + switch x := v.(type) { + case nil: + return "null" + case string: + return x + case bool: + return strconv.FormatBool(x) + case float64: + // encoding/json writes a finite float64 as JS does; JSON has no other. + data, _ := json.Marshal(x) + return string(data) + case []any: + parts := make([]string, len(x)) + for i, e := range x { + if e != nil { + parts[i] = jsString(e) + } + } + return strings.Join(parts, ",") } - return result + return "[object Object]" +} + +// jsSpace is JavaScript's \s, which is wider than RE2's. +const jsSpace = `[\t\n\v\f\r \x{a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]` + +// templateOptionPattern is TS replaceTemplatedValues' /\${templateOption:\s*(\w+?)\s*}/g. +var templateOptionPattern = regexp.MustCompile(`\$\{templateOption:` + jsSpace + `*(\w+?)` + jsSpace + `*\}`) + +// substituteTemplateOptions is TS replaceTemplatedValues: every +// ${templateOption:NAME} becomes the option's value, the empty string for an option without +// one. An option named like an Object.prototype property is no exception here +// (docs/DIVERGENCES.md). +func substituteTemplateOptions(content string, options map[string]string) string { + return templateOptionPattern.ReplaceAllStringFunc(content, func(match string) string { + return options[templateOptionPattern.FindStringSubmatch(match)[1]] + }) } diff --git a/internal/templates/apply_pure_test.go b/internal/templates/apply_pure_test.go index a9f4ef5..a0f880a 100644 --- a/internal/templates/apply_pure_test.go +++ b/internal/templates/apply_pure_test.go @@ -1,6 +1,7 @@ package templates import ( + "maps" "os" "path/filepath" "testing" @@ -26,7 +27,7 @@ func TestEscapeJSONPointer(t *testing.T) { func TestSubstituteTemplateOptions_Pure(t *testing.T) { opts := map[string]string{"imageVariant": "bookworm", "nodeVersion": "20"} in := `FROM base:${templateOption:imageVariant}\nNODE=${templateOption:nodeVersion} keep=${templateOption:missing}` - want := `FROM base:bookworm\nNODE=20 keep=${templateOption:missing}` + want := `FROM base:bookworm\nNODE=20 keep=` if got := substituteTemplateOptions(in, opts); got != want { t.Errorf("got %q, want %q", got, want) } @@ -60,6 +61,42 @@ func TestApplyOptionDefaults(t *testing.T) { } } +// TS fetchTemplate fills a default only for an option of type "string" or +// "boolean" whose default is truthy, as the default's JS string +// (containerTemplatesOCI.ts, "Auto-replace default values"). +func TestApplyOptionDefaultsTakesTruthyDefaultsOfStringAndBooleanOptions(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "devcontainer-template.json"), []byte(`{ + "id": "x", + "options": { + "falseDefault": { "type": "boolean", "default": false }, + "emptyDefault": { "type": "string", "default": "" }, + "zeroDefault": { "type": "string", "default": 0 }, + "untyped": { "default": "v" }, + "otherType": { "type": "number", "default": 3 }, + "notAnObject": "v", + "numberString": { "type": "string", "default": 1.5 }, + "stringBool": { "type": "boolean", "default": "yes" }, + "arrayBool": { "type": "boolean", "default": [1, null, [2, true]] }, + "objectString": { "type": "string", "default": {} }, + "bigNumber": { "type": "string", "default": 1e21 } + } + }`), 0o644); err != nil { + t.Fatal(err) + } + got := applyOptionDefaults(pfs.OSFS{}, dir, nil, log.Null) + want := map[string]string{ + "numberString": "1.5", + "stringBool": "yes", + "arrayBool": "1,,2,true", + "objectString": "[object Object]", + "bigNumber": "1e+21", + } + if !maps.Equal(got, want) { + t.Errorf("applyOptionDefaults = %v, want %v", got, want) + } +} + func TestApplyOptionDefaults_JSONCMetadata(t *testing.T) { dir := t.TempDir() metadata := []byte(`{ diff --git a/internal/templates/apply_test.go b/internal/templates/apply_test.go index ab6ff99..c34f7d0 100644 --- a/internal/templates/apply_test.go +++ b/internal/templates/apply_test.go @@ -4,47 +4,6 @@ import ( "testing" ) -func TestSubstituteTemplateOptions(t *testing.T) { - tests := []struct { - content string - options map[string]string - want string - }{ - { - content: `{"image": "${templateOption:baseImage}"}`, - options: map[string]string{"baseImage": "ubuntu:22.04"}, - want: `{"image": "ubuntu:22.04"}`, - }, - { - content: "FROM ${templateOption:baseImage}\nRUN echo ${templateOption:greeting}", - options: map[string]string{"baseImage": "alpine", "greeting": "hello"}, - want: "FROM alpine\nRUN echo hello", - }, - { - content: "no options here", - options: map[string]string{}, - want: "no options here", - }, - { - content: "${templateOption:missing}", - options: map[string]string{}, - want: "${templateOption:missing}", // unresolved passthrough - }, - { - content: "${templateOption: spacedName }", - options: map[string]string{"spacedName": "value"}, - want: "value", - }, - } - - for _, tt := range tests { - got := substituteTemplateOptions(tt.content, tt.options) - if got != tt.want { - t.Errorf("substituteTemplateOptions(%q, %v) = %q, want %q", tt.content, tt.options, got, tt.want) - } - } -} - func TestSelectedTemplate_OmitPaths(t *testing.T) { // Verify omit path parsing selected := SelectedTemplate{ diff --git a/internal/templates/testdata/ts-oracle/templates.json b/internal/templates/testdata/ts-oracle/templates.json new file mode 100644 index 0000000..eb51e36 --- /dev/null +++ b/internal/templates/testdata/ts-oracle/templates.json @@ -0,0 +1,191 @@ +{ + "comment": "containerTemplatesOCI.ts replaceTemplatedValues (module-private, exported for the oracle by build.mjs); ported in apply.go (substituteTemplateOptions).", + "cases": [ + { + "name": "known option", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption:baseImage}", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "ubuntu:22.04" + }, + { + "name": "unknown option is empty", + "fn": "replaceTemplatedValues", + "args": [ + "keep=${templateOption:missing}|", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "keep=|" + }, + { + "name": "empty value is empty", + "fn": "replaceTemplatedValues", + "args": [ + "[${templateOption:empty}]", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "[]" + }, + { + "name": "whitespace around the name", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption: greeting }|${templateOption:\tgreeting\n}", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "hello|hello" + }, + { + "name": "unicode whitespace around the name", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption: greeting }|${templateOption:\u000bgreeting}", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "hello|hello" + }, + { + "name": "word characters only", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption:a_1}|${templateOption:a-1}|${templateOption:a.b}|${templateOption:a:b}", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "x|${templateOption:a-1}|${templateOption:a.b}|${templateOption:a:b}" + }, + { + "name": "no name", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption:}|${templateOption: }", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "${templateOption:}|${templateOption: }" + }, + { + "name": "non-ASCII name is no word", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption:café}", + { + "café": "x" + } + ], + "result": "${templateOption:café}" + }, + { + "name": "unterminated and nested", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption:greeting|${templateOption:${templateOption:greeting}}", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "${templateOption:greeting|${templateOption:hello}" + }, + { + "name": "other variables stay", + "fn": "replaceTemplatedValues", + "args": [ + "${localEnv:HOME}|${templateOption}|$templateOption:greeting", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "${localEnv:HOME}|${templateOption}|$templateOption:greeting" + }, + { + "name": "repeated", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption:greeting}${templateOption:greeting}", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "hellohello" + }, + { + "name": "case sensitive", + "fn": "replaceTemplatedValues", + "args": [ + "${TemplateOption:greeting}|${templateOption:Greeting}", + { + "baseImage": "ubuntu:22.04", + "greeting": "hello", + "empty": "", + "a_1": "x" + } + ], + "result": "${TemplateOption:greeting}|" + }, + { + "name": "replacement is literal", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption:dollar}", + { + "dollar": "$1$&$$" + } + ], + "result": "$1$&$$" + }, + { + "name": "inherited Object property", + "fn": "replaceTemplatedValues", + "args": [ + "${templateOption:constructor}|${templateOption:toString}", + {} + ], + "divergence": "docs/DIVERGENCES.md: an unknown ${templateOption:NAME} that names an Object.prototype property becomes ''; the reference looks it up on a plain object and writes the inherited function's source", + "result": "function Object() { [native code] }|function toString() { [native code] }" + } + ] +} diff --git a/internal/templates/ts_oracle_test.go b/internal/templates/ts_oracle_test.go new file mode 100644 index 0000000..3b48e00 --- /dev/null +++ b/internal/templates/ts_oracle_test.go @@ -0,0 +1,23 @@ +package templates + +import ( + "encoding/json" + "testing" + + "github.com/devcontainers/cli/internal/tsoracle/oracletest" +) + +// TestTemplateOptionsAgainstTSOracle holds substituteTemplateOptions to TS +// replaceTemplatedValues over testdata/ts-oracle/templates.json. +func TestTemplateOptionsAgainstTSOracle(t *testing.T) { + oracletest.Run(t, "testdata/ts-oracle/templates.json", map[string]oracletest.Func{ + "replaceTemplatedValues": func(args []json.RawMessage) (any, error) { + var template string + var options map[string]string + if err := oracletest.Args(args, &template, &options); err != nil { + return nil, err + } + return substituteTemplateOptions(template, options), nil + }, + }) +} diff --git a/tools/tsoracle/build.mjs b/tools/tsoracle/build.mjs index 4fb813a..81004e5 100644 --- a/tools/tsoracle/build.mjs +++ b/tools/tsoracle/build.mjs @@ -19,6 +19,7 @@ const esbuild = createRequire(path.join(refDir, 'package.json'))('esbuild'); const privateExports = { 'src/spec-configuration/containerFeaturesConfiguration.ts': ['escapeQuotesForShell'], + 'src/spec-configuration/containerTemplatesOCI.ts': ['replaceTemplatedValues'], 'src/spec-node/dockerCompose.ts': ['convertMountToVolume', 'convertMountToVolumeTopLevelElement'], }; diff --git a/tools/tsoracle/oracle.ts b/tools/tsoracle/oracle.ts index bc2d733..42bae29 100644 --- a/tools/tsoracle/oracle.ts +++ b/tools/tsoracle/oracle.ts @@ -15,11 +15,12 @@ import { generateContainerEnvs, getFeatureValueObject, parseMount, escapeQuotesF import { getSafeId } from '../../reference/src/spec-node/containerFeatures'; import { getFeatureIdWithoutVersion } from '../../reference/src/spec-configuration/containerFeaturesOCI'; import { getRef, getCollectionRef } from '../../reference/src/spec-configuration/containerCollectionsOCI'; +import { replaceTemplatedValues } from '../../reference/src/spec-configuration/containerTemplatesOCI'; import { readVersionPrefix, convertMountToVolume, convertMountToVolumeTopLevelElement } from '../../reference/src/spec-node/dockerCompose'; import { qualifyImageName, isBuildxCacheToInline } from '../../reference/src/spec-node/utils'; -// escapeQuotesForShell, convertMountToVolume and convertMountToVolumeTopLevelElement -// are module-private in the reference; build.mjs exports them for this bundle. +// escapeQuotesForShell, convertMountToVolume, convertMountToVolumeTopLevelElement +// and replaceTemplatedValues are module-private in the reference; build.mjs exports them for this bundle. const nullOutput: any = { write() { }, raw() { }, start() { return 0; }, stop() { }, event() { } }; @@ -66,6 +67,8 @@ const fns: Record any> = { // spec-configuration/containerCollectionsOCI.ts getRef: (s: string) => getRef(nullOutput, s), getCollectionRef: (registry: string, namespace: string) => getCollectionRef(nullOutput, registry, namespace), + // spec-configuration/containerTemplatesOCI.ts + replaceTemplatedValues: (template: string, options: any) => replaceTemplatedValues(nullOutput, template, options), // spec-node/dockerCompose.ts: readVersionPrefix reads the compose files' contents given here. readVersionPrefix: async (contents: string[]) => { const host: any = { readFile: async (p: string) => Buffer.from(contents[Number(p)], 'utf8') }; From d4440bf3f9e3009371ca3b87e00b35a8ce2947e5 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:44:42 -0300 Subject: [PATCH 02/37] fix(config): load wrong-typed legacy and customizations properties A falsy "extensions", a "devPort" that customizations.vscode already sets, or a "customizations" that is no object failed the typed decode (exit 1, "cannot unmarshal"). TS readDevContainerConfigFile validates no property type, so the configuration now loads and reports them as written. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 11 ++++++ internal/config/loader_object_held_test.go | 38 +++++++++++++++++++ internal/config/types.go | 26 +++++++++++-- .../.devcontainer/devcontainer.json | 12 ++++++ 4 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/test/configs/legacy-wrong-types/.devcontainer/devcontainer.json diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index d55d6da..d06a939 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -880,6 +880,17 @@ initial_cases: current_status: match notes: "The container is created without a remoteUser and the config then names foo. exec probes the user environment as the merged configuration's remoteUser (devContainersSpecCLI.ts exec: getImageMetadataFromContainer adds the config's remoteUser, createContainerProperties(..., mergedConfig.remoteUser)), so the probed HOME and PATH are foo's. Go probed as the container's metadata user (root here) and only ran the command as foo, passing root's HOME=/root and PATH." + - id: read-configuration.legacy-wrong-types + lane: semantic + command: read-configuration + priority: p2 + docker_required: false + ts_cmd: "read-configuration --workspace-folder src/test/configs/legacy-wrong-types" + asserts: [exit_code, stdout_normalized] + class: config-parsing + current_status: match + notes: "TS validates no property type: a falsy \"extensions\" and a \"devPort\" that customizations.vscode already sets stay in the configuration as written. Go failed the typed decode (exit 1, 'cannot unmarshal')." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/config/loader_object_held_test.go b/internal/config/loader_object_held_test.go index ddeb22a..a982d1b 100644 --- a/internal/config/loader_object_held_test.go +++ b/internal/config/loader_object_held_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "reflect" "testing" ) @@ -40,3 +41,40 @@ func TestLoadRejectsAConfigThatIsNotAnObject(t *testing.T) { t.Errorf("a null override: err = %v, want a NotObjectError naming the --config file", err) } } + +// TS readDevContainerConfigFile validates no property type: a falsy legacy +// "extensions", a "devPort" that customizations.vscode already sets (both left +// in place by updateFromOldProperties) and a "customizations" that is no object +// load, and the raw configuration keeps them as written. +func TestLoadAcceptsWrongTypedLegacyAndCustomizationsProperties(t *testing.T) { + for _, tc := range []struct { + content, key string + want any + wantCustomizations bool + }{ + {`{"image": "a", "extensions": ""}`, "extensions", "", false}, + {`{"image": "a", "extensions": 0}`, "extensions", 0.0, false}, + {`{"image": "a", "extensions": false}`, "extensions", false, false}, + {`{"image": "a", "devPort": "x", "customizations": {"vscode": {"devPort": 1}}}`, "devPort", "x", true}, + {`{"image": "a", "customizations": ""}`, "customizations", "", false}, + {`{"image": "a", "customizations": "x"}`, "customizations", "x", false}, + {`{"image": "a", "customizations": [1]}`, "customizations", []any{1.0}, false}, + } { + ws := t.TempDir() + cfg := filepath.Join(ws, ".devcontainer.json") + if err := os.WriteFile(cfg, []byte(tc.content), 0o644); err != nil { + t.Fatal(err) + } + res, err := LoadDevContainerConfig(ws, cfg, "") + if err != nil { + t.Errorf("%s: %v", tc.content, err) + continue + } + if got := res.Raw[tc.key]; !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s: raw %s = %#v, want %#v", tc.content, tc.key, got, tc.want) + } + if got := res.Config.Customizations != nil; got != tc.wantCustomizations { + t.Errorf("%s: typed customizations = %v, want present %v", tc.content, res.Config.Customizations, tc.wantCustomizations) + } + } +} diff --git a/internal/config/types.go b/internal/config/types.go index adcfdb3..21f2884 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -44,7 +44,7 @@ type DevContainer struct { Features map[string]any `json:"features,omitempty"` OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"` HostRequirements *HostRequirements `json:"hostRequirements,omitempty"` - Customizations map[string]any `json:"customizations,omitempty"` + Customizations Customizations `json:"customizations,omitempty"` // Secrets is the spec's "recommended secrets" metadata (env-var key -> // {description, documentationUrl}). The CLI does not act on it; kept as an // open map so it round-trips losslessly through read-configuration/metadata. @@ -64,9 +64,27 @@ type DevContainer struct { RunServices []string `json:"runServices,omitempty"` // --- Deprecated / VS Code specific (migrated by UpdateFromOldProperties) --- - Extensions []string `json:"extensions,omitempty"` - Settings any `json:"settings,omitempty"` - DevPort *int `json:"devPort,omitempty"` + // Untyped: TS neither validates nor reads what updateFromOldProperties + // leaves behind (a falsy "extensions", a "devPort" vscode already has). + Extensions any `json:"extensions,omitempty"` + Settings any `json:"settings,omitempty"` + DevPort any `json:"devPort,omitempty"` +} + +// Customizations is the "customizations" object of a configuration. TS +// neither validates it nor fails on another JSON value; such a value is no +// customizations here, and the raw configuration still carries it as written. +type Customizations map[string]any + +// UnmarshalJSON decodes an object; any other JSON value decodes to nil. +func (c *Customizations) UnmarshalJSON(data []byte) error { + var v any + if err := json.Unmarshal(data, &v); err != nil { + return err + } + m, _ := v.(map[string]any) + *c = m + return nil } // Build represents the "build" property in devcontainer.json. diff --git a/src/test/configs/legacy-wrong-types/.devcontainer/devcontainer.json b/src/test/configs/legacy-wrong-types/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1ba67e0 --- /dev/null +++ b/src/test/configs/legacy-wrong-types/.devcontainer/devcontainer.json @@ -0,0 +1,12 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:latest", + // Left in place by updateFromOldProperties: a falsy "extensions" and a + // "devPort" that customizations.vscode already sets. + "extensions": "", + "devPort": "x", + "customizations": { + "vscode": { + "devPort": 1 + } + } +} From a58b59eec71e0e52037e19481c0d332e5571073d Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:46:25 -0300 Subject: [PATCH 03/37] fix(imagemeta): merge forwardPorts as the reference does TS mergeForwardPorts tests /localhost:\d+/ anywhere in a port string and takes parseInt of its text from the tenth character on, so "localhost:3000abc" is 3000, "localhost:0x10" is 16 and "mylocalhost:1" is null. Numbers keep their fraction when they are de-duplicated, and booleans, null and objects are kept as they are instead of being turned into Go-formatted strings. Co-Authored-By: Claude Opus 5.5 --- internal/imagemeta/merge.go | 118 ++++++--- .../testdata/ts-oracle/imagemeta.json | 231 ++++++++++++++++++ 2 files changed, 314 insertions(+), 35 deletions(-) diff --git a/internal/imagemeta/merge.go b/internal/imagemeta/merge.go index 3d5a6d1..5506a40 100644 --- a/internal/imagemeta/merge.go +++ b/internal/imagemeta/merge.go @@ -3,12 +3,14 @@ package imagemeta import ( "cmp" "encoding/json" - "fmt" "maps" "math" + "math/big" + "regexp" "slices" "strconv" "strings" + "unicode/utf16" ) // MergedConfig holds the result of merging devcontainer.json config with @@ -214,49 +216,95 @@ func dedupeMountsByTarget(mounts []any) []any { return out } -// forwardPortKey returns the de-dup key of a forwarded port: "localhost:N" for -// a numeric port N (a numeric string is NOT treated as a number by TS — only -// actual JSON numbers are), the port itself as a string otherwise. -func forwardPortKey(p any) string { - switch v := p.(type) { - case float64: - return fmt.Sprintf("localhost:%d", int(v)) - case int: - return fmt.Sprintf("localhost:%d", v) - case int64: - return fmt.Sprintf("localhost:%d", v) - } - return fmt.Sprintf("%v", p) -} - +// dedupeForwardPorts is TS mergeForwardPorts over the concatenated +// forwardPorts: a number N becomes "localhost:N", the values are de-duplicated +// as a JS Set does (strings, booleans and null by value, objects never), and a +// string /localhost:\d+/ finds anywhere becomes parseInt of its text from the +// tenth character on (null where that is NaN). Every other value stays as it is. func dedupeForwardPorts(ports []any) []any { - seen := make(map[string]bool, len(ports)) - var keys []string + seen := make(map[any]bool, len(ports)) + var out []any // nil without ports, as the merge leaves ForwardPorts then for _, p := range ports { - key := forwardPortKey(p) - if !seen[key] { - seen[key] = true - keys = append(keys, key) + port := normalizeForwardPort(p) + switch port.(type) { + case string, bool, nil: + if seen[port] { + continue + } + seen[port] = true } - } - // Denormalize: a "localhost:N" key becomes the number N; anything else stays a - // string — matching TS's final .map(port => /localhost:\d+/.test ? parseInt : port). - var out []any // nil without ports, as the merge leaves ForwardPorts then - for _, k := range keys { - out = append(out, denormalizePort(k)) + out = append(out, denormalizePort(port)) } return out } -// denormalizePort turns a "localhost:" key back into the port number -// and returns any other key as the string it is. -func denormalizePort(key string) any { - if rest, ok := strings.CutPrefix(key, "localhost:"); ok && allDigits(rest) { - if n, err := strconv.Atoi(rest); err == nil { - return float64(n) +// normalizeForwardPort is TS `typeof port === 'number' ? `localhost:${port}` : port`. +func normalizeForwardPort(p any) any { + switch v := p.(type) { + case float64: + if v == 0 { + return "localhost:0" // `${-0}` is "0" } + return "localhost:" + jsNumberString(v) + case int: + return "localhost:" + strconv.Itoa(v) + case int64: + return "localhost:" + strconv.FormatInt(v, 10) } - return key + return p +} + +// localhostPort is TS mergeForwardPorts' /localhost:\d+/, unanchored. +var localhostPort = regexp.MustCompile(`localhost:[0-9]`) + +// denormalizePort is TS `/localhost:\d+/.test(port) ? parseInt(port.substring('localhost:'.length)) : port` +// for a normalized port: the substring counts UTF-16 code units from the start +// of the string, wherever the match is. +func denormalizePort(port any) any { + s, ok := port.(string) + if !ok || !localhostPort.MatchString(s) { + return port + } + units := utf16.Encode([]rune(s)) + n, ok := jsParseInt(string(utf16.Decode(units[len("localhost:"):]))) + if !ok { + return nil // NaN, which JSON.stringify writes as null + } + return n +} + +// jsSpaceChars are the characters JS parseInt skips before a number. +const jsSpaceChars = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" + +// jsParseInt is JS parseInt(s) without a radix: leading white space, a sign, +// then hexadecimal digits after 0x or 0X, else decimal digits, up to the +// first other character. ok is false for NaN; a zero is +0, which is how +// JSON.stringify writes -0. +func jsParseInt(s string) (float64, bool) { + s = strings.TrimLeft(s, jsSpaceChars) + neg := false + if s != "" && (s[0] == '-' || s[0] == '+') { + neg = s[0] == '-' + s = s[1:] + } + base, digits := 10, "0123456789" + if len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') { + base, digits = 16, "0123456789abcdefABCDEF" + s = s[2:] + } + end := 0 + for end < len(s) && strings.IndexByte(digits, s[end]) >= 0 { + end++ + } + if end == 0 { + return 0, false + } + n, _ := new(big.Int).SetString(s[:end], base) + f, _ := new(big.Float).SetInt(n).Float64() + if neg && f != 0 { + f = -f + } + return f, true } func allDigits(s string) bool { diff --git a/internal/imagemeta/testdata/ts-oracle/imagemeta.json b/internal/imagemeta/testdata/ts-oracle/imagemeta.json index 4a58286..6c09f80 100644 --- a/internal/imagemeta/testdata/ts-oracle/imagemeta.json +++ b/internal/imagemeta/testdata/ts-oracle/imagemeta.json @@ -765,6 +765,237 @@ ] } }, + { + "name": "forwardPorts localhost: then digits anywhere", + "fn": "mergeConfiguration", + "args": [ + {}, + [ + { + "forwardPorts": [ + "localhost:3000abc", + "localhost:12 ", + "localhost:0x10", + "localhost:1.5e3", + "localhost:99999999999999999999" + ] + } + ] + ], + "result": { + "init": false, + "privileged": false, + "onCreateCommands": [], + "updateContentCommands": [], + "postCreateCommands": [], + "postStartCommands": [], + "postAttachCommands": [], + "remoteEnv": {}, + "containerEnv": {}, + "portsAttributes": {}, + "forwardPorts": [ + 3000, + 12, + 16, + 1, + 100000000000000000000 + ] + } + }, + { + "name": "forwardPorts localhost: not at the start", + "fn": "mergeConfiguration", + "args": [ + {}, + [ + { + "forwardPorts": [ + "mylocalhost:3000", + "xlocalhost:3000", + "a localhost:5", + "127.0.0.1:localhost:80" + ] + } + ] + ], + "result": { + "init": false, + "privileged": false, + "onCreateCommands": [], + "updateContentCommands": [], + "postCreateCommands": [], + "postStartCommands": [], + "postAttachCommands": [], + "remoteEnv": {}, + "containerEnv": {}, + "portsAttributes": {}, + "forwardPorts": [ + null, + null, + null, + null + ] + } + }, + { + "name": "forwardPorts strings that are no localhost port", + "fn": "mergeConfiguration", + "args": [ + {}, + [ + { + "forwardPorts": [ + "3000", + "LOCALHOST:3000", + "localhost:-1", + "localhost:", + "localhost: 12" + ] + } + ] + ], + "result": { + "init": false, + "privileged": false, + "onCreateCommands": [], + "updateContentCommands": [], + "postCreateCommands": [], + "postStartCommands": [], + "postAttachCommands": [], + "remoteEnv": {}, + "containerEnv": {}, + "portsAttributes": {}, + "forwardPorts": [ + "3000", + "LOCALHOST:3000", + "localhost:-1", + "localhost:", + "localhost: 12" + ] + } + }, + { + "name": "forwardPorts non-integer and large numbers", + "fn": "mergeConfiguration", + "args": [ + {}, + [ + { + "forwardPorts": [ + 3000.5, + 3000, + 1e+21, + -5, + -0.0, + 0 + ] + } + ] + ], + "result": { + "init": false, + "privileged": false, + "onCreateCommands": [], + "updateContentCommands": [], + "postCreateCommands": [], + "postStartCommands": [], + "postAttachCommands": [], + "remoteEnv": {}, + "containerEnv": {}, + "portsAttributes": {}, + "forwardPorts": [ + 3000, + 3000, + 1, + "localhost:-5", + 0 + ] + } + }, + { + "name": "forwardPorts other JSON values", + "fn": "mergeConfiguration", + "args": [ + {}, + [ + { + "forwardPorts": [ + true, + true, + null, + null, + { + "a": 1 + }, + { + "a": 1 + }, + false + ] + } + ] + ], + "result": { + "init": false, + "privileged": false, + "onCreateCommands": [], + "updateContentCommands": [], + "postCreateCommands": [], + "postStartCommands": [], + "postAttachCommands": [], + "remoteEnv": {}, + "containerEnv": {}, + "portsAttributes": {}, + "forwardPorts": [ + true, + null, + { + "a": 1 + }, + { + "a": 1 + }, + false + ] + } + }, + { + "name": "forwardPorts number and string forms across entries", + "fn": "mergeConfiguration", + "args": [ + {}, + [ + { + "forwardPorts": [ + 3000, + "localhost:3000abc" + ] + }, + { + "forwardPorts": [ + "localhost:3000", + 3000.0 + ] + } + ] + ], + "result": { + "init": false, + "privileged": false, + "onCreateCommands": [], + "updateContentCommands": [], + "postCreateCommands": [], + "postStartCommands": [], + "postAttachCommands": [], + "remoteEnv": {}, + "containerEnv": {}, + "portsAttributes": {}, + "forwardPorts": [ + 3000, + 3000 + ] + } + }, { "name": "hostRequirements per-field max", "fn": "mergeConfiguration", From 36403399801094894ef83897a79d80c351cdbfa3 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:49:49 -0300 Subject: [PATCH 04/37] fix(features): order lockfile entries as the reference does TS generateLockfile sorts the features by localeCompare of their ids and keeps each dependsOn in the order the Feature's metadata lists it; Go sorted both by bytes, so "ghcr.io/a/b/Node" came before "ghcr.io/a/b/alpha" and a lockfile TS wrote could fail --frozen-lockfile here. The ids are now compared with the en-US collator Node uses and dependsOn keeps its metadata order. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- internal/cli/feature_install.go | 3 + internal/features/lockfile.go | 40 +- .../features/testdata/ts-oracle/lockfile.json | 363 ++++++++++++++++++ internal/features/ts_oracle_test.go | 45 +++ internal/features/types.go | 57 ++- tools/tsoracle/oracle.ts | 3 + 7 files changed, 502 insertions(+), 11 deletions(-) create mode 100644 internal/features/testdata/ts-oracle/lockfile.json diff --git a/go.mod b/go.mod index 0e5d44b..17272e5 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( go.uber.org/mock v0.6.0 golang.org/x/crypto v0.58.0 golang.org/x/net v0.61.0 + golang.org/x/text v0.43.0 gopkg.in/yaml.v3 v3.0.1 oras.land/oras-go/v2 v2.6.4 ) @@ -173,7 +174,6 @@ require ( golang.org/x/sys v0.49.0 // indirect golang.org/x/telemetry v0.0.0-20261010193011-f71dc92da45e // indirect golang.org/x/term v0.47.0 // indirect - golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect golang.org/x/vuln v1.9.0 // indirect diff --git a/internal/cli/feature_install.go b/internal/cli/feature_install.go index 50fd843..93c3ef8 100644 --- a/internal/cli/feature_install.go +++ b/internal/cli/feature_install.go @@ -191,6 +191,7 @@ func featureFromMetadata(m features.Feature, id string, opts any, cachePath stri UserOptions: extractUserOptions(opts), Options: m.Options, DependsOn: m.DependsOn, + DependsOnOrder: m.DependsOnOrder, InstallsAfter: m.InstallsAfter, LegacyIds: m.LegacyIds, ContainerEnv: m.ContainerEnv, @@ -393,6 +394,7 @@ func processInstallFeature( UserOptions: extractUserOptions(opts), Options: m.Options, DependsOn: m.DependsOn, + DependsOnOrder: m.DependsOnOrder, InstallsAfter: m.InstallsAfter, LegacyIds: m.LegacyIds, ContainerEnv: m.ContainerEnv, @@ -443,6 +445,7 @@ func processInstallFeature( if json.Unmarshal([]byte(metaJSON), &am) == nil { set.Features[0].LegacyIds = am.LegacyIds set.Features[0].DependsOn = am.DependsOn + set.Features[0].DependsOnOrder = am.DependsOnOrder set.Features[0].InstallsAfter = am.InstallsAfter set.Features[0].Options = am.Options set.Features[0].Init = am.Init diff --git a/internal/features/lockfile.go b/internal/features/lockfile.go index 45a016c..2b1c0f8 100644 --- a/internal/features/lockfile.go +++ b/internal/features/lockfile.go @@ -11,6 +11,9 @@ import ( "path/filepath" "slices" "strings" + + "golang.org/x/text/collate" + "golang.org/x/text/language" ) // Lockfile represents a devcontainer-lock.json. @@ -18,6 +21,40 @@ type Lockfile struct { Features map[string]LockfileEntry `json:"features"` } +// featureIDCollator is the collator of TS's `a.id.localeCompare(b.id)`, which +// orders a generated lockfile's features: Node's default, en-US. +var featureIDCollator = collate.New(language.AmericanEnglish) + +// MarshalJSON writes the features in the order TS generateLockfile sorts them +// in, String.prototype.localeCompare of their ids; ids that collate as equal +// keep byte order. +func (l Lockfile) MarshalJSON() ([]byte, error) { + ids := slices.SortedFunc(maps.Keys(l.Features), func(a, b string) int { + if c := featureIDCollator.CompareString(a, b); c != 0 { + return c + } + return strings.Compare(a, b) + }) + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + buf.WriteString(`{"features":{`) + for i, id := range ids { + if i > 0 { + buf.WriteByte(',') + } + if err := enc.Encode(id); err != nil { + return nil, err + } + buf.WriteByte(':') + if err := enc.Encode(l.Features[id]); err != nil { + return nil, err + } + } + buf.WriteString(`}}`) + return buf.Bytes(), nil +} + // LockfileEntry records a pinned feature version. type LockfileEntry struct { Version string `json:"version"` @@ -64,9 +101,8 @@ func GenerateLockfile(config *Config, excludeUserFeatureIDs map[string]bool) *Lo Integrity: fs.ComputedDigest, } if len(feat.DependsOn) > 0 { - e.DependsOn = slices.Sorted(maps.Keys(feat.DependsOn)) + e.DependsOn = feat.DependsOnKeys() } - // A map: the file is written with its keys sorted (json.MarshalIndent). lf.Features[src.UserFeatureID()] = e } return lf diff --git a/internal/features/testdata/ts-oracle/lockfile.json b/internal/features/testdata/ts-oracle/lockfile.json new file mode 100644 index 0000000..dd45167 --- /dev/null +++ b/internal/features/testdata/ts-oracle/lockfile.json @@ -0,0 +1,363 @@ +{ + "comment": "lockfile.ts generateLockfile, as the text writeLockfile writes (JSON.stringify(lockfile, null, 2) + newline, which keeps the key order); ported in lockfile.go (GenerateLockfile, Lockfile.MarshalJSON) and types.go (Feature.DependsOnKeys).", + "cases": [ + { + "name": "ids that differ in case", + "fn": "generateLockfileText", + "args": [ + { + "featureSets": [ + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/node:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/node" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/Node:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/Node" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/zeta:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/zeta" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/Alpha:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/Alpha" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + } + ] + } + ], + "result": "{\n \"features\": {\n \"ghcr.io/a/b/Alpha:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/Alpha@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"ghcr.io/a/b/node:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"ghcr.io/a/b/Node:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/Node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"ghcr.io/a/b/zeta:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/zeta@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n }\n }\n}\n" + }, + { + "name": "ids that differ in punctuation", + "fn": "generateLockfileText", + "args": [ + { + "featureSets": [ + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/node-js:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/node-js" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/node_js:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/node_js" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/node.js:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/node.js" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/node:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/node" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/node" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/node", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/node" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + } + ] + } + ], + "result": "{\n \"features\": {\n \"ghcr.io/a/b/node\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"ghcr.io/a/b/node_js:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/node_js@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"ghcr.io/a/b/node-js:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/node-js@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"ghcr.io/a/b/node:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"ghcr.io/a/b/node.js:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/node.js@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"ghcr.io/a/b/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/node@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n }\n }\n}\n" + }, + { + "name": "version tags and registries", + "fn": "generateLockfileText", + "args": [ + { + "featureSets": [ + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/c:10", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/c" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/c:9", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/c" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "localhost:5000/a/b", + "featureRef": { + "registry": "localhost:5000", + "path": "a/b" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "LOCALHOST:5000/a/c", + "featureRef": { + "registry": "LOCALHOST:5000", + "path": "a/c" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0" + } + ] + } + ] + } + ], + "result": "{\n \"features\": {\n \"ghcr.io/a/b/c:10\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/c@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"ghcr.io/a/b/c:9\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/c@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"localhost:5000/a/b\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"localhost:5000/a/b@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"LOCALHOST:5000/a/c\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"LOCALHOST:5000/a/c@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n }\n }\n}\n" + }, + { + "name": "tarballs", + "fn": "generateLockfileText", + "args": [ + { + "featureSets": [ + { + "sourceInformation": { + "type": "direct-tarball", + "userFeatureId": "https://example.com/b.tgz?x=1&y=<2>", + "tarballUri": "https://example.com/b.tgz?x=1&y=<2>" + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "2.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "direct-tarball", + "userFeatureId": "https://example.com/A.tgz", + "tarballUri": "https://example.com/A.tgz" + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "2.0.0" + } + ] + }, + { + "sourceInformation": { + "type": "direct-tarball", + "userFeatureId": "https://example.com/a.tgz", + "tarballUri": "https://example.com/a.tgz" + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "2.0.0" + } + ] + } + ] + } + ], + "result": "{\n \"features\": {\n \"https://example.com/a.tgz\": {\n \"version\": \"2.0.0\",\n \"resolved\": \"https://example.com/a.tgz\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"https://example.com/A.tgz\": {\n \"version\": \"2.0.0\",\n \"resolved\": \"https://example.com/A.tgz\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n },\n \"https://example.com/b.tgz?x=1&y=<2>\": {\n \"version\": \"2.0.0\",\n \"resolved\": \"https://example.com/b.tgz?x=1&y=<2>\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n }\n }\n}\n" + }, + { + "name": "dependsOn keeps its order", + "fn": "generateLockfileText", + "args": [ + { + "featureSets": [ + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/c:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/c" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0", + "dependsOn": { + "ghcr.io/z/z/z:1": {}, + "ghcr.io/a/a/a:1": { + "x": "1" + }, + "ghcr.io/M/m/m": {} + } + } + ] + }, + { + "sourceInformation": { + "type": "oci", + "userFeatureId": "ghcr.io/a/b/d:1", + "featureRef": { + "registry": "ghcr.io", + "path": "a/b/d" + } + }, + "computedDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "features": [ + { + "version": "1.0.0", + "dependsOn": {} + } + ] + } + ] + } + ], + "result": "{\n \"features\": {\n \"ghcr.io/a/b/c:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/c@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"dependsOn\": [\n \"ghcr.io/z/z/z:1\",\n \"ghcr.io/a/a/a:1\",\n \"ghcr.io/M/m/m\"\n ]\n },\n \"ghcr.io/a/b/d:1\": {\n \"version\": \"1.0.0\",\n \"resolved\": \"ghcr.io/a/b/d@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"integrity\": \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n }\n }\n}\n" + } + ] +} diff --git a/internal/features/ts_oracle_test.go b/internal/features/ts_oracle_test.go index ae17e97..03db4d8 100644 --- a/internal/features/ts_oracle_test.go +++ b/internal/features/ts_oracle_test.go @@ -2,6 +2,8 @@ package features import ( "encoding/json" + "path" + "strings" "testing" "github.com/devcontainers/cli/internal/tsoracle/oracletest" @@ -27,3 +29,46 @@ func TestTSOracleVersion(t *testing.T) { }, }) } + +// TestTSOracleLockfile holds GenerateLockfile and the lockfile text to TS +// generateLockfile + writeLockfile's JSON.stringify: the features in +// localeCompare order of their ids, each dependsOn in metadata order. +func TestTSOracleLockfile(t *testing.T) { + oracletest.Run(t, "testdata/ts-oracle/lockfile.json", map[string]oracletest.Func{ + "generateLockfileText": func(args []json.RawMessage) (any, error) { + var input struct { + FeatureSets []struct { + SourceInformation struct { + Type string `json:"type"` + UserFeatureID string `json:"userFeatureId"` + TarballURI string `json:"tarballUri"` + FeatureRef struct { + Registry string `json:"registry"` + Path string `json:"path"` + } `json:"featureRef"` + } `json:"sourceInformation"` + ComputedDigest string `json:"computedDigest"` + Features []Feature `json:"features"` + } `json:"featureSets"` + } + if err := oracletest.Args(args, &input); err != nil { + return nil, err + } + var sets []*Set + for _, fs := range input.FeatureSets { + src := fs.SourceInformation + set := &Set{Features: fs.Features, ComputedDigest: fs.ComputedDigest} + switch src.Type { + case "oci": + namespace, id := path.Split(src.FeatureRef.Path) + set.SourceInfo = &OCISource{Type: "oci", UserID: src.UserFeatureID, Registry: src.FeatureRef.Registry, Namespace: strings.TrimSuffix(namespace, "/"), ID: id} + case "direct-tarball": + set.SourceInfo = &TarballSource{TarballURI: src.TarballURI, UserID: src.UserFeatureID} + } + sets = append(sets, set) + } + data, err := stringifyJSON(GenerateLockfile(&Config{FeatureSets: sets}, nil)) + return string(data), err + }, + }) +} diff --git a/internal/features/types.go b/internal/features/types.go index 3076670..7dcbec2 100644 --- a/internal/features/types.go +++ b/internal/features/types.go @@ -1,7 +1,13 @@ // Package features resolves, orders, installs and locks dev container Features. package features -import "encoding/json" +import ( + "encoding/json" + "maps" + "slices" + + "github.com/devcontainers/cli/internal/jsonc" +) // Set represents a resolved feature with its source and metadata. type Set struct { @@ -13,13 +19,16 @@ type Set struct { // Feature represents a single feature within a Set. type Feature struct { - ID string `json:"id"` - Version string `json:"version,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - DocumentationURL string `json:"documentationURL,omitempty"` - Options map[string]any `json:"options,omitempty"` - DependsOn map[string]any `json:"dependsOn,omitempty"` + ID string `json:"id"` + Version string `json:"version,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + DocumentationURL string `json:"documentationURL,omitempty"` + Options map[string]any `json:"options,omitempty"` + DependsOn map[string]any `json:"dependsOn,omitempty"` + // DependsOnOrder is the order the metadata lists dependsOn's keys in, + // which TS keeps (Object.keys); UnmarshalJSON records it. + DependsOnOrder []string `json:"-"` InstallsAfter []string `json:"installsAfter,omitempty"` LegacyIds []string `json:"legacyIds,omitempty"` Included bool `json:"included"` @@ -42,6 +51,38 @@ type Feature struct { Customizations map[string]any `json:"customizations,omitempty"` } +// UnmarshalJSON decodes the Feature and records the order of its dependsOn +// keys in DependsOnOrder. +func (f *Feature) UnmarshalJSON(data []byte) error { + type plain Feature + if err := json.Unmarshal(data, (*plain)(f)); err != nil { + return err + } + f.DependsOnOrder = jsonc.MemberNames(data, "dependsOn") + return nil +} + +// DependsOnKeys is TS Object.keys(dependsOn) for a Feature decoded from its +// metadata: the keys of DependsOn in the order DependsOnOrder lists them (a +// repeated key where it first appears). Keys DependsOnOrder does not list, +// as for a Feature built in code, follow in sorted order. +func (f *Feature) DependsOnKeys() []string { + keys := make([]string, 0, len(f.DependsOn)) + listed := make(map[string]bool, len(f.DependsOn)) + for _, k := range f.DependsOnOrder { + if _, ok := f.DependsOn[k]; ok && !listed[k] { + listed[k] = true + keys = append(keys, k) + } + } + for _, k := range slices.Sorted(maps.Keys(f.DependsOn)) { + if !listed[k] { + keys = append(keys, k) + } + } + return keys +} + // SourceInfo describes where a feature comes from. type SourceInfo interface { SourceType() string diff --git a/tools/tsoracle/oracle.ts b/tools/tsoracle/oracle.ts index 42bae29..d4446d8 100644 --- a/tools/tsoracle/oracle.ts +++ b/tools/tsoracle/oracle.ts @@ -14,6 +14,7 @@ import { mergeConfiguration, getDevcontainerMetadataLabel } from '../../referenc import { generateContainerEnvs, getFeatureValueObject, parseMount, escapeQuotesForShell } from '../../reference/src/spec-configuration/containerFeaturesConfiguration'; import { getSafeId } from '../../reference/src/spec-node/containerFeatures'; import { getFeatureIdWithoutVersion } from '../../reference/src/spec-configuration/containerFeaturesOCI'; +import { generateLockfile } from '../../reference/src/spec-configuration/lockfile'; import { getRef, getCollectionRef } from '../../reference/src/spec-configuration/containerCollectionsOCI'; import { replaceTemplatedValues } from '../../reference/src/spec-configuration/containerTemplatesOCI'; import { readVersionPrefix, convertMountToVolume, convertMountToVolumeTopLevelElement } from '../../reference/src/spec-node/dockerCompose'; @@ -64,6 +65,8 @@ const fns: Record any> = { getSafeId: (s: string) => getSafeId(s), // spec-configuration/containerFeaturesOCI.ts getFeatureIdWithoutVersion: (s: string) => getFeatureIdWithoutVersion(s), + // spec-configuration/lockfile.ts: the text writeLockfile writes, which keeps the key order. + generateLockfileText: async (featuresConfig: any) => JSON.stringify(await generateLockfile(featuresConfig), null, 2) + '\n', // spec-configuration/containerCollectionsOCI.ts getRef: (s: string) => getRef(nullOutput, s), getCollectionRef: (registry: string, namespace: string) => getCollectionRef(nullOutput, registry, namespace), From 20505cac564fb83038fd2a6fd539ff14664399e7 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:55:29 -0300 Subject: [PATCH 05/37] fix(cli): read the set-up config as the reference does TS doSetUp reads --config with no workspace and before it looks for the container. ${localWorkspaceFolder} and its basename now stay as written, as does ${containerWorkspaceFolder} unless the config sets workspaceFolder (Go resolved them to "." and "/workspaces/."). A missing config is "Dev container config (...) not found." and any other load error is reported instead of being ignored. configuration and mergedConfiguration also keep the properties the typed config has no field for. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 24 +++ internal/cli/setup.go | 152 +++++++-------- internal/cli/setup_held_test.go | 181 ++++++++++++------ .../devcontainer.json | 17 ++ 4 files changed, 242 insertions(+), 132 deletions(-) create mode 100644 src/test/configs/set-up-with-workspace-variables/devcontainer.json diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index d06a939..ec190e4 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -891,6 +891,30 @@ initial_cases: current_status: match notes: "TS validates no property type: a falsy \"extensions\" and a \"devPort\" that customizations.vscode already sets stay in the configuration as written. Go failed the typed decode (exit 1, 'cannot unmarshal')." + - id: set-up.config-without-workspace + lane: runtime + command: set-up + priority: p1 + docker_required: true + ts_cmd: "set-up --container-id ${CONTAINER_ID} --config src/test/configs/set-up-with-workspace-variables/devcontainer.json --include-configuration --include-merged-configuration --skip-post-create" + setup_cmd: "CONTAINER_ID=$(docker run -d -e TEST_CE=TEST_VALUE alpine:3.17 sleep inf); echo CONTAINER_ID=$CONTAINER_ID" + cleanup_cmd: "docker rm -f ${CONTAINER_ID} >/dev/null 2>&1 || true" + asserts: [exit_code, stdout_normalized] + class: lifecycle-runtime + current_status: match + notes: "TS doSetUp reads --config with no workspace: ${localWorkspaceFolder}, its basename and (without workspaceFolder) ${containerWorkspaceFolder} stay as written, and properties the typed config has no field for are reported. Go resolved them against the current directory ('.', '/workspaces/.') and dropped the untyped properties." + + - id: set-up.missing-config + lane: runtime + command: set-up + priority: p1 + docker_required: true + ts_cmd: "set-up --container-id parity-no-such-container --config src/test/configs/set-up-with-workspace-variables/missing.json" + asserts: [exit_code, stdout_normalized] + class: lifecycle-runtime + current_status: match + notes: "TS reads --config before it looks for the container: 'Dev container config (...) not found.' and exit 1. Go ignored a config that did not load (and reported the missing container first)." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/setup.go b/internal/cli/setup.go index bb7a1ea..b2a3197 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -3,8 +3,9 @@ package cli import ( "cmp" "context" - "encoding/json" + "errors" "fmt" + "io/fs" "github.com/moby/moby/api/types/container" @@ -117,11 +118,30 @@ func runSetUp(ctx context.Context, out Output, opts *setUpOpts) error { } defer engine.Close() + // TS doSetUp reads the --config file before it looks for the container. + loaded, err := setUpConfig(opts.configPath) + if err != nil { + return writeErrorJSON(out, setUpErrorOutput(err)) + } + inspect, err := engine.InspectContainer(ctx, opts.containerID) if err != nil { return writeErrorResult(out, "Dev container not found.") } - return setUpContainer(ctx, out, engine, logger, opts, inspect) + return setUpContainer(ctx, out, engine, logger, opts, loaded, inspect) +} + +// setUpErrorOutput is TS doSetUp's error result: a ContainerError as it is +// (a NotObjectError is one in TS), any other error described as "An error +// occurred running user commands in the container.". +func setUpErrorOutput(err error) coreerrors.ErrorOutput { + if _, ok := errors.AsType[*config.NotObjectError](err); ok { + return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: err.Error()}) + } + if ce, ok := coreerrors.AsContainerError(err); ok { + return coreerrors.ToErrorOutput(ce) + } + return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: "An error occurred running user commands in the container.", OriginalError: err}) } // setUpContainer runs the lifecycle hooks in the container and writes the @@ -130,10 +150,14 @@ func runSetUp(ctx context.Context, out Output, opts *setUpOpts) error { // mutate-lane: TestParityMatrix - set-up.container-config-success, // set-up.container-metadata-success and set-up.after-failed-post-create-success // set up a real container through it. -func setUpContainer(ctx context.Context, out Output, engine *docker.EngineClient, logger log.Logger, opts *setUpOpts, inspect container.InspectResponse) error { +func setUpContainer(ctx context.Context, out Output, engine *docker.EngineClient, logger log.Logger, opts *setUpOpts, loaded *config.LoadResult, inspect container.InspectResponse) error { containerID := opts.containerID - cfg := setUpConfig(opts.configPath) - merged := setUpMerged(inspect.Config.Labels, cfg, logger) + var cfg *config.DevContainer + if loaded != nil { + cfg = loaded.Config + } + entries := setUpEntries(inspect.Config.Labels, cfg, logger) + merged := imagemeta.MergeConfiguration(entries) var cfgProbe string var cfgRemoteEnv map[string]*string @@ -166,99 +190,75 @@ func setUpContainer(ctx context.Context, out Output, engine *docker.EngineClient } } - result, err := setUpResult(cfg, merged, config.EnvMap(inspect.Config.Env), opts.includeConfig, opts.includeMerged) + result, err := setUpResult(loaded, entries, inspect.Config, opts.includeConfig, opts.includeMerged) if err != nil { return writeErrorResult(out, err.Error()) } return writeSuccessJSON(out, result) } -// setUpConfig is the --config file set-up merges over the container's -// metadata: nil when no --config is given or it cannot be loaded. -func setUpConfig(configPath string) *config.DevContainer { +// setUpConfig loads the --config file set-up merges over the container's +// metadata (nil without --config) as TS doSetUp reads it: with no workspace, +// so ${localWorkspaceFolder} and its basename stay as written, and so does +// ${containerWorkspaceFolder} unless the config sets workspaceFolder. A file +// that does not exist is TS's "Dev container config (...) not found.". +func setUpConfig(configPath string) (*config.LoadResult, error) { if configPath == "" { - return nil + return nil, nil } - loadResult, err := config.LoadDevContainerConfig(".", resolvePath(configPath), "") - if err != nil { - return nil + path := resolvePath(configPath) + loaded, err := config.LoadDevContainerConfig("", path, "") + if errors.Is(err, fs.ErrNotExist) { + return nil, &coreerrors.ContainerError{Description: fmt.Sprintf("Dev container config (%s) not found.", path)} } - return loadResult.Config + return loaded, err } -// setUpMerged is set-up's merged configuration: the container's metadata label -// entries, then the config (when there is one) as the last entry. -func setUpMerged(labels map[string]string, cfg *config.DevContainer, logger log.Logger) *imagemeta.MergedConfig { +// setUpEntries are the metadata entries set-up merges: the container's +// metadata label entries, then the config (when there is one) as the last +// entry. +func setUpEntries(labels map[string]string, cfg *config.DevContainer, logger log.Logger) []imagemeta.Entry { entries := imagemeta.ReadMetadataFromLabels(labels, logger) if cfg != nil { entries = append(entries, configToMetadataEntry(cfg)) } - return imagemeta.MergeConfiguration(entries) + return entries } -// setUpResult is set-up's success result: configuration -// (--include-configuration; {} without a config) and mergedConfiguration -// (--include-merged-configuration), with ${containerEnv:…} resolved from the -// container's environment and configFilePath as a file URI, as TS reports them. -func setUpResult(cfg *config.DevContainer, merged *imagemeta.MergedConfig, containerEnv map[string]string, includeConfig, includeMerged bool) (map[string]any, error) { +// setUpResult is set-up's success result as TS doSetUp reports it: +// configuration (--include-configuration) is the loaded configuration ({} +// without one) and mergedConfiguration (--include-merged-configuration) the +// merge of entries over it, both with ${containerEnv:...} resolved from the +// container's environment. configFilePath is the file URI, which TS never +// substitutes. +func setUpResult(loaded *config.LoadResult, entries []imagemeta.Entry, containerCfg *container.Config, includeConfig, includeMerged bool) (map[string]any, error) { result := map[string]any{"outcome": "success"} - + if !includeConfig && !includeMerged { + return result, nil + } + cfgMap := map[string]any{} + var configFileURI any + if loaded != nil { + cfgMap = configurationOutput(loaded, &readConfigOpts{configPath: loaded.Config.ConfigFilePath}) + configFileURI = cfgMap["configFilePath"] + delete(cfgMap, "configFilePath") + } + substituted, err := substituteContainerEnv(containerCfg, cfgMap) + if err != nil { + return nil, fmt.Errorf("resolve configuration container variables: %w", err) + } + if configFileURI != nil { + substituted["configFilePath"] = configFileURI + } if includeConfig { - if cfg != nil { - // Substitute containerEnv in config - cfgJSON, _ := json.Marshal(cfg) - var cfgGeneric any - json.Unmarshal(cfgJSON, &cfgGeneric) - substituted, subErr := resolveContainerVariables(containerEnv, cfgGeneric) - if subErr != nil { - return nil, fmt.Errorf("resolve configuration container variables: %w", subErr) - } - // Clean null values and internal fields - if subMap, ok := substituted.(map[string]any); ok { - for k, v := range subMap { - if v == nil { - delete(subMap, k) - } - } - // Convert configFilePath to URI format (matches TS) - if cfp, ok := subMap["configFilePath"].(string); ok && cfp != "" { - subMap["configFilePath"] = map[string]any{ - "$mid": 1, - "fsPath": cfp, - "path": cfp, - "scheme": "file", - } - } else { - delete(subMap, "configFilePath") - } - delete(subMap, "extensions") - delete(subMap, "settings") - delete(subMap, "devPort") - result["configuration"] = subMap - } else { - result["configuration"] = substituted - } - } else { - // No config file → empty object (TS behavior) - result["configuration"] = map[string]any{} - } + result["configuration"] = substituted } - if includeMerged { - // Copy configFilePath URI to mergedConfiguration (matches TS) - if cfgResult, ok := result["configuration"].(map[string]any); ok { - if cfpURI, ok := cfgResult["configFilePath"]; ok { - merged.ConfigFilePath = cfpURI - } - } - mergedJSON, _ := json.Marshal(merged) - var mergedGeneric any - json.Unmarshal(mergedJSON, &mergedGeneric) - substituted, subErr := resolveContainerVariables(containerEnv, mergedGeneric) - if subErr != nil { - return nil, fmt.Errorf("resolve merged container variables: %w", subErr) + merged, err := mergedConfigurationOutput(entries, containerCfg, substituted) + if err != nil { + return nil, fmt.Errorf("resolve merged container variables: %w", err) } - result["mergedConfiguration"] = substituted + result["mergedConfiguration"] = merged } return result, nil } diff --git a/internal/cli/setup_held_test.go b/internal/cli/setup_held_test.go index d102c9a..0daf5bc 100644 --- a/internal/cli/setup_held_test.go +++ b/internal/cli/setup_held_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "encoding/json" + "errors" "os" "path/filepath" "reflect" @@ -12,6 +13,7 @@ import ( "github.com/devcontainers/cli/internal/config" "github.com/devcontainers/cli/internal/imagemeta" "github.com/devcontainers/cli/internal/log" + "github.com/moby/moby/api/types/container" ) // runSetUpCmd runs `set-up` with args and returns its stdout, stderr and error. @@ -96,25 +98,76 @@ func TestSetUpReportsAnUnusableDockerHost(t *testing.T) { } } -func TestSetUpConfigLoadsTheGivenConfigOnly(t *testing.T) { +// TS doSetUp reads --config with no workspace: a file that does not exist is +// "Dev container config (...) not found.", one that does not load is an error, +// and only a config's own workspaceFolder gives ${containerWorkspaceFolder}. +func TestSetUpConfigLoadsTheGivenConfigWithoutAWorkspace(t *testing.T) { dir := t.TempDir() good := filepath.Join(dir, "devcontainer.json") - if err := os.WriteFile(good, []byte(`{"image":"alpine:3","remoteUser":"vscode"}`), 0o644); err != nil { + if err := os.WriteFile(good, []byte(`{"image":"alpine:3","remoteUser":"vscode","remoteEnv":{ + "A":"${localWorkspaceFolder}","B":"${localWorkspaceFolderBasename}", + "C":"${containerWorkspaceFolder}","D":"${containerWorkspaceFolderBasename}"}}`), 0o644); err != nil { + t.Fatal(err) + } + withFolder := filepath.Join(dir, "with-folder.json") + if err := os.WriteFile(withFolder, []byte(`{"image":"alpine:3","workspaceFolder":"/src/app","remoteEnv":{"C":"${containerWorkspaceFolder}","D":"${containerWorkspaceFolderBasename}"}}`), 0o644); err != nil { t.Fatal(err) } bad := filepath.Join(dir, "broken.json") - if err := os.WriteFile(bad, []byte(`{"image":`), 0o644); err != nil { + if err := os.WriteFile(bad, []byte(`[1]`), 0o644); err != nil { + t.Fatal(err) + } + + if loaded, err := setUpConfig(""); loaded != nil || err != nil { + t.Errorf("setUpConfig(\"\") = %+v, %v; want nil without --config", loaded, err) + } + loaded, err := setUpConfig(good) + if err != nil || loaded.Config.RemoteUser != "vscode" { + t.Fatalf("setUpConfig(%q) = %+v, %v; want the config's remoteUser", good, loaded, err) + } + want := map[string]any{"A": "${localWorkspaceFolder}", "B": "${localWorkspaceFolderBasename}", "C": "${containerWorkspaceFolder}", "D": "${containerWorkspaceFolderBasename}"} + if got := loaded.Raw["remoteEnv"]; !reflect.DeepEqual(got, want) { + t.Errorf("remoteEnv = %v, want the workspace variables as written %v", got, want) + } + loaded, err = setUpConfig(withFolder) + if err != nil { t.Fatal(err) } + if got, want := loaded.Raw["remoteEnv"], map[string]any{"C": "/src/app", "D": "app"}; !reflect.DeepEqual(got, want) { + t.Errorf("remoteEnv = %v, want the config's workspaceFolder %v", got, want) + } - if cfg := setUpConfig(""); cfg != nil { - t.Errorf("setUpConfig(\"\") = %+v, want nil without --config", cfg) + missing := filepath.Join(dir, "missing.json") + _, err = setUpConfig(missing) + if out := setUpErrorOutput(err); out.Message != "Dev container config ("+missing+") not found." || out.Description != out.Message { + t.Errorf("missing config: %+v, want TS's not found error", out) } - if cfg := setUpConfig(good); cfg == nil || cfg.RemoteUser != "vscode" { - t.Errorf("setUpConfig(%q) = %+v, want the config's remoteUser", good, cfg) + _, err = setUpConfig(bad) + if out := setUpErrorOutput(err); out.Message != "Dev container config ("+bad+") must contain a JSON object literal." || out.Description != out.Message { + t.Errorf("config that is no object: %+v, want TS's error", out) } - if cfg := setUpConfig(bad); cfg != nil { - t.Errorf("setUpConfig(%q) = %+v, want nil for a config that does not load", bad, cfg) + if out := setUpErrorOutput(errors.New("boom")); out.Message != "boom" || out.Description != "An error occurred running user commands in the container." { + t.Errorf("other error: %+v, want TS's wrapped error", out) + } +} + +// set-up reads --config before it looks for the container, as TS doSetUp +// does, so a missing config is reported even when the container is missing. +func TestSetUpReportsAMissingConfigBeforeTheContainer(t *testing.T) { + startFakeEngine(t, map[string]map[string]string{}) + missing := filepath.Join(t.TempDir(), "nope.json") + + stdout, _, err := runSetUpCmd(t, "--container-id", "missing", "--config", missing) + + if err == nil { + t.Fatal("set-up succeeded without its config") + } + var got map[string]any + if jerr := json.Unmarshal([]byte(stdout), &got); jerr != nil { + t.Fatalf("stdout is not a JSON result: %v\n%s", jerr, stdout) + } + if want := "Dev container config (" + missing + ") not found."; got["outcome"] != "error" || got["message"] != want || got["description"] != want { + t.Errorf("result = %v, want %q", got, want) } } @@ -126,10 +179,10 @@ func TestSetUpMergedPutsTheConfigOverTheContainerMetadata(t *testing.T) { t.Fatal(err) } - if got := setUpMerged(labels, nil, logger); got.RemoteUser != "node" || !reflect.DeepEqual(got.PostCreateCommands, []any{"from-image"}) { + if got := imagemeta.MergeConfiguration(setUpEntries(labels, nil, logger)); got.RemoteUser != "node" || !reflect.DeepEqual(got.PostCreateCommands, []any{"from-image"}) { t.Errorf("without a config: remoteUser %q, postCreate %v; want the label's", got.RemoteUser, got.PostCreateCommands) } - got := setUpMerged(labels, cfg, logger) + got := imagemeta.MergeConfiguration(setUpEntries(labels, cfg, logger)) if got.RemoteUser != "vscode" { t.Errorf("remoteUser = %q, want the config's", got.RemoteUser) } @@ -139,9 +192,9 @@ func TestSetUpMergedPutsTheConfigOverTheContainerMetadata(t *testing.T) { } // setUpResultJSON is setUpResult's result as set-up prints it. -func setUpResultJSON(t *testing.T, cfg *config.DevContainer, merged *imagemeta.MergedConfig, env map[string]string, includeConfig, includeMerged bool) map[string]any { +func setUpResultJSON(t *testing.T, loaded *config.LoadResult, entries []imagemeta.Entry, env []string, includeConfig, includeMerged bool) map[string]any { t.Helper() - result, err := setUpResult(cfg, merged, env, includeConfig, includeMerged) + result, err := setUpResult(loaded, entries, &container.Config{Env: env}, includeConfig, includeMerged) if err != nil { t.Fatal(err) } @@ -158,46 +211,61 @@ func setUpResultJSON(t *testing.T, cfg *config.DevContainer, merged *imagemeta.M func strPtr(s string) *string { return &s } -func TestSetUpResultReportsTheConfigurationAsTSDoes(t *testing.T) { - cfg := &config.DevContainer{ - ConfigFilePath: "/ws/.devcontainer/devcontainer.json", - Image: "alpine:3", - RemoteEnv: map[string]*string{"FROM_CONTAINER": strPtr("${containerEnv:FOO}")}, +// loadedConfig is the LoadResult of a config with the given raw properties, +// read from configFile. +func loadedConfig(t *testing.T, configFile string, raw map[string]any) *config.LoadResult { + t.Helper() + var cfg config.DevContainer + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) } - merged := imagemeta.MergeConfiguration([]imagemeta.Entry{configToMetadataEntry(cfg)}) - env := map[string]string{"FOO": "bar"} + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + cfg.ConfigFilePath = configFile + return &config.LoadResult{Config: &cfg, Raw: raw} +} - got := setUpResultJSON(t, cfg, merged, env, true, true) +func TestSetUpResultReportsTheConfigurationAsTSDoes(t *testing.T) { + loaded := loadedConfig(t, "/ws/.devcontainer/devcontainer.json", map[string]any{ + "image": "alpine:3", + "remoteEnv": map[string]any{"FROM_CONTAINER": "${containerEnv:FOO}"}, + // Properties the typed config has no field for, or leaves behind, + // are reported as written, as TS reports its configuration. + "foo": map[string]any{"b": 1.0}, + "extensions": "", + }) + entries := setUpEntries(nil, loaded.Config, log.Null) + + got := setUpResultJSON(t, loaded, entries, []string{"FOO=bar"}, true, true) - wantURI := map[string]any{"$mid": float64(1), "fsPath": cfg.ConfigFilePath, "path": cfg.ConfigFilePath, "scheme": "file"} + wantURI := map[string]any{"$mid": float64(1), "fsPath": loaded.Config.ConfigFilePath, "path": loaded.Config.ConfigFilePath, "scheme": "file"} conf, _ := got["configuration"].(map[string]any) - if !reflect.DeepEqual(conf["configFilePath"], wantURI) { - t.Errorf("configuration.configFilePath = %v, want %v", conf["configFilePath"], wantURI) - } - if re, _ := conf["remoteEnv"].(map[string]any); re["FROM_CONTAINER"] != "bar" { - t.Errorf("configuration.remoteEnv = %v, want ${containerEnv:FOO} resolved to bar", conf["remoteEnv"]) - } - for k, v := range conf { - if v == nil { - t.Errorf("configuration.%s is null; TS leaves unset properties out", k) - } - } mc, _ := got["mergedConfiguration"].(map[string]any) - if !reflect.DeepEqual(mc["configFilePath"], wantURI) { - t.Errorf("mergedConfiguration.configFilePath = %v, want %v", mc["configFilePath"], wantURI) - } - if re, _ := mc["remoteEnv"].(map[string]any); re["FROM_CONTAINER"] != "bar" { - t.Errorf("mergedConfiguration.remoteEnv = %v, want ${containerEnv:FOO} resolved to bar", mc["remoteEnv"]) + for name, m := range map[string]map[string]any{"configuration": conf, "mergedConfiguration": mc} { + if !reflect.DeepEqual(m["configFilePath"], wantURI) { + t.Errorf("%s.configFilePath = %v, want %v", name, m["configFilePath"], wantURI) + } + if re, _ := m["remoteEnv"].(map[string]any); re["FROM_CONTAINER"] != "bar" { + t.Errorf("%s.remoteEnv = %v, want ${containerEnv:FOO} resolved to bar", name, m["remoteEnv"]) + } + if m["image"] != "alpine:3" || !reflect.DeepEqual(m["foo"], map[string]any{"b": 1.0}) || m["extensions"] != "" { + t.Errorf("%s = %v, want image, foo and extensions as written", name, m) + } + for k, v := range m { + if v == nil { + t.Errorf("%s.%s is null; TS leaves unset properties out", name, k) + } + } } } func TestSetUpResultLeavesOutWhatWasNotAskedFor(t *testing.T) { - merged := imagemeta.MergeConfiguration(nil) - - if got := setUpResultJSON(t, nil, merged, nil, false, false); !reflect.DeepEqual(got, map[string]any{"outcome": "success"}) { + if got := setUpResultJSON(t, nil, nil, nil, false, false); !reflect.DeepEqual(got, map[string]any{"outcome": "success"}) { t.Errorf("result = %v, want the outcome alone", got) } - got := setUpResultJSON(t, nil, merged, nil, true, false) + got := setUpResultJSON(t, nil, nil, nil, true, false) if !reflect.DeepEqual(got["configuration"], map[string]any{}) { t.Errorf("configuration without a config = %v, want {}", got["configuration"]) } @@ -206,28 +274,29 @@ func TestSetUpResultLeavesOutWhatWasNotAskedFor(t *testing.T) { } } -func TestSetUpResultDropsAConfigFilePathThatResolvesToNothing(t *testing.T) { - cfg := &config.DevContainer{ConfigFilePath: "${containerEnv:UNSET}"} +// TS substitutes no URI (substitute0), so configFilePath is the file's path +// as it is, whatever ${containerEnv:...} it reads like. +func TestSetUpResultLeavesTheConfigFilePathUnsubstituted(t *testing.T) { + file := "/ws/${containerEnv:UNSET}/devcontainer.json" + loaded := loadedConfig(t, file, map[string]any{"image": "alpine:3"}) - got := setUpResultJSON(t, cfg, imagemeta.MergeConfiguration(nil), nil, true, true) + got := setUpResultJSON(t, loaded, nil, nil, true, true) - conf, _ := got["configuration"].(map[string]any) - if v, ok := conf["configFilePath"]; ok { - t.Errorf("configuration.configFilePath = %v, want it left out", v) - } - mc, _ := got["mergedConfiguration"].(map[string]any) - if v, ok := mc["configFilePath"]; ok { - t.Errorf("mergedConfiguration.configFilePath = %v, want it left out", v) + for _, name := range []string{"configuration", "mergedConfiguration"} { + m, _ := got[name].(map[string]any) + if uri, _ := m["configFilePath"].(map[string]any); uri["fsPath"] != file || uri["path"] != file { + t.Errorf("%s.configFilePath = %v, want the path %q", name, m["configFilePath"], file) + } } } func TestSetUpResultReportsAContainerEnvReferenceWithoutAName(t *testing.T) { - cfg := &config.DevContainer{RemoteEnv: map[string]*string{"X": strPtr("${containerEnv}")}} - if _, err := setUpResult(cfg, imagemeta.MergeConfiguration(nil), nil, true, false); err == nil || !strings.HasPrefix(err.Error(), "resolve configuration container variables: ") { + loaded := loadedConfig(t, "/ws/devcontainer.json", map[string]any{"remoteEnv": map[string]any{"X": "${containerEnv}"}}) + if _, err := setUpResult(loaded, nil, &container.Config{}, true, false); err == nil || !strings.HasPrefix(err.Error(), "resolve configuration container variables: ") { t.Errorf("configuration error = %v, want the substitution error", err) } - merged := imagemeta.MergeConfiguration([]imagemeta.Entry{{RemoteEnv: cfg.RemoteEnv}}) - if _, err := setUpResult(nil, merged, nil, false, true); err == nil || !strings.HasPrefix(err.Error(), "resolve merged container variables: ") { + entries := []imagemeta.Entry{{RemoteEnv: map[string]*string{"X": strPtr("${containerEnv}")}}} + if _, err := setUpResult(nil, entries, &container.Config{}, false, true); err == nil || !strings.HasPrefix(err.Error(), "resolve merged container variables: ") { t.Errorf("merged error = %v, want the substitution error", err) } } diff --git a/src/test/configs/set-up-with-workspace-variables/devcontainer.json b/src/test/configs/set-up-with-workspace-variables/devcontainer.json new file mode 100644 index 0000000..e52b437 --- /dev/null +++ b/src/test/configs/set-up-with-workspace-variables/devcontainer.json @@ -0,0 +1,17 @@ +{ + "image": "alpine:3.17", + // set-up reads its --config without a workspace: only workspaceFolder + // (unset here) gives ${containerWorkspaceFolder}. + "remoteEnv": { + "LOCAL_FOLDER": "${localWorkspaceFolder}", + "LOCAL_BASENAME": "${localWorkspaceFolderBasename}", + "CONTAINER_FOLDER": "${containerWorkspaceFolder}", + "CONTAINER_BASENAME": "${containerWorkspaceFolderBasename}", + "FROM_CONTAINER": "${containerEnv:TEST_CE}" + }, + // Reported as written in configuration and mergedConfiguration. + "extensions": "", + "x-unknown": { + "kept": true + } +} From 054f14fa08e31c64d5e932f1815dfeb4d8e4370d Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:56:08 -0300 Subject: [PATCH 06/37] docs: record that invalid JSONC configurations fail to load TS reads devcontainer.json with jsonc-parser's error recovery, which keeps what it could read and reports nothing: {"image": , ...} loses "image" silently. This CLI keeps failing with the parse error; the divergence is now documented and held by a test. Co-Authored-By: Claude Opus 5.5 --- docs/DIVERGENCES.md | 8 ++++++ internal/config/loader_object_held_test.go | 29 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/docs/DIVERGENCES.md b/docs/DIVERGENCES.md index 37baa4f..c78f524 100644 --- a/docs/DIVERGENCES.md +++ b/docs/DIVERGENCES.md @@ -34,6 +34,14 @@ touches a compared surface, reflected in the parity matrix. "additional privileges requested". This CLI starts its `docker compose` processes with `BUILDX_BAKE_ENTITLEMENTS_FS=0` unless the variable is already set (`TestWithBakeFSEntitlement`, `TestE2E_ComposeBuildLabel`). +- **A devcontainer.json that is not valid JSONC fails to load.** TS parses the configuration + with jsonc-parser, whose error recovery keeps whatever it could read and reports nothing: + `{bad` loads as `{}`, a missing comma or trailing garbage is skipped, and + `{"image": , "remoteUser": "u"}` silently loses `image`. This CLI stops with the parse + error, naming the file, line and column (`parse config : hujson: line 1, column 2: + ...`), because running a configuration with properties silently dropped is worse than + refusing it. Valid JSONC (comments, trailing commas) loads as in TS + (`TestLoadRejectsAConfigThatIsNotValidJSONC`). - **`--override-config` deep-merges** the override onto the base config, whereas TS replaces the config wholesale (`readDocument(overrideConfigFile ?? configFile)`). With no readable base, the override stands alone — identical to TS. This lets an orchestrator diff --git a/internal/config/loader_object_held_test.go b/internal/config/loader_object_held_test.go index a982d1b..3f9d0d4 100644 --- a/internal/config/loader_object_held_test.go +++ b/internal/config/loader_object_held_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" ) @@ -78,3 +79,31 @@ func TestLoadAcceptsWrongTypedLegacyAndCustomizationsProperties(t *testing.T) { } } } + +// Deliberate divergence (docs/DIVERGENCES.md): a devcontainer.json that is not +// valid JSONC fails to load, naming the file and where it breaks. TS parses it +// with jsonc-parser's error recovery and silently keeps what it could read +// ({bad loads as {}, and {"image": , "remoteUser": "u"} drops "image"). +func TestLoadRejectsAConfigThatIsNotValidJSONC(t *testing.T) { + for _, content := range []string{ + `{bad`, + `{"image": "a"} garbage`, + `{"image": "a" "postCreateCommand": "x"}`, + `{"image": , "remoteUser": "u"}`, + `{"image": "a", "features": {"x": {}}`, + } { + ws := t.TempDir() + cfg := filepath.Join(ws, ".devcontainer.json") + if err := os.WriteFile(cfg, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + res, err := LoadDevContainerConfig(ws, cfg, "") + if err == nil { + t.Errorf("%s: loaded %v, want a parse error", content, res.Raw) + continue + } + if want := "parse config " + cfg + ": hujson: line 1, column "; !strings.HasPrefix(err.Error(), want) { + t.Errorf("%s: error = %q, want it to start with %q", content, err, want) + } + } +} From d52c9f591838007dcbcb01ea0b5ed692a4aea534 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:03:09 -0300 Subject: [PATCH 07/37] refactor(imagemeta): drop the integer forwardPorts branches Merged forwardPorts come from decoded JSON (float64 or string) or PortOrString values, never Go integers, so the int and int64 cases were unreachable. Co-Authored-By: Claude Opus 5.5 --- internal/imagemeta/merge.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/imagemeta/merge.go b/internal/imagemeta/merge.go index 5506a40..a38ccd3 100644 --- a/internal/imagemeta/merge.go +++ b/internal/imagemeta/merge.go @@ -246,10 +246,6 @@ func normalizeForwardPort(p any) any { return "localhost:0" // `${-0}` is "0" } return "localhost:" + jsNumberString(v) - case int: - return "localhost:" + strconv.Itoa(v) - case int64: - return "localhost:" + strconv.FormatInt(v, 10) } return p } @@ -297,7 +293,7 @@ func jsParseInt(s string) (float64, bool) { end++ } if end == 0 { - return 0, false + return 0, false // mutate-exempt: the value is unused when ok is false } n, _ := new(big.Int).SetString(s[:end], base) f, _ := new(big.Float).SetInt(n).Float64() From f4ca50416d6dae882d6a2bb2f840fbed108496e2 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:03:09 -0300 Subject: [PATCH 08/37] test: hold the edits mutation testing found unguarded Cover parseInt's sign, white space, hexadecimal and -0 handling in the forwardPorts merge, DependsOnKeys with a repeated or absent key, and the dependsOn order a manifest annotation carries. Co-Authored-By: Claude Opus 5.5 --- internal/cli/feature_install_held_test.go | 3 +- internal/features/types_order_test.go | 25 +++++++ internal/imagemeta/reference_oracle_test.go | 13 ++++ .../testdata/ts-oracle/imagemeta.json | 74 +++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 internal/features/types_order_test.go diff --git a/internal/cli/feature_install_held_test.go b/internal/cli/feature_install_held_test.go index c50dbcc..7f427d7 100644 --- a/internal/cli/feature_install_held_test.go +++ b/internal/cli/feature_install_held_test.go @@ -133,7 +133,7 @@ func TestProcessInstallFeatureOCI(t *testing.T) { blobMeta := `{"id":"hello","name":"Hello","options":{"blob":{}},"dependsOn":{"blob/dep":{}},"installsAfter":["blob"], "legacyIds":["blob"],"init":false,"privileged":false,"capAdd":["BLOB"],"securityOpt":["blob"],"entrypoint":"/blob", "mounts":["blob"],"customizations":{"blob":{}}}` - annotation := `{"legacyIds":["old-hello"],"dependsOn":{"ghcr.io/owner/coll/dep:1":{}},"installsAfter":["ghcr.io/owner/coll/after"], + annotation := `{"legacyIds":["old-hello"],"dependsOn":{"ghcr.io/owner/coll/zdep:1":{},"ghcr.io/owner/coll/dep:1":{}},"installsAfter":["ghcr.io/owner/coll/after"], "options":{"greeting":{"type":"string"}},"init":true,"privileged":true,"capAdd":["SYS_PTRACE"],"securityOpt":["seccomp=unconfined"], "entrypoint":"/ann","mounts":["source=a,target=/a,type=volume"],"customizations":{"vscode":{"extensions":["x"]}}}` @@ -155,6 +155,7 @@ func TestProcessInstallFeatureOCI(t *testing.T) { for name, pair := range map[string][2]any{ "legacyIds": {got.LegacyIds, want.LegacyIds}, "dependsOn": {got.DependsOn, want.DependsOn}, + "dependsOnKeys": {got.DependsOnKeys(), []string{"ghcr.io/owner/coll/zdep:1", "ghcr.io/owner/coll/dep:1"}}, "installsAfter": {got.InstallsAfter, want.InstallsAfter}, "options": {got.Options, want.Options}, "init": {got.Init, want.Init}, diff --git a/internal/features/types_order_test.go b/internal/features/types_order_test.go new file mode 100644 index 0000000..d94efd3 --- /dev/null +++ b/internal/features/types_order_test.go @@ -0,0 +1,25 @@ +package features + +import ( + "encoding/json" + "reflect" + "testing" +) + +// DependsOnKeys is TS Object.keys(dependsOn): the metadata's order, a +// repeated key where it first appears, and keys set in code (not in the +// metadata) after them, sorted. +func TestDependsOnKeysKeepsTheMetadataOrder(t *testing.T) { + var f Feature + if err := json.Unmarshal([]byte(`{"id":"x","dependsOn":{"b":{},"a":{},"b":{"v":1}}}`), &f); err != nil { + t.Fatal(err) + } + if got, want := f.DependsOnKeys(), []string{"b", "a"}; !reflect.DeepEqual(got, want) { + t.Errorf("DependsOnKeys = %v, want %v", got, want) + } + + f = Feature{DependsOn: map[string]any{"c": nil, "z": nil, "a": nil}, DependsOnOrder: []string{"gone", "c"}} + if got, want := f.DependsOnKeys(), []string{"c", "a", "z"}; !reflect.DeepEqual(got, want) { + t.Errorf("DependsOnKeys = %v, want %v", got, want) + } +} diff --git a/internal/imagemeta/reference_oracle_test.go b/internal/imagemeta/reference_oracle_test.go index b6dd451..9636e48 100644 --- a/internal/imagemeta/reference_oracle_test.go +++ b/internal/imagemeta/reference_oracle_test.go @@ -79,3 +79,16 @@ func TestTSOracleImageMetadata(t *testing.T) { "escapeQuotesForShell": str(escapeQuotesForShell), }) } + +// JSON.stringify writes the -0 that parseInt("-0") makes as 0; so does the +// merged configuration, byte for byte (DeepEqual cannot tell -0 from 0). +func TestMergeForwardPortsWritesANegativeZeroAsZero(t *testing.T) { + merged := MergeConfiguration([]Entry{{ForwardPorts: []any{"aaaaaaaaaa-0 localhost:1"}}}) + data, err := json.Marshal(merged.ForwardPorts) + if err != nil { + t.Fatal(err) + } + if string(data) != "[0]" { + t.Errorf("forwardPorts = %s, want [0]", data) + } +} diff --git a/internal/imagemeta/testdata/ts-oracle/imagemeta.json b/internal/imagemeta/testdata/ts-oracle/imagemeta.json index 6c09f80..2083d3c 100644 --- a/internal/imagemeta/testdata/ts-oracle/imagemeta.json +++ b/internal/imagemeta/testdata/ts-oracle/imagemeta.json @@ -996,6 +996,80 @@ ] } }, + { + "name": "forwardPorts parseInt of the text from the tenth character", + "fn": "mergeConfiguration", + "args": [ + {}, + [ + { + "forwardPorts": [ + "aaaaaaaaaa-5 localhost:1", + "aaaaaaaaaa+5 localhost:2", + "aaaaaaaaaa-1 localhost:3", + "aaaaaaaaaa \t0X1f localhost:4", + "aaaaaaaaaa-x localhost:5", + "aaaaaaaaaa-0 localhost:6" + ] + } + ] + ], + "result": { + "init": false, + "privileged": false, + "onCreateCommands": [], + "updateContentCommands": [], + "postCreateCommands": [], + "postStartCommands": [], + "postAttachCommands": [], + "remoteEnv": {}, + "containerEnv": {}, + "portsAttributes": {}, + "forwardPorts": [ + -5, + 5, + -1, + 31, + null, + 0 + ] + } + }, + { + "name": "forwardPorts one digit and hexadecimal edges", + "fn": "mergeConfiguration", + "args": [ + {}, + [ + { + "forwardPorts": [ + "localhost:7", + "localhost:0x", + "localhost:0X1f", + "localhost:0xg" + ] + } + ] + ], + "result": { + "init": false, + "privileged": false, + "onCreateCommands": [], + "updateContentCommands": [], + "postCreateCommands": [], + "postStartCommands": [], + "postAttachCommands": [], + "remoteEnv": {}, + "containerEnv": {}, + "portsAttributes": {}, + "forwardPorts": [ + 7, + null, + 31, + null + ] + } + }, { "name": "hostRequirements per-field max", "fn": "mergeConfiguration", From 247b826909ef61fe47b5aff1680ab3967e53d34a Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:45:42 -0300 Subject: [PATCH 09/37] fix(log): write the text log in the reference's two formats The reference picks its text log handler on process.stdout.isTTY (spec-node/devContainers.ts createLogFrom). Piped, it writes the plain log: `[] text` ending in os.EOL, raw output stamped and stripped of escape sequences, and Stop lines only at debug and trace. On a terminal, it writes colored `[N ms]` lines ending in \r\n, numbers highlighted, and Stop lines only at trace (spec-utils/log.ts). This CLI always wrote uncolored `[N ms] text\n` and showed a Stop line whenever its level passed, matching neither. The parity normalizer scrubbed both timestamp shapes to the same token, which hid it; it now scrubs only the ISO shape. --log-file and --terminal-log-file still receive the log without color or \r, as documented. The goldens in internal/log/testdata/ts-oracle record the reference handlers' output for each event. Scripts that parsed `[N ms]` from piped stderr must read the ISO timestamp instead (docs/migration.md). Co-Authored-By: Claude Opus 5.5 --- docs/migration.md | 7 + go.mod | 2 +- internal/cli/logfile.go | 18 +- internal/cli/logfile_test.go | 36 ++ internal/cli/parity_matrix_test.go | 4 +- internal/log/eol_other.go | 6 + internal/log/eol_windows.go | 4 + internal/log/log.go | 200 +++++- internal/log/log_held_test.go | 69 +-- internal/log/log_nodocker_held_test.go | 1 + internal/log/log_test.go | 23 +- internal/log/log_tty_linux_test.go | 63 ++ internal/log/testdata/ts-oracle/log.json | 740 +++++++++++++++++++++++ internal/log/ts_oracle_test.go | 49 ++ tools/tsoracle/oracle.ts | 9 + 15 files changed, 1138 insertions(+), 93 deletions(-) create mode 100644 internal/log/eol_other.go create mode 100644 internal/log/eol_windows.go create mode 100644 internal/log/log_tty_linux_test.go create mode 100644 internal/log/testdata/ts-oracle/log.json create mode 100644 internal/log/ts_oracle_test.go diff --git a/docs/migration.md b/docs/migration.md index 806594c..2f887a2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -86,6 +86,13 @@ runs the official CLI is unaffected: matching container. - Features are staged under `/devcontainercli-/container-features/`, one folder per build, and that folder is kept as the official CLI keeps it. +- The text log on stderr follows the official CLI's two formats. With stdout + redirected or piped, each line starts with its UTC time + (`[2026-01-02T03:04:05.678Z] text`) and Stop lines appear at `--log-level debug` + and `trace`. With stdout on a terminal, lines keep the elapsed `[N ms]` prefix, + are colored and end in `\r\n`, and Stop lines appear only at `trace`. Earlier + releases always wrote `[N ms] text` without color. A script that parses the log + should read the timestamped form, or use `--log-format json`. ## Rollback diff --git a/go.mod b/go.mod index 17272e5..822d9e1 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( go.uber.org/mock v0.6.0 golang.org/x/crypto v0.58.0 golang.org/x/net v0.61.0 + golang.org/x/term v0.47.0 golang.org/x/text v0.43.0 gopkg.in/yaml.v3 v3.0.1 oras.land/oras-go/v2 v2.6.4 @@ -173,7 +174,6 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/telemetry v0.0.0-20261010193011-f71dc92da45e // indirect - golang.org/x/term v0.47.0 // indirect golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect golang.org/x/vuln v1.9.0 // indirect diff --git a/internal/cli/logfile.go b/internal/cli/logfile.go index b0d9b1d..5ab8dad 100644 --- a/internal/cli/logfile.go +++ b/internal/cli/logfile.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "github.com/devcontainers/cli/internal/log" "github.com/spf13/cobra" ) @@ -42,7 +43,7 @@ func logWriter(paths ...string) (io.Writer, func(), error) { if p == "" { continue } - if dir := filepath.Dir(p); dir != "" && dir != "." { + if dir := filepath.Dir(p); dir != "" && dir != "." { // mutate-exempt: MkdirAll of "." creates nothing _ = os.MkdirAll(dir, 0o755) } f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) @@ -51,7 +52,7 @@ func logWriter(paths ...string) (io.Writer, func(), error) { return os.Stderr, noop, err } files = append(files, f) - writers = append(writers, f) + writers = append(writers, plainFileWriter{f}) } if len(files) == 0 { @@ -59,3 +60,16 @@ func logWriter(paths ...string) (io.Writer, func(), error) { } return io.MultiWriter(writers...), cleanup, nil } + +// plainFileWriter keeps a log file free of the terminal format: on a terminal +// stdout the log is colored and ends its lines in \r\n (TS createTerminalLog), +// which a file reader should not have to filter. The logger writes one event per +// Write, so no escape sequence is split across two calls. +type plainFileWriter struct{ w io.Writer } + +func (p plainFileWriter) Write(b []byte) (int, error) { + if _, err := io.WriteString(p.w, log.StripTerminalFormatting(string(b))); err != nil { + return 0, err + } + return len(b), nil +} diff --git a/internal/cli/logfile_test.go b/internal/cli/logfile_test.go index 97f5042..a6d17cf 100644 --- a/internal/cli/logfile_test.go +++ b/internal/cli/logfile_test.go @@ -60,6 +60,42 @@ func TestLogWriterTeesToBothFiles(t *testing.T) { } } +// A log file holds the log without the terminal format: on a terminal stdout +// the log is colored and ends its lines in \r\n (TS createTerminalLog), and +// the file keeps only the text, as --log-file and --terminal-log-file document. +func TestLogWriterKeepsTheTerminalFormatOutOfTheFile(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "log.txt") + w, cleanup, err := logWriter(logPath, "") + if err != nil { + t.Fatalf("logWriter: %v", err) + } + line := "[\x1b[1m\x1b[38;2;99;143;79m12 ms\x1b[39m\x1b[22m] took \x1b[1m\x1b[38;2;86;156;214m5\x1b[39m\x1b[22m ms\r\nnext\r\n" + if n, err := w.Write([]byte(line)); err != nil || n != len(line) { + t.Fatalf("Write = %d, %v; want %d, nil", n, err, len(line)) + } + cleanup() + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if want := "[12 ms] took 5 ms\nnext\n"; string(data) != want { + t.Errorf("log file = %q, want %q", data, want) + } +} + +// A log file that fails to take a line reports the failure, not a write. +func TestPlainFileWriterReportsAFailedWrite(t *testing.T) { + f, err := os.Create(filepath.Join(t.TempDir(), "log.txt")) + if err != nil { + t.Fatal(err) + } + _ = f.Close() + if n, err := (plainFileWriter{f}).Write([]byte("line\n")); n != 0 || err == nil { + t.Errorf("Write = %d, %v; want 0 and the error", n, err) + } +} + // TestLogWriterNoFileFallsBackToStderr confirms that with no paths the writer is // os.Stderr and the cleanup is a harmless no-op. func TestLogWriterNoFileFallsBackToStderr(t *testing.T) { diff --git a/internal/cli/parity_matrix_test.go b/internal/cli/parity_matrix_test.go index f1912fc..87df046 100644 --- a/internal/cli/parity_matrix_test.go +++ b/internal/cli/parity_matrix_test.go @@ -669,7 +669,8 @@ func runShellCommand(ctx context.Context, repoRoot, shellCmd string, env map[str // --- Normalization --- var ( - reTimestampGo = regexp.MustCompile(`\[\d+ ms\]`) + // Both CLIs stamp a piped log line with its ISO time (TS createPlainLog); + // only that shape is scrubbed, so a line in another shape stays a mismatch. reTimestampTS = regexp.MustCompile(`\[\d{4}-\d{2}-\d{2}T[\d:.]+Z\]`) // Host/tmp path scrubbers stop at structural delimiters (comma, '=', ';', ')') // so a mount spec like source=/home/x,target=/y,type=bind keeps its @@ -844,7 +845,6 @@ func normalizeTextOpts(raw string, scrubHashes bool) string { } // Normalize timestamps - line = reTimestampGo.ReplaceAllString(line, "[X ms]") line = reTimestampTS.ReplaceAllString(line, "[X ms]") // Normalize paths and IDs diff --git a/internal/log/eol_other.go b/internal/log/eol_other.go new file mode 100644 index 0000000..a17aa54 --- /dev/null +++ b/internal/log/eol_other.go @@ -0,0 +1,6 @@ +//go:build !windows + +package log + +// eol is Node's os.EOL, which ends each line of the plain log. +const eol = "\n" diff --git a/internal/log/eol_windows.go b/internal/log/eol_windows.go new file mode 100644 index 0000000..6f46991 --- /dev/null +++ b/internal/log/eol_windows.go @@ -0,0 +1,4 @@ +package log + +// eol is Node's os.EOL, which ends each line of the plain log. +const eol = "\r\n" diff --git a/internal/log/log.go b/internal/log/log.go index d3549ca..ee53e35 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -7,10 +7,13 @@ import ( "fmt" "io" "os" + "regexp" "runtime" "slices" "strings" "time" + + "golang.org/x/term" ) // Level mirrors the TS enum values for compatibility. @@ -123,15 +126,20 @@ func New(opts Options) Logger { opts.Level = LevelInfo } + // The handler is TS createLogFrom's (spec-node/devContainers.ts): JSON for + // --log-format json, the terminal format when stdout is a terminal + // (process.stdout.isTTY), the plain format otherwise. var h Handler - switch opts.Format { - case "json": + switch { + case opts.Format == "json": // The JSON stream emits every event regardless of --log-level; the // consumer (e.g. the VS Code extension) filters client-side. Only the - // terminal handler applies the level filter, matching the TS CLI. + // text handlers apply the level filter, matching the TS CLI. h = newJSONHandler(opts.Writer) - default: + case stdoutIsTerminal(): h = newTerminalHandler(opts.Writer, opts.StartTime, opts.Level) + default: + h = newPlainHandler(opts.Writer, opts.Level) } l := &logger{ @@ -249,6 +257,15 @@ func (n *nullLog) Event(Event) {} // --- Terminal handler --- +// stdoutIsTerminal reports what TS reads as process.stdout.isTTY: whether the +// CLI's stdout is a terminal. Like TS, the choice of log format follows stdout +// even though the log itself goes to stderr. +func stdoutIsTerminal() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +// terminalHandler is TS createTerminalLog: logEventToTerminalText +// (spec-utils/log.ts) with ANSI colors and \r\n line ends. type terminalHandler struct { w io.Writer start int64 @@ -261,32 +278,178 @@ func newTerminalHandler(w io.Writer, start time.Time, minLevel Level) Handler { } func (h *terminalHandler) HandleEvent(e Event) { - if e.Type == "progress" { - return // progress events are not printed in terminal mode - } - // Filter by --log-level: drop events below the configured threshold, like - // the TS CLI terminal handler (e.g. at info, debug "Run:" lines are hidden). - if e.Level == 0 || e.Level < h.minLevel { + // A progress event has no level (0, below any minLevel), so it is never + // printed, as in TS. + if e.Level < h.minLevel { return } - elapsed := e.Timestamp - h.start + elapsed := fmt.Sprintf("%d ms", e.Timestamp-h.start) + trace := h.minLevel <= LevelTrace var line string switch e.Type { case "text": - line = fmt.Sprintf("[%d ms] %s", elapsed, ensureNewline(e.Text)) + line = "[" + color(timestampColor, elapsed) + "] " + toTerminalText(e.Text) case "raw": line = e.Text case "start": - line = fmt.Sprintf("[%d ms] Start: %s", elapsed, ensureNewline(e.Text)) + if trace { + line = color(startColor, "["+elapsed+"] Start") + ": " + toTerminalText(e.Text) + } else { + line = "[" + color(timestampColor, elapsed) + "] Start: " + toTerminalText(e.Text) + } case "stop": - duration := e.Timestamp - e.StartTimestamp - line = fmt.Sprintf("[%d ms] Stop (%d ms): %s", elapsed, duration, ensureNewline(e.Text)) + // TS shows a stop line on the terminal only at trace. + if trace { + line = color(stopColor, "["+elapsed+"] Stop") + fmt.Sprintf(" (%d ms): ", e.Timestamp-e.StartTimestamp) + toTerminalText(e.Text) + } } if line != "" { fmt.Fprint(h.w, line) } } +// TS foreground colors (spec-utils/log.ts). +const ( + stopColor = "38;2;143;99;79" + startColor = "38;2;99;143;79" + timestampColor = "38;2;99;143;79" + numberColor = "38;2;86;156;214" +) + +// color is TS color: s in bold and the given foreground color. TS colors each +// line of s; every caller passes one line, a timestamp or a number. +func color(c, s string) string { + return "\x1b[1m\x1b[" + c + "m" + s + "\x1b[39m\x1b[22m" +} + +// terminalEscapeSequences is TS terminalEscapeSequences (spec-utils/log.ts). +var terminalEscapeSequences = regexp.MustCompile(`(\x9B|\x1B\[)[0-?]*[ -/]*[@-~]`) + +// newlines matches TS toTerminalText's /\r?\n/g. +var newlines = regexp.MustCompile(`\r?\n`) + +// toTerminalText is TS toTerminalText: the text colorized, every line ending +// in \r\n, and one \r\n at the end. +func toTerminalText(text string) string { + s := newlines.ReplaceAllString(colorize(text), "\r\n") + if !strings.HasSuffix(s, "\r\n") { + s += "\r\n" + } + return s +} + +// colorize is TS colorize: numbers colored outside escape sequences, which are +// kept as they are. +func colorize(text string) string { + var b strings.Builder + last := 0 + for _, m := range terminalEscapeSequences.FindAllStringIndex(text, -1) { // mutate-exempt: any negative n finds every match + b.WriteString(colorizePlainText(text[last:m[0]])) + b.WriteString(text[m[0]:m[1]]) + last = m[1] + } + b.WriteString(colorizePlainText(text[last:])) + return b.String() +} + +// colorizePlainText is TS colorizePlainText, whose expression +// /(?<=^|[^A-Za-z0-9_\-\.])[0-9]+(\.[0-9]+)*(?=$|[^A-Za-z0-9_\-\.])/g needs +// lookaround that Go's regexp lacks: a number such as 1 or 1.2.3 is colored +// when neither neighbour is a letter, digit, '_', '-' or '.'. +func colorizePlainText(text string) string { + var b strings.Builder + last := 0 + for i := 0; i < len(text); i++ { + if !isDigit(text[i]) || (i > 0 && isWordByte(text[i-1])) { + continue + } + j := skipDigits(text, i) + for j+1 < len(text) && text[j] == '.' && isDigit(text[j+1]) { + j = skipDigits(text, j+1) // mutate-exempt: text[j+1] is a digit, so skipping from j+2 ends at the same place + } + // No shorter match can pass the lookahead: it would end before a + // digit or a '.', so a failed number is skipped whole. + if j == len(text) || !isWordByte(text[j]) { + b.WriteString(text[last:i]) + b.WriteString(color(numberColor, text[i:j])) + last = j + } + i = j - 1 + } + b.WriteString(text[last:]) + return b.String() +} + +func skipDigits(s string, i int) int { + for i < len(s) && isDigit(s[i]) { + i++ + } + return i +} + +func isDigit(c byte) bool { return '0' <= c && c <= '9' } + +// isWordByte is the complement of TS's [^A-Za-z0-9_\-\.]. +func isWordByte(c byte) bool { + return isDigit(c) || 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || c == '_' || c == '-' || c == '.' +} + +// StripTerminalFormatting removes what the terminal format adds to a line, its +// escape sequences and the \r of its \r\n line ends, for a copy of the log +// that is read as a file (--log-file, --terminal-log-file). +func StripTerminalFormatting(s string) string { + return strings.ReplaceAll(terminalEscapeSequences.ReplaceAllString(s, ""), "\r\n", "\n") +} + +// --- Plain handler --- + +// plainHandler is TS createPlainLog: logEventToFileText (spec-utils/log.ts), +// used when stdout is not a terminal. +type plainHandler struct { + w io.Writer + minLevel Level +} + +// newPlainHandler's minLevel is never 0: New defaults it to info. +func newPlainHandler(w io.Writer, minLevel Level) Handler { + return &plainHandler{w: w, minLevel: minLevel} +} + +func (h *plainHandler) HandleEvent(e Event) { + // A progress event has no level (0, below any minLevel), so it is never + // printed, as in TS. + if e.Level < h.minLevel { + return + } + // new Date(e.timestamp).toISOString() + stamp := "[" + time.UnixMilli(e.Timestamp).UTC().Format("2006-01-02T15:04:05.000Z") + "] " + var line string + switch e.Type { + case "text", "raw": + line = stamp + toLogFileText(e.Text) + case "start": + line = stamp + "Start: " + toLogFileText(e.Text) + case "stop": + // TS shows a stop line in the plain log at debug and trace. + if h.minLevel <= LevelDebug { + line = stamp + fmt.Sprintf("Stop (%d ms): ", e.Timestamp-e.StartTimestamp) + toLogFileText(e.Text) + } + } + if line != "" { + fmt.Fprint(h.w, line) + } +} + +// toLogFileText is TS toLogFileText: the text without escape sequences, its +// last line end replaced with os.EOL (one added when it has none). +func toLogFileText(text string) string { + s := terminalEscapeSequences.ReplaceAllString(text, "") + if t, ok := strings.CutSuffix(s, "\r\n"); ok { + return t + eol + } + return strings.TrimSuffix(s, "\n") + eol +} + // --- JSON handler --- type jsonHandler struct { @@ -310,10 +473,3 @@ func (h *jsonHandler) HandleEvent(e Event) { func nowMillis() int64 { return time.Now().UnixMilli() } - -func ensureNewline(s string) string { - if len(s) == 0 || s[len(s)-1] != '\n' { - return s + "\n" - } - return s -} diff --git a/internal/log/log_held_test.go b/internal/log/log_held_test.go index 125dfb9..76505f7 100644 --- a/internal/log/log_held_test.go +++ b/internal/log/log_held_test.go @@ -7,14 +7,29 @@ import ( "os" "regexp" "runtime" - "strconv" "strings" "testing" - "time" ) +// plainStdout points stdout at /dev/null for the test, so New picks the plain +// log however the test binary was started. +func plainStdout(t *testing.T) { + t.Helper() + null, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + saved := os.Stdout + os.Stdout = null + t.Cleanup(func() { + os.Stdout = saved + _ = null.Close() + }) +} + // Without a Writer the logger writes to stderr, where the TS CLI writes its log. func TestNewWritesToStderrByDefault(t *testing.T) { + plainStdout(t) r, w, err := os.Pipe() if err != nil { t.Fatal(err) @@ -31,17 +46,16 @@ func TestNewWritesToStderrByDefault(t *testing.T) { } } -// Without a StartTime the elapsed time counts from New, so the first line -// reads a few milliseconds, not the milliseconds since 1970. -func TestNewCountsElapsedFromNowByDefault(t *testing.T) { +// With stdout not a terminal the log is TS's plain log: each line stamped with +// its ISO time (spec-node/devContainers.ts createLogFrom picks createPlainLog +// when process.stdout.isTTY is false). /dev/null is a character device but no +// terminal. +func TestNewWritesThePlainLogWhenStdoutIsNotATerminal(t *testing.T) { + plainStdout(t) var buf bytes.Buffer New(Options{Writer: &buf}).Write("x", LevelInfo) - m := regexp.MustCompile(`^\[(-?\d+) ms\] x\n$`).FindStringSubmatch(buf.String()) - if m == nil { - t.Fatalf("output = %q", buf.String()) - } - if ms, _ := strconv.ParseInt(m[1], 10, 64); ms < 0 || ms > 60_000 { - t.Errorf("elapsed = %d ms, want the time since New", ms) + if !regexp.MustCompile(`^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] x\n$`).MatchString(buf.String()) { + t.Errorf("output = %q, want the plain log line", buf.String()) } } @@ -69,36 +83,3 @@ func TestBannerLineNamesTheKernelRelease(t *testing.T) { t.Errorf("BannerLine = %q, want %q", got, want) } } - -// A line's [N ms] is its timestamp minus the logger's start, and each text -// ends in exactly one newline, as TS logEventToTerminalText -// (spec-utils/log.ts: `${e.timestamp - startTimestamp} ms`, and -// toTerminalText's replace(/(\r?\n)?$/, ...) adding a line end only when -// there is none). -func TestTerminalLinesCountFromTheStartAndEndInOneNewline(t *testing.T) { - start := time.UnixMilli(1_000) - for _, tc := range []struct{ text, want string }{ - {"a", "[1234 ms] a\n"}, - {"a\n", "[1234 ms] a\n"}, - {"ab\n", "[1234 ms] ab\n"}, - {"\n", "[1234 ms] \n"}, - {"", "[1234 ms] \n"}, - } { - var buf bytes.Buffer - newTerminalHandler(&buf, start, LevelInfo).HandleEvent(Event{Type: "text", Level: LevelInfo, Timestamp: 2_234, Text: tc.text}) - if buf.String() != tc.want { - t.Errorf("text %q printed %q, want %q", tc.text, buf.String(), tc.want) - } - } -} - -// A stop line reads the elapsed time since the start and, in parentheses, the -// time since its own start event (spec-utils/log.ts: `Stop (${e.timestamp - -// e.startTimestamp} ms)`). -func TestTerminalStopLineShowsItsDuration(t *testing.T) { - var buf bytes.Buffer - newTerminalHandler(&buf, time.UnixMilli(1_000), LevelTrace).HandleEvent(Event{Type: "stop", Level: LevelInfo, Timestamp: 2_234, StartTimestamp: 2_000, Text: "Run: x"}) - if want := "[1234 ms] Stop (234 ms): Run: x\n"; buf.String() != want { - t.Errorf("printed %q, want %q", buf.String(), want) - } -} diff --git a/internal/log/log_nodocker_held_test.go b/internal/log/log_nodocker_held_test.go index 839b3fd..43f4d0c 100644 --- a/internal/log/log_nodocker_held_test.go +++ b/internal/log/log_nodocker_held_test.go @@ -10,6 +10,7 @@ import ( // nothing, as TS replaceAllLog (spec-utils/log.ts) filters out empty values and // sorts the rest longest first. func TestSecretMaskingReplacesEachSecretWhole(t *testing.T) { + plainStdout(t) var buf bytes.Buffer l := New(Options{Level: LevelInfo, Format: "text", Writer: &buf, Secrets: []string{"", "tok", "tok3n"}}) l.Write("use tok3n and tok", LevelInfo) diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 534366d..ffce38f 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -60,28 +60,7 @@ func TestSecretMasking(t *testing.T) { } } -func TestTerminalHandler_Text(t *testing.T) { - var buf bytes.Buffer - start := time.Now() - l := New(Options{ - Level: LevelInfo, - Format: "text", - Writer: &buf, - StartTime: start, - }) - - l.Write("Starting container", LevelInfo) - out := buf.String() - - if !strings.Contains(out, "ms]") { - t.Errorf("expected timestamp, got: %q", out) - } - if !strings.Contains(out, "Starting container") { - t.Errorf("expected message, got: %q", out) - } -} - -func TestTerminalHandler_StartStop(t *testing.T) { +func TestTextHandler_StartStop(t *testing.T) { var buf bytes.Buffer start := time.Now() l := New(Options{ diff --git a/internal/log/log_tty_linux_test.go b/internal/log/log_tty_linux_test.go new file mode 100644 index 0000000..5e29bd2 --- /dev/null +++ b/internal/log/log_tty_linux_test.go @@ -0,0 +1,63 @@ +package log + +import ( + "bytes" + "fmt" + "os" + "regexp" + "strconv" + "syscall" + "testing" + "unsafe" +) + +// With stdout a terminal the log is TS's terminal log, colored and ending its +// lines in \r\n (createLogFrom picks createTerminalLog when +// process.stdout.isTTY), and a line's [N ms] counts from New when no +// StartTime is given. +func TestNewWritesTheTerminalLogWhenStdoutIsATerminal(t *testing.T) { + saved := os.Stdout + os.Stdout = openPTY(t) + t.Cleanup(func() { os.Stdout = saved }) + + var buf bytes.Buffer + New(Options{Writer: &buf}).Write("x", LevelInfo) + m := regexp.MustCompile(`^\[\x1b\[1m\x1b\[38;2;99;143;79m(\d+) ms\x1b\[39m\x1b\[22m\] x\r\n$`).FindStringSubmatch(buf.String()) + if m == nil { + t.Fatalf("output = %q, want the terminal log line", buf.String()) + } + if ms, _ := strconv.ParseInt(m[1], 10, 64); ms > 60_000 { + t.Errorf("elapsed = %d ms, want the time since New", ms) + } +} + +// openPTY opens a pseudo-terminal and returns its terminal side. +func openPTY(t *testing.T) *os.File { + t.Helper() + ptmx, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0) + if err != nil { + t.Skipf("no pseudo-terminal: %v", err) + } + t.Cleanup(func() { _ = ptmx.Close() }) + var unlock int32 + if err := ioctl(ptmx, syscall.TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil { + t.Fatalf("unlock pty: %v", err) + } + var n uint32 + if err := ioctl(ptmx, syscall.TIOCGPTN, unsafe.Pointer(&n)); err != nil { + t.Fatalf("pty number: %v", err) + } + pts, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = pts.Close() }) + return pts +} + +func ioctl(f *os.File, req uintptr, arg unsafe.Pointer) error { + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), req, uintptr(arg)); errno != 0 { + return errno + } + return nil +} diff --git a/internal/log/testdata/ts-oracle/log.json b/internal/log/testdata/ts-oracle/log.json new file mode 100644 index 0000000..7fc31fc --- /dev/null +++ b/internal/log/testdata/ts-oracle/log.json @@ -0,0 +1,740 @@ +{ + "comment": "spec-utils/log.ts logEventToTerminalText and createPlainLog's logEventToFileText, ported in log.go.", + "cases": [ + { + "name": "terminal/no line end", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "a" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] a\r\n" + }, + { + "name": "plain/no line end", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 1792975082379, + "text": "a" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] a\n" + }, + { + "name": "terminal/line end", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "a\n" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] a\r\n" + }, + { + "name": "plain/line end", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 1792975082379, + "text": "a\n" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] a\n" + }, + { + "name": "terminal/crlf line end", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "a\r\n" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] a\r\n" + }, + { + "name": "plain/crlf line end", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 1792975082379, + "text": "a\r\n" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] a\n" + }, + { + "name": "terminal/lone cr", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "a\r" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] a\r\r\n" + }, + { + "name": "plain/lone cr", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 1792975082379, + "text": "a\r" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] a\r\n" + }, + { + "name": "terminal/empty", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] \r\n" + }, + { + "name": "plain/empty", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 1792975082379, + "text": "" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] \n" + }, + { + "name": "terminal/only line end", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "\n" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] \r\n" + }, + { + "name": "plain/only line end", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 1792975082379, + "text": "\n" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] \n" + }, + { + "name": "terminal/two lines", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "line1\nline2" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] line1\r\nline2\r\n" + }, + { + "name": "plain/two lines", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 1792975082379, + "text": "line1\nline2" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] line1\nline2\n" + }, + { + "name": "terminal/two lines crlf", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "line1\r\nline2\n" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] line1\r\nline2\r\n" + }, + { + "name": "plain/two lines crlf", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 1792975082379, + "text": "line1\r\nline2\n" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] line1\r\nline2\n" + }, + { + "name": "terminal/number", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "took 5 ms" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] took \u001b[1m\u001b[38;2;86;156;214m5\u001b[39m\u001b[22m ms\r\n" + }, + { + "name": "terminal/banner", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "@devcontainers/cli 0.89.0. Node.js v22.22.1. linux 7.0.0-38-generic x64." + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] @devcontainers/cli 0.89.0. Node.js v22.22.1. linux 7.0.0-38-generic x64.\r\n" + }, + { + "name": "terminal/dotted number", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "version 1.2.3" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] version \u001b[1m\u001b[38;2;86;156;214m1.2.3\u001b[39m\u001b[22m\r\n" + }, + { + "name": "terminal/port pair", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "port 8080:80" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] port \u001b[1m\u001b[38;2;86;156;214m8080\u001b[39m\u001b[22m:\u001b[1m\u001b[38;2;86;156;214m80\u001b[39m\u001b[22m\r\n" + }, + { + "name": "terminal/glued to word chars", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "x-1 a_2 b.3 4a" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] x-1 a_2 b.3 4a\r\n" + }, + { + "name": "terminal/word char boundaries", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "1-x 2_y Z3 3Z z4 4z A5 5A a6 0 @7 8[" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] 1-x 2_y Z3 3Z z4 4z A5 5A a6 \u001b[1m\u001b[38;2;86;156;214m0\u001b[39m\u001b[22m @\u001b[1m\u001b[38;2;86;156;214m7\u001b[39m\u001b[22m \u001b[1m\u001b[38;2;86;156;214m8\u001b[39m\u001b[22m[\r\n" + }, + { + "name": "terminal/word char before a second-character digit", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "x1" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] x1\r\n" + }, + { + "name": "terminal/trailing dot", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "1.2." + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] 1.2.\r\n" + }, + { + "name": "terminal/parenthesized", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "(42)" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] (\u001b[1m\u001b[38;2;86;156;214m42\u001b[39m\u001b[22m)\r\n" + }, + { + "name": "terminal/first and last", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "7 and 9" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] \u001b[1m\u001b[38;2;86;156;214m7\u001b[39m\u001b[22m and \u001b[1m\u001b[38;2;86;156;214m9\u001b[39m\u001b[22m\r\n" + }, + { + "name": "terminal/non-ascii neighbour", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "é5 5é" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] é\u001b[1m\u001b[38;2;86;156;214m5\u001b[39m\u001b[22m \u001b[1m\u001b[38;2;86;156;214m5\u001b[39m\u001b[22mé\r\n" + }, + { + "name": "terminal/escape sequences", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "\u001b[31m7\u001b[0m done 3" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] \u001b[31m\u001b[1m\u001b[38;2;86;156;214m7\u001b[39m\u001b[22m\u001b[0m done \u001b[1m\u001b[38;2;86;156;214m3\u001b[39m\u001b[22m\r\n" + }, + { + "name": "terminal/c1 csi", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "›1m9" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] ›1m\u001b[1m\u001b[38;2;86;156;214m9\u001b[39m\u001b[22m\r\n" + }, + { + "name": "terminal/number across lines", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 2234, + "text": "1\n2" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] \u001b[1m\u001b[38;2;86;156;214m1\u001b[39m\u001b[22m\r\n\u001b[1m\u001b[38;2;86;156;214m2\u001b[39m\u001b[22m\r\n" + }, + { + "name": "terminal/below level", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "text", + "level": 2, + "timestamp": 2234, + "text": "hidden" + }, + 3, + 1000 + ], + "result": null + }, + { + "name": "terminal/raw is verbatim", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "raw", + "level": 3, + "timestamp": 2234, + "text": "raw \u001b[1m 5\n" + }, + 3, + 1000 + ], + "result": "raw \u001b[1m 5\n" + }, + { + "name": "terminal/progress", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "progress", + "name": "Building", + "status": "running" + }, + 3, + 1000 + ], + "result": null + }, + { + "name": "terminal/start at info", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "start", + "level": 3, + "timestamp": 2234, + "text": "Run: x" + }, + 3, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] Start: Run: x\r\n" + }, + { + "name": "terminal/stop at info", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "stop", + "level": 3, + "timestamp": 2234, + "text": "Run: x", + "startTimestamp": 2000 + }, + 3, + 1000 + ], + "result": null + }, + { + "name": "plain/start at info", + "fn": "plainLogText", + "args": [ + { + "type": "start", + "level": 3, + "timestamp": 1792975082379, + "text": "Run: x" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] Start: Run: x\n" + }, + { + "name": "plain/stop at info", + "fn": "plainLogText", + "args": [ + { + "type": "stop", + "level": 3, + "timestamp": 1792975082379, + "text": "Run: x", + "startTimestamp": 1792975082145 + }, + 3 + ], + "result": null + }, + { + "name": "terminal/start at debug", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "start", + "level": 3, + "timestamp": 2234, + "text": "Run: x" + }, + 2, + 1000 + ], + "result": "[\u001b[1m\u001b[38;2;99;143;79m1234 ms\u001b[39m\u001b[22m] Start: Run: x\r\n" + }, + { + "name": "terminal/stop at debug", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "stop", + "level": 3, + "timestamp": 2234, + "text": "Run: x", + "startTimestamp": 2000 + }, + 2, + 1000 + ], + "result": null + }, + { + "name": "plain/start at debug", + "fn": "plainLogText", + "args": [ + { + "type": "start", + "level": 3, + "timestamp": 1792975082379, + "text": "Run: x" + }, + 2 + ], + "result": "[2026-10-26T00:38:02.379Z] Start: Run: x\n" + }, + { + "name": "plain/stop at debug", + "fn": "plainLogText", + "args": [ + { + "type": "stop", + "level": 3, + "timestamp": 1792975082379, + "text": "Run: x", + "startTimestamp": 1792975082145 + }, + 2 + ], + "result": "[2026-10-26T00:38:02.379Z] Stop (234 ms): Run: x\n" + }, + { + "name": "terminal/start at trace", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "start", + "level": 3, + "timestamp": 2234, + "text": "Run: x" + }, + 1, + 1000 + ], + "result": "\u001b[1m\u001b[38;2;99;143;79m[1234 ms] Start\u001b[39m\u001b[22m: Run: x\r\n" + }, + { + "name": "terminal/stop at trace", + "fn": "logEventToTerminalText", + "args": [ + { + "type": "stop", + "level": 3, + "timestamp": 2234, + "text": "Run: x", + "startTimestamp": 2000 + }, + 1, + 1000 + ], + "result": "\u001b[1m\u001b[38;2;143;99;79m[1234 ms] Stop\u001b[39m\u001b[22m (234 ms): Run: x\r\n" + }, + { + "name": "plain/start at trace", + "fn": "plainLogText", + "args": [ + { + "type": "start", + "level": 3, + "timestamp": 1792975082379, + "text": "Run: x" + }, + 1 + ], + "result": "[2026-10-26T00:38:02.379Z] Start: Run: x\n" + }, + { + "name": "plain/stop at trace", + "fn": "plainLogText", + "args": [ + { + "type": "stop", + "level": 3, + "timestamp": 1792975082379, + "text": "Run: x", + "startTimestamp": 1792975082145 + }, + 1 + ], + "result": "[2026-10-26T00:38:02.379Z] Stop (234 ms): Run: x\n" + }, + { + "name": "plain/raw is timestamped", + "fn": "plainLogText", + "args": [ + { + "type": "raw", + "level": 3, + "timestamp": 1792975082379, + "text": "chunk" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] chunk\n" + }, + { + "name": "plain/escape sequences stripped", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 1792975082379, + "text": "\u001b[31mred\u001b[0m 5›1m" + }, + 3 + ], + "result": "[2026-10-26T00:38:02.379Z] red 5\n" + }, + { + "name": "plain/below level", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 2, + "timestamp": 1792975082379, + "text": "hidden" + }, + 3 + ], + "result": null + }, + { + "name": "plain/progress", + "fn": "plainLogText", + "args": [ + { + "type": "progress", + "name": "Building", + "status": "running" + }, + 3 + ], + "result": null + }, + { + "name": "plain/timestamp in UTC with milliseconds", + "fn": "plainLogText", + "args": [ + { + "type": "text", + "level": 3, + "timestamp": 0, + "text": "x" + }, + 3 + ], + "result": "[1970-01-01T00:00:00.000Z] x\n" + } + ] +} diff --git a/internal/log/ts_oracle_test.go b/internal/log/ts_oracle_test.go new file mode 100644 index 0000000..067221f --- /dev/null +++ b/internal/log/ts_oracle_test.go @@ -0,0 +1,49 @@ +package log + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/devcontainers/cli/internal/tsoracle/oracletest" +) + +// The goldens are the lines the reference log handlers write for one event +// (go tool task testdata:ts-oracle): the terminal log when stdout is a TTY, the +// plain log otherwise. +func TestTSOracleLog(t *testing.T) { + oracletest.Run(t, "testdata/ts-oracle/log.json", map[string]oracletest.Func{ + "logEventToTerminalText": func(args []json.RawMessage) (any, error) { + var e Event + var level Level + var start int64 + if err := oracletest.Args(args, &e, &level, &start); err != nil { + return nil, err + } + var buf bytes.Buffer + newTerminalHandler(&buf, time.UnixMilli(start), level).HandleEvent(e) + return written(buf.String()), nil + }, + "plainLogText": func(args []json.RawMessage) (any, error) { + var e Event + var level Level + if err := oracletest.Args(args, &e, &level); err != nil { + return nil, err + } + var buf bytes.Buffer + newPlainHandler(&buf, level).HandleEvent(e) + // The goldens are recorded where Node's os.EOL is "\n". + return written(strings.ReplaceAll(buf.String(), eol, "\n")), nil + }, + }) +} + +// written is what a handler wrote, or nil (TS undefined) when it wrote nothing. +func written(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/tools/tsoracle/oracle.ts b/tools/tsoracle/oracle.ts index d4446d8..578559c 100644 --- a/tools/tsoracle/oracle.ts +++ b/tools/tsoracle/oracle.ts @@ -19,6 +19,7 @@ import { getRef, getCollectionRef } from '../../reference/src/spec-configuration import { replaceTemplatedValues } from '../../reference/src/spec-configuration/containerTemplatesOCI'; import { readVersionPrefix, convertMountToVolume, convertMountToVolumeTopLevelElement } from '../../reference/src/spec-node/dockerCompose'; import { qualifyImageName, isBuildxCacheToInline } from '../../reference/src/spec-node/utils'; +import { logEventToTerminalText, createPlainLog } from '../../reference/src/spec-utils/log'; // escapeQuotesForShell, convertMountToVolume, convertMountToVolumeTopLevelElement // and replaceTemplatedValues are module-private in the reference; build.mjs exports them for this bundle. @@ -82,6 +83,14 @@ const fns: Record any> = { // spec-node/utils.ts qualifyImageName: (s: string) => qualifyImageName(s), isBuildxCacheToInline: (s: string | null) => isBuildxCacheToInline(s ?? undefined), + // spec-utils/log.ts: the line the terminal log (stdout a TTY) and the plain + // log (stdout not a TTY) write for one event, or null for none. + logEventToTerminalText: (e: any, logLevel: number, startTimestamp: number) => logEventToTerminalText(e, logLevel, startTimestamp), + plainLogText: (e: any, logLevel: number) => { + let text: string | null = null; + createPlainLog(t => { text = t; }, () => logLevel).event(e); + return text; + }, }; async function main() { From e06be4e1a2529d74d00a130265cd0a688160401d Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:50:03 -0300 Subject: [PATCH 10/37] fix(cli): check the terminal size flags by presence, as yargs does yargs .implies() fails when a flag is given without the flag it implies, whatever their values (validation.js keyExists tests hasOwnProperty). The reference declares --terminal-columns and --terminal-rows to imply each other on up, set-up, run-user-commands, read-configuration, outdated and exec (devContainersSpecCLI.ts). This CLI tested the values (> 0), so `--terminal-rows 0` alone passed where the reference exits 1 with "Implications failed", and outdated did not check the pair at all. The flags now record whether they were given, parsing their value as before. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 77 +++++++++++++++++++++ internal/cli/exec.go | 8 +-- internal/cli/outdated.go | 9 ++- internal/cli/read_configuration.go | 8 +-- internal/cli/run_user_commands.go | 8 +-- internal/cli/run_user_commands_held_test.go | 2 +- internal/cli/setup.go | 8 +-- internal/cli/up.go | 8 +-- internal/cli/validate.go | 33 +++++++-- internal/cli/validate_repo_held_test.go | 40 ++++++++--- 10 files changed, 163 insertions(+), 38 deletions(-) diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index ec190e4..f5f9434 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -915,6 +915,83 @@ initial_cases: current_status: match notes: "TS reads --config before it looks for the container: 'Dev container config (...) not found.' and exit 1. Go ignored a config that did not load (and reported the missing container first)." + - id: read-configuration.terminal-rows-zero-implies-columns + lane: contract + command: read-configuration + priority: p1 + docker_required: false + ts_cmd: "read-configuration --workspace-folder src/test/configs/image --terminal-rows 0" + asserts: [exit_code, stderr_normalized] + class: implies-validation + current_status: match + notes: "yargs .implies() tests whether the implied flag is present (validation.js keyExists: hasOwnProperty), not its value, so a 0 counts as given. Go tested the value (> 0) and let a lone 0 through." + + - id: up.terminal-columns-zero-implies-rows + lane: contract + command: up + priority: p1 + docker_required: false + ts_cmd: "up --workspace-folder src/test/configs/image --terminal-columns 0" + asserts: [exit_code, stdout_normalized, stderr_normalized] + class: implies-validation + current_status: match + notes: "yargs .implies() tests whether the implied flag is present (validation.js keyExists: hasOwnProperty), not its value, so a 0 counts as given. Go tested the value (> 0) and let a lone 0 through." + + - id: set-up.terminal-rows-zero-implies-columns + lane: contract + command: set-up + priority: p1 + docker_required: false + ts_cmd: "set-up --container-id fake --terminal-rows 0" + asserts: [exit_code, stdout_normalized, stderr_normalized] + class: implies-validation + current_status: match + notes: "yargs .implies() tests whether the implied flag is present (validation.js keyExists: hasOwnProperty), not its value, so a 0 counts as given. Go tested the value (> 0) and let a lone 0 through." + + - id: run-user-commands.terminal-columns-zero-implies-rows + lane: contract + command: run-user-commands + priority: p1 + docker_required: false + ts_cmd: "run-user-commands --workspace-folder src/test/configs/image --terminal-columns 0" + asserts: [exit_code, stdout_normalized, stderr_normalized] + class: implies-validation + current_status: match + notes: "yargs .implies() tests whether the implied flag is present (validation.js keyExists: hasOwnProperty), not its value, so a 0 counts as given. Go tested the value (> 0) and let a lone 0 through." + + - id: exec.terminal-rows-zero-implies-columns + lane: contract + command: exec + priority: p1 + docker_required: false + ts_cmd: "exec --workspace-folder src/test/configs/image --terminal-rows 0 echo hello" + asserts: [exit_code, stderr_normalized] + class: implies-validation + current_status: match + notes: "yargs .implies() tests whether the implied flag is present (validation.js keyExists: hasOwnProperty), not its value, so a 0 counts as given. Go tested the value (> 0) and let a lone 0 through." + + - id: outdated.terminal-rows-zero-implies-columns + lane: contract + command: outdated + priority: p1 + docker_required: false + ts_cmd: "outdated --workspace-folder src/test/configs/image --terminal-rows 0" + asserts: [exit_code, stdout_normalized, stderr_normalized] + class: implies-validation + current_status: match + notes: "yargs .implies() tests whether the implied flag is present (validation.js keyExists: hasOwnProperty), not its value, so a 0 counts as given. Go tested the value (> 0) and let a lone 0 through." + + - id: outdated.terminal-columns-implies-rows + lane: contract + command: outdated + priority: p1 + docker_required: false + ts_cmd: "outdated --workspace-folder src/test/configs/image --terminal-columns 80" + asserts: [exit_code, stdout_normalized, stderr_normalized] + class: implies-validation + current_status: match + notes: "TS outdated declares terminal-columns/terminal-rows with implies, as its siblings do (devContainersSpecCLI.ts outdatedOptions); Go outdated did not check the pair at all." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 9f16fb8..e5f5e61 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -36,8 +36,8 @@ type execOpts struct { logFile string terminalLogFile string remoteEnvs []string - terminalColumns int - terminalRows int + terminalColumns givenInt + terminalRows givenInt defaultUserEnvProbe string } @@ -98,8 +98,8 @@ func newExecCmd() *cobra.Command { f.Bool("skip-feature-auto-mapping", false, "") _ = f.MarkHidden("skip-feature-auto-mapping") // hidden testing flag (TS parity) f.String("user-data-folder", "", "") - f.IntVar(&opts.terminalColumns, "terminal-columns", 0, "") - f.IntVar(&opts.terminalRows, "terminal-rows", 0, "") + f.Var(&opts.terminalColumns, "terminal-columns", "") + f.Var(&opts.terminalRows, "terminal-rows", "") addLogFileFlags(cmd, &opts.logFile, &opts.terminalLogFile) return cmd diff --git a/internal/cli/outdated.go b/internal/cli/outdated.go index 903965c..22ec8ca 100644 --- a/internal/cli/outdated.go +++ b/internal/cli/outdated.go @@ -48,6 +48,9 @@ func newOutdatedCmd() *cobra.Command { Use: "outdated", Short: "Show current and available versions", RunE: func(cmd *cobra.Command, args []string) error { + if err := validateTerminalImplications(opts.terminalColumns, opts.terminalRows); err != nil { + return err + } // 0.88: workspace-folder defaults to cwd when not provided. if opts.workspaceFolder == "" { opts.workspaceFolder, _ = os.Getwd() @@ -63,8 +66,8 @@ func newOutdatedCmd() *cobra.Command { f.StringVar(&opts.logLevel, "log-level", "info", "Log level.") f.StringVar(&opts.logFormat, "log-format", "text", "Log format.") f.String("user-data-folder", "", "") - f.Int("terminal-columns", 0, "") - f.Int("terminal-rows", 0, "") + f.Var(&opts.terminalColumns, "terminal-columns", "") + f.Var(&opts.terminalRows, "terminal-rows", "") addLogFileFlags(cmd, &opts.logFile, &opts.terminalLogFile) return cmd @@ -79,6 +82,8 @@ type outdatedOpts struct { logFormat string logFile string terminalLogFile string + terminalColumns givenInt + terminalRows givenInt } type outdatedEntry struct { diff --git a/internal/cli/read_configuration.go b/internal/cli/read_configuration.go index b792049..ba4e951 100644 --- a/internal/cli/read_configuration.go +++ b/internal/cli/read_configuration.go @@ -49,8 +49,8 @@ type readConfigOpts struct { dockerPath string additionalFeatures string skipFeatureAutoMapping bool - terminalColumns int - terminalRows int + terminalColumns givenInt + terminalRows givenInt cacheKey bool } @@ -82,8 +82,8 @@ func newReadConfigurationCmd() *cobra.Command { f.String("user-data-folder", "", "Host path to persisted data.") f.StringVar(&opts.containerID, "container-id", "", "Container ID.") f.StringArrayVar(&opts.idLabels, "id-label", nil, "Id label(s).") - f.IntVar(&opts.terminalColumns, "terminal-columns", 0, "Terminal columns.") - f.IntVar(&opts.terminalRows, "terminal-rows", 0, "Terminal rows.") + f.Var(&opts.terminalColumns, "terminal-columns", "Terminal columns.") + f.Var(&opts.terminalRows, "terminal-rows", "Terminal rows.") f.StringVar(&opts.additionalFeatures, "additional-features", "", "Additional features JSON.") f.BoolVar(&opts.skipFeatureAutoMapping, "skip-feature-auto-mapping", false, "") cmd.Flags().MarkHidden("skip-feature-auto-mapping") diff --git a/internal/cli/run_user_commands.go b/internal/cli/run_user_commands.go index 5b0e5ef..8fc41fa 100644 --- a/internal/cli/run_user_commands.go +++ b/internal/cli/run_user_commands.go @@ -35,8 +35,8 @@ type runUserCommandsOpts struct { defaultUserEnvProbe string containerSessionDataFolder string secretsFile string - terminalColumns int - terminalRows int + terminalColumns givenInt + terminalRows givenInt } func newRunUserCommandsCmd() *cobra.Command { @@ -79,8 +79,8 @@ func newRunUserCommandsCmd() *cobra.Command { f.StringVar(&opts.containerSessionDataFolder, "container-session-data-folder", "", "") f.StringVar(&opts.secretsFile, "secrets-file", "", "") f.String("user-data-folder", "", "") - f.IntVar(&opts.terminalColumns, "terminal-columns", 0, "") - f.IntVar(&opts.terminalRows, "terminal-rows", 0, "") + f.Var(&opts.terminalColumns, "terminal-columns", "") + f.Var(&opts.terminalRows, "terminal-rows", "") addLogFileFlags(cmd, &opts.logFile, &opts.terminalLogFile) return cmd diff --git a/internal/cli/run_user_commands_held_test.go b/internal/cli/run_user_commands_held_test.go index 8d6620b..2ee3db8 100644 --- a/internal/cli/run_user_commands_held_test.go +++ b/internal/cli/run_user_commands_held_test.go @@ -30,7 +30,7 @@ func TestPrepareRunUserCommandsOptsRejectsInvalidFlags(t *testing.T) { {"id-label", func(o *runUserCommandsOpts) { o.idLabels = []string{"broken"} }, "id-label must match"}, {"remote-env", func(o *runUserCommandsOpts) { o.remoteEnvs = []string{"broken"} }, "remote-env must match"}, {"default-user-env-probe", func(o *runUserCommandsOpts) { o.defaultUserEnvProbe = "broken" }, "default-user-env-probe"}, - {"terminal-columns", func(o *runUserCommandsOpts) { o.terminalColumns = 80 }, "terminal-columns -> terminal-rows"}, + {"terminal-columns", func(o *runUserCommandsOpts) { o.terminalColumns = givenInt{value: 80, given: true} }, "terminal-columns -> terminal-rows"}, } { t.Run(tc.name, func(t *testing.T) { opts := validRunUserCommandsOpts() diff --git a/internal/cli/setup.go b/internal/cli/setup.go index b2a3197..14180ee 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -36,8 +36,8 @@ type setUpOpts struct { remoteEnvs []string defaultUserEnvProbe string containerSessionDataFolder string - terminalColumns int - terminalRows int + terminalColumns givenInt + terminalRows givenInt } func newSetUpCmd() *cobra.Command { @@ -70,8 +70,8 @@ func newSetUpCmd() *cobra.Command { f.String("user-data-folder", "", "") f.StringArrayVar(&opts.remoteEnvs, "remote-env", nil, "Remote env vars.") f.StringVar(&opts.containerSessionDataFolder, "container-session-data-folder", "", "") - f.IntVar(&opts.terminalColumns, "terminal-columns", 0, "") - f.IntVar(&opts.terminalRows, "terminal-rows", 0, "") + f.Var(&opts.terminalColumns, "terminal-columns", "") + f.Var(&opts.terminalRows, "terminal-rows", "") addLogFileFlags(cmd, &opts.logFile, &opts.terminalLogFile) return cmd diff --git a/internal/cli/up.go b/internal/cli/up.go index 5c65da7..a62c2d3 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -61,8 +61,8 @@ type upOpts struct { dotfilesTarget string omitSyntaxDirective bool omitConfigRemoteEnvFromMeta bool - terminalColumns int - terminalRows int + terminalColumns givenInt + terminalRows givenInt secretsFile string experimentalLockfile bool experimentalFrozenLockfile bool @@ -172,8 +172,8 @@ func newUpCmd() *cobra.Command { } { _ = f.MarkHidden(h) } - f.IntVar(&opts.terminalColumns, "terminal-columns", 0, "") - f.IntVar(&opts.terminalRows, "terminal-rows", 0, "") + f.Var(&opts.terminalColumns, "terminal-columns", "") + f.Var(&opts.terminalRows, "terminal-rows", "") addLogFileFlags(cmd, &opts.logFile, &opts.terminalLogFile) return cmd diff --git a/internal/cli/validate.go b/internal/cli/validate.go index 4ef6ca0..eaf058c 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "slices" + "strconv" "strings" ) @@ -39,18 +40,40 @@ func validateMounts(mounts []string) error { return nil } -// validateTerminalImplications checks bidirectional implications between -// terminal-columns and terminal-rows (matches yargs .implies()). -func validateTerminalImplications(columns, rows int) error { - if columns > 0 && rows == 0 { +// validateTerminalImplications checks that --terminal-columns and +// --terminal-rows are given together. Like yargs .implies(), it tests whether +// each flag is present, not its value: --terminal-rows 0 alone fails too. +func validateTerminalImplications(columns, rows givenInt) error { + if columns.given && !rows.given { return fmt.Errorf("Implications failed:\n terminal-columns -> terminal-rows") } - if rows > 0 && columns == 0 { + if rows.given && !columns.given { return fmt.Errorf("Implications failed:\n terminal-rows -> terminal-columns") } return nil } +// givenInt is a number flag that remembers whether it was given, for checks +// that test a flag's presence (yargs .implies()). It parses its value as +// pflag's IntVar does. +type givenInt struct { + value int + given bool +} + +func (g *givenInt) Set(s string) error { + v, err := strconv.ParseInt(s, 0, 64) + if err != nil { + return err + } + g.value, g.given = int(v), true + return nil +} + +func (g *givenInt) String() string { return strconv.Itoa(g.value) } + +func (g *givenInt) Type() string { return "int" } + // validateEnum checks that a flag value is one of the allowed choices. // logLevels and logFormats are the choices TS yargs declares for --log-level // and --log-format. diff --git a/internal/cli/validate_repo_held_test.go b/internal/cli/validate_repo_held_test.go index e59d508..94517a3 100644 --- a/internal/cli/validate_repo_held_test.go +++ b/internal/cli/validate_repo_held_test.go @@ -52,19 +52,23 @@ func TestValidateMounts(t *testing.T) { } } -// --terminal-columns and --terminal-rows imply each other, as yargs .implies(). +// --terminal-columns and --terminal-rows imply each other, as yargs .implies(): +// a flag counts when it is given, whatever its value, so a 0 counts too +// (yargs validation.js keyExists tests hasOwnProperty). func TestValidateTerminalImplications(t *testing.T) { + absent := givenInt{} + given := func(n int) givenInt { return givenInt{value: n, given: true} } cases := []struct { - cols, rows int + cols, rows givenInt want string }{ - {0, 0, ""}, - {80, 24, ""}, - {80, 0, "Implications failed:\n terminal-columns -> terminal-rows"}, - {0, 24, "Implications failed:\n terminal-rows -> terminal-columns"}, - {1, 0, "Implications failed:\n terminal-columns -> terminal-rows"}, - {0, 1, "Implications failed:\n terminal-rows -> terminal-columns"}, - {1, 1, ""}, + {absent, absent, ""}, + {given(80), given(24), ""}, + {given(0), given(0), ""}, + {given(80), absent, "Implications failed:\n terminal-columns -> terminal-rows"}, + {absent, given(24), "Implications failed:\n terminal-rows -> terminal-columns"}, + {given(0), absent, "Implications failed:\n terminal-columns -> terminal-rows"}, + {absent, given(0), "Implications failed:\n terminal-rows -> terminal-columns"}, } for _, c := range cases { err := validateTerminalImplications(c.cols, c.rows) @@ -73,11 +77,27 @@ func TestValidateTerminalImplications(t *testing.T) { got = err.Error() } if got != c.want { - t.Errorf("validateTerminalImplications(%d, %d) = %q, want %q", c.cols, c.rows, got, c.want) + t.Errorf("validateTerminalImplications(%+v, %+v) = %q, want %q", c.cols, c.rows, got, c.want) } } } +// A givenInt reads its value as pflag's IntVar does and records that it was +// given; a value that is no number is an error and leaves it not given. +func TestGivenIntParsesLikeIntVar(t *testing.T) { + var g givenInt + if g.String() != "0" || g.Type() != "int" { + t.Errorf("zero givenInt = %q of type %q, want \"0\" of type \"int\"", g.String(), g.Type()) + } + if err := g.Set("0x10"); err != nil || g.value != 16 || !g.given || g.String() != "16" { + t.Errorf("Set(\"0x10\") = %v, givenInt %+v; want 16 and given", err, g) + } + var bad givenInt + if err := bad.Set("abc"); err == nil || bad.given { + t.Errorf("Set(\"abc\") = %v, givenInt %+v; want an error and not given", err, bad) + } +} + // The first flag whose value is not one of its choices is the one reported. func TestValidateEnums(t *testing.T) { if err := validateEnums(enumFlag{"log-level", "debug", logLevels}, enumFlag{"log-format", "json", logFormats}); err != nil { From 58281eb5f26430a15b54ebf7dbcb86a765c3934e Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:52:27 -0300 Subject: [PATCH 11/37] fix(cli): report a compose build's image name as the reference does Without --image-name, the reference's doBuild sets imageNameResult = originalImageName for a Docker Compose configuration, so the result reads "imageName":"", a string; with --image-name it is the array of the given names (devContainersSpecCLI.ts). This CLI always wrote an array. A consumer that read the compose case as an array must read a string now, as it already had to with the reference (docs/migration.md). Co-Authored-By: Claude Opus 5.5 --- docs/migration.md | 4 ++++ docs/parity/parity-matrix.yaml | 12 ++++++++++++ internal/cli/build.go | 12 +++++++++++- internal/cli/build_held_test.go | 22 ++++++++++++++++++++++ internal/cli/e2e_fixes_test.go | 8 ++++---- 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 2f887a2..bcbf0aa 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -86,6 +86,10 @@ runs the official CLI is unaffected: matching container. - Features are staged under `/devcontainercli-/container-features/`, one folder per build, and that folder is kept as the official CLI keeps it. +- `build` on a Docker Compose configuration without `--image-name` reports + `"imageName"` as the image's name, a string, as the official CLI does. Earlier + releases wrapped it in an array. With `--image-name`, and for image and + Dockerfile configurations, it stays an array. - The text log on stderr follows the official CLI's two formats. With stdout redirected or piped, each line starts with its UTC time (`[2026-01-02T03:04:05.678Z] text`) and Stop lines appear at `--log-level debug` diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index f5f9434..884825c 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -992,6 +992,18 @@ initial_cases: current_status: match notes: "TS outdated declares terminal-columns/terminal-rows with implies, as its siblings do (devContainersSpecCLI.ts outdatedOptions); Go outdated did not check the pair at all." + - id: build.compose-image-name-is-a-string-without-image-name + lane: runtime + command: build + priority: p1 + docker_required: true + ts_cmd: "build --workspace-folder src/test/configs/compose-image-without-features-minimal --buildkit=never" + setup_cmd: "echo DOCKER_DEFAULT_PLATFORM=linux/amd64" + asserts: [exit_code, stdout_normalized] + class: build-runtime + current_status: match + notes: "Without --image-name, TS doBuild reports a Compose build's image as a string (imageNameResult = originalImageName); with it, as the array of --image-name values. Go always wrote an array. The image keeps the name the sibling compose-image-without-features-minimal cases give it, so no cleanup removes it from under them." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/build.go b/internal/cli/build.go index 32953d0..359ab16 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -222,11 +222,21 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { return writeSuccessJSON(out, map[string]any{ "outcome": "success", - "imageName": imageNameResult, + "imageName": buildResultImageName(cfg.IsComposeConfig(), opts.imageNames, imageNameResult), "ociAuthDiagnostics": ociAuthDiagnostics(ctx), }) } +// buildResultImageName is the result's imageName: the image names built, except +// that TS reports the one image a Compose build made without --image-name as a +// string (doBuild: imageNameResult = originalImageName). +func buildResultImageName(compose bool, argImageNames, built []string) any { + if compose && len(argImageNames) == 0 { + return built[0] + } + return built +} + // foldFrozenLockfile folds the deprecated --experimental-frozen-lockfile into // --frozen-lockfile, as TS does (effectiveFrozenLockfile = frozenLockfile || // experimentalFrozenLockfile); the Feature build reads the folded value. diff --git a/internal/cli/build_held_test.go b/internal/cli/build_held_test.go index ffe6f27..a36af09 100644 --- a/internal/cli/build_held_test.go +++ b/internal/cli/build_held_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -255,3 +256,24 @@ func TestPrepareDockerfileBuildStage(t *testing.T) { t.Errorf("no FROM: err = %v", err) } } + +// A Compose build without --image-name reports its image as a string, as TS +// doBuild does (imageNameResult = originalImageName); with --image-name, and +// for any other configuration, the names are an array. +func TestBuildResultImageName(t *testing.T) { + for _, tc := range []struct { + name string + compose bool + argNames []string + built []string + want any + }{ + {"compose without --image-name", true, nil, []string{"proj-app"}, "proj-app"}, + {"compose with --image-name", true, []string{"a"}, []string{"a"}, []string{"a"}}, + {"image without --image-name", false, nil, []string{"vsc-x"}, []string{"vsc-x"}}, + } { + if got := buildResultImageName(tc.compose, tc.argNames, tc.built); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s: imageName = %#v, want %#v", tc.name, got, tc.want) + } + } +} diff --git a/internal/cli/e2e_fixes_test.go b/internal/cli/e2e_fixes_test.go index 08fdc4e..7f44a97 100644 --- a/internal/cli/e2e_fixes_test.go +++ b/internal/cli/e2e_fixes_test.go @@ -141,11 +141,11 @@ func TestE2E_ComposeBuildLabel(t *testing.T) { buildOut := runCLI(t, "build", "--workspace-folder", ws, "--label", "e2e.compose.label=applied") assertOutcome(t, buildOut, "success") - names, _ := parseJSON(t, buildOut)["imageName"].([]any) - if len(names) == 0 { - t.Fatal("no imageName in build output") + // Without --image-name, TS reports a Compose build's image as a string. + img, _ := parseJSON(t, buildOut)["imageName"].(string) + if img == "" { + t.Fatalf("imageName is not the image's name as a string:\n%s", buildOut) } - img, _ := names[0].(string) defer exec.Command("docker", "rmi", "-f", img).Run() out, err := exec.Command("docker", "inspect", "--format", `{{index .Config.Labels "e2e.compose.label"}}`, img).CombinedOutput() From 6b3e87e072518f9c1665ebdff1ca75d0d8a630eb Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 21:58:17 -0300 Subject: [PATCH 12/37] fix(oci): parse cross-origin auth hosts as WHATWG URL does The reference normalizes --allow-cross-origin-auth-host authorities with new URL(`https://${authority}`) and compares registry and realm hosts by their WHATWG `host`. net/url kept an explicit :443, refused forms Node accepts (a trailing "/", "\", dot segments, a bare "?" or "#", empty userinfo, tabs) and did no IDNA, IPv4 or IPv6 normalization, so a mapping TS trusts could fail to parse or never match the realm. Port the WHATWG host rules for http(s) URLs and use them both to normalize the configured entries and to key the registry and realm lookups, so default ports drop on both sides. The ts-oracle golden pins parseCrossOriginAuthHosts and isAllowedTokenServiceRealm. Co-Authored-By: Claude Opus 5.5 --- internal/oci/authpolicy.go | 62 +- internal/oci/authpolicy_oracle_test.go | 56 + .../oci/testdata/ts-oracle/authpolicy.json | 1959 +++++++++++++++++ internal/oci/whatwghost.go | 389 ++++ tools/tsoracle/oracle.ts | 7 + 5 files changed, 2452 insertions(+), 21 deletions(-) create mode 100644 internal/oci/authpolicy_oracle_test.go create mode 100644 internal/oci/testdata/ts-oracle/authpolicy.json create mode 100644 internal/oci/whatwghost.go diff --git a/internal/oci/authpolicy.go b/internal/oci/authpolicy.go index 3372e47..3cfb7e8 100644 --- a/internal/oci/authpolicy.go +++ b/internal/oci/authpolicy.go @@ -142,22 +142,28 @@ func ParseCrossOriginAuthHosts(entries []string) (map[string]map[string]bool, er return out, nil } -// normalizeHTTPSAuthority validates that authority is a bare host[:port] and -// returns it lower-cased. +// normalizeHTTPSAuthority is TS normalizeHttpsAuthority (httpOCIRegistry.ts): +// the WHATWG URL `host` of https://, lower-cased, refused when the +// URL carries userinfo, a path other than "/", a query or a fragment. func normalizeHTTPSAuthority(authority string) (string, error) { - invalid := fmt.Errorf("Invalid authority '%s'.", authority) - parsed, err := url.Parse("https://" + authority) - if err != nil || parsed.Host == "" { - return "", invalid - } - // url.Parse keeps the host as written, so userinfo, a path, a query or a - // fragment leaves Host short of the authority: TS normalizeHttpsAuthority's - // username/pathname/search/hash checks (httpOCIRegistry.ts) all reject - // through this one comparison. - if parsed.Host != authority { - return "", invalid - } - return strings.ToLower(parsed.Host), nil + host, err := parseHTTPSAuthority(authority) + if err != nil { + return "", fmt.Errorf("Invalid authority '%s'.", authority) + } + return strings.ToLower(host), nil +} + +// urlHost is the WHATWG URL `host` of u (the default port of its scheme +// dropped), the form TS compares registry and realm authorities in. +func urlHost(u *url.URL) (string, error) { + defaultPort := "" + switch strings.ToLower(u.Scheme) { + case "https": + defaultPort = "443" + case "http": + defaultPort = "80" + } + return whatwgHost(u.Host, defaultPort) } // isRegistryOrigin reports whether u addresses the registry of ociRef, treating @@ -193,21 +199,35 @@ func canonicalAuthority(u *url.URL) string { // to realm: the same authority over HTTPS (or HTTP on localhost), or an HTTPS auth // host explicitly mapped to that registry. func (p *AuthPolicy) allowsTokenServiceRealm(realm, registry *url.URL) bool { - if isAllowedSameAuthorityRealm(realm, registry) { + realmHost, err := urlHost(realm) + if err != nil { + return false + } + registryHost, err := urlHost(registry) + if err != nil { + return false + } + if isAllowedSameAuthorityRealm(realm.Scheme, realmHost, registryHost) { return true } if !strings.EqualFold(realm.Scheme, "https") { return false } - return p.crossOrigin[strings.ToLower(registry.Host)][strings.ToLower(realm.Host)] + return p.crossOrigin[registryHost][realmHost] } -func isAllowedSameAuthorityRealm(realm, registry *url.URL) bool { - if !strings.EqualFold(realm.Host, registry.Host) { +// isAllowedSameAuthorityRealm is TS isAllowedSameAuthorityRealm over WHATWG +// hosts: the realm shares the registry's host and is HTTPS, or HTTP on +// localhost. +func isAllowedSameAuthorityRealm(realmScheme, realmHost, registryHost string) bool { + if realmHost != registryHost { return false } - return strings.EqualFold(realm.Scheme, "https") || - (strings.EqualFold(realm.Scheme, "http") && strings.EqualFold(realm.Hostname(), "localhost")) + if strings.EqualFold(realmScheme, "https") { + return true + } + hostname, _, _ := strings.Cut(realmHost, ":") + return strings.EqualFold(realmScheme, "http") && hostname == "localhost" } // authPolicyTransport applies the OCI auth policy to one repository's traffic. diff --git a/internal/oci/authpolicy_oracle_test.go b/internal/oci/authpolicy_oracle_test.go new file mode 100644 index 0000000..856757d --- /dev/null +++ b/internal/oci/authpolicy_oracle_test.go @@ -0,0 +1,56 @@ +package oci + +import ( + "encoding/json" + "net/url" + "slices" + "testing" + + "github.com/devcontainers/cli/internal/log" + "github.com/devcontainers/cli/internal/tsoracle/oracletest" +) + +// TestTSOracleAuthPolicy asserts the cross-origin auth host parsing and the +// token realm check against the reference functions' results (go tool task +// testdata:ts-oracle): authorities go through Node's WHATWG URL parser, so +// default ports drop, hosts normalize and a path resolving to "/" is accepted. +func TestTSOracleAuthPolicy(t *testing.T) { + oracletest.Run(t, "testdata/ts-oracle/authpolicy.json", map[string]oracletest.Func{ + "parseCrossOriginAuthHosts": func(args []json.RawMessage) (any, error) { + var entries []string + if err := oracletest.Args(args, &entries); err != nil { + return nil, err + } + hosts, err := ParseCrossOriginAuthHosts(entries) + if err != nil { + return nil, err + } + out := map[string][]string{} + for registry, authHosts := range hosts { + for authHost := range authHosts { + out[registry] = append(out[registry], authHost) + } + slices.Sort(out[registry]) + } + return out, nil + }, + // TS returns false when either URL fails to parse. + "isAllowedTokenServiceRealm": func(args []json.RawMessage) (any, error) { + var realm, registry string + var entries []string + if err := oracletest.Args(args, &realm, ®istry, &entries); err != nil { + return nil, err + } + policy, err := NewAuthPolicy(false, entries, log.Null) + if err != nil { + return false, nil + } + realmURL, rerr := url.Parse(realm) + registryURL, gerr := url.Parse(registry) + if rerr != nil || gerr != nil { + return false, nil + } + return policy.allowsTokenServiceRealm(realmURL, registryURL), nil + }, + }) +} diff --git a/internal/oci/testdata/ts-oracle/authpolicy.json b/internal/oci/testdata/ts-oracle/authpolicy.json new file mode 100644 index 0000000..30ded83 --- /dev/null +++ b/internal/oci/testdata/ts-oracle/authpolicy.json @@ -0,0 +1,1959 @@ +{ + "comment": "httpOCIRegistry.ts parseCrossOriginAuthHosts (normalizeHttpsAuthority) and isAllowedTokenServiceRealm; ported in authpolicy.go (ParseCrossOriginAuthHosts, normalizeHTTPSAuthority, AuthPolicy.allowsTokenServiceRealm) and whatwghost.go. Authorities go through Node's WHATWG URL parser.", + "cases": [ + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=AUTH.Example.COM" + ] + ], + "result": { + "registry.example": [ + "auth.example.com" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example?" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example#" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:443" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:0443" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:00000000000000443" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:80" + ] + ], + "result": { + "registry.example": [ + "auth.example:80" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:080" + ] + ], + "result": { + "registry.example": [ + "auth.example:80" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:65535" + ] + ], + "result": { + "registry.example": [ + "auth.example:65535" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:65536" + ] + ], + "error": "Invalid authority 'auth.example:65536'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example\\" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/." + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/.." + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/./" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/x/.." + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/x/../" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/%2e" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/.%2E/" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/?" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example?#" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:443/" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=/auth.example" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=\\auth.example" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=//auth.example" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=@auth.example" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=:@auth.example" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example\t" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example " + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.ex\nample" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=\tauth.example" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=x%41y.example" + ] + ], + "result": { + "registry.example": [ + "xay.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=bücher.example" + ] + ], + "result": { + "registry.example": [ + "xn--bcher-kva.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=xn--bcher-kva.example" + ] + ], + "result": { + "registry.example": [ + "xn--bcher-kva.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=A.example" + ] + ], + "result": { + "registry.example": [ + "a.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=a­b.example" + ] + ], + "result": { + "registry.example": [ + "ab.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=faß.example" + ] + ], + "result": { + "registry.example": [ + "xn--fa-hia.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=0x7f.1" + ] + ], + "result": { + "registry.example": [ + "127.0.0.1" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=127.1" + ] + ], + "result": { + "registry.example": [ + "127.0.0.1" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=0" + ] + ], + "result": { + "registry.example": [ + "0.0.0.0" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=1.2.3." + ] + ], + "result": { + "registry.example": [ + "1.2.0.3" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=4294967295" + ] + ], + "result": { + "registry.example": [ + "255.255.255.255" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[::1]" + ] + ], + "result": { + "registry.example": [ + "[::1]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[::1]:443" + ] + ], + "result": { + "registry.example": [ + "[::1]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[0:0::1]:8443" + ] + ], + "result": { + "registry.example": [ + "[::1]:8443" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[ABCD::]" + ] + ], + "result": { + "registry.example": [ + "[abcd::]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[1:0:0:2:0:0:0:3]" + ] + ], + "result": { + "registry.example": [ + "[1:0:0:2::3]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[1:0:0:2:0:0:3:4]" + ] + ], + "result": { + "registry.example": [ + "[1::2:0:0:3:4]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[::ffff:1.2.3.4]" + ] + ], + "result": { + "registry.example": [ + "[::ffff:102:304]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[::1.2.3.4]" + ] + ], + "result": { + "registry.example": [ + "[::102:304]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth..example" + ] + ], + "result": { + "registry.example": [ + "auth..example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example." + ] + ], + "result": { + "registry.example": [ + "auth.example." + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=-" + ] + ], + "result": { + "registry.example": [ + "-" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=a_b.example" + ] + ], + "result": { + "registry.example": [ + "a_b.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=a;b.example" + ] + ], + "result": { + "registry.example": [ + "a;b.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=a$b.example" + ] + ], + "result": { + "registry.example": [ + "a$b.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=" + ] + ], + "error": "Invalid cross-origin auth host 'registry.example='. Expected '='." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=/" + ] + ], + "error": "Invalid authority '/'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/x" + ] + ], + "error": "Invalid authority 'auth.example/x'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/x/../y" + ] + ], + "error": "Invalid authority 'auth.example/x/../y'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example//" + ] + ], + "error": "Invalid authority 'auth.example//'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example?x" + ] + ], + "error": "Invalid authority 'auth.example?x'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example#x" + ] + ], + "error": "Invalid authority 'auth.example#x'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example#?" + ] + ], + "error": "Invalid authority 'auth.example#?'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example/#x" + ] + ], + "error": "Invalid authority 'auth.example/#x'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example\\x" + ] + ], + "error": "Invalid authority 'auth.example\\x'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=user@auth.example" + ] + ], + "error": "Invalid authority 'user@auth.example'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=:pw@auth.example" + ] + ], + "error": "Invalid authority ':pw@auth.example'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=::@auth.example" + ] + ], + "error": "Invalid authority '::@auth.example'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=@@auth.example" + ] + ], + "error": "Invalid authority '@@auth.example'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example@" + ] + ], + "error": "Invalid authority 'auth.example@'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=@" + ] + ], + "error": "Invalid authority '@'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:x" + ] + ], + "error": "Invalid authority 'auth.example:x'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:1:2" + ] + ], + "error": "Invalid authority 'auth.example:1:2'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth example" + ] + ], + "error": "Invalid authority 'auth example'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example= auth.example" + ] + ], + "error": "Invalid authority ' auth.example'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth='." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "=auth.example" + ] + ], + "error": "Invalid cross-origin auth host '=auth.example'. Expected '='." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=" + ] + ], + "error": "Invalid cross-origin auth host 'registry.example='. Expected '='." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=cafe.example" + ] + ], + "result": { + "registry.example": [ + "cafe.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth%" + ] + ], + "error": "Invalid authority 'auth%'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth%4" + ] + ], + "error": "Invalid authority 'auth%4'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.ex%41" + ] + ], + "result": { + "registry.example": [ + "auth.exa" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=%41uth.example" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=a€b.example" + ] + ], + "error": "Invalid authority 'a€b.example'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=xn--.example" + ] + ], + "error": "Invalid authority 'xn--.example'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=XN--.example" + ] + ], + "error": "Invalid authority 'XN--.example'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=XN--bcher-kva.example" + ] + ], + "result": { + "registry.example": [ + "xn--bcher-kva.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=%2e" + ] + ], + "result": { + "registry.example": [ + "." + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example.." + ] + ], + "result": { + "registry.example": [ + "auth.example.." + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.09" + ] + ], + "error": "Invalid authority 'auth.09'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=0x" + ] + ], + "result": { + "registry.example": [ + "0.0.0.0" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=0X10" + ] + ], + "result": { + "registry.example": [ + "0.0.0.16" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=0.08" + ] + ], + "error": "Invalid authority '0.08'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=010" + ] + ], + "result": { + "registry.example": [ + "0.0.0.8" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=0x10000000000000000" + ] + ], + "error": "Invalid authority '0x10000000000000000'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=99999999999999999999999" + ] + ], + "error": "Invalid authority '99999999999999999999999'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=1." + ] + ], + "result": { + "registry.example": [ + "0.0.0.1" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=1.2.3.4.0" + ] + ], + "error": "Invalid authority '1.2.3.4.0'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=1.2.256.4" + ] + ], + "error": "Invalid authority '1.2.256.4'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=1.255.3.4" + ] + ], + "result": { + "registry.example": [ + "1.255.3.4" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=1.2.3.4" + ] + ], + "result": { + "registry.example": [ + "1.2.3.4" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=1.2.3.4." + ] + ], + "result": { + "registry.example": [ + "1.2.3.4" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.ex\rample" + ] + ], + "result": { + "registry.example": [ + "auth.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=:443" + ] + ], + "error": "Invalid authority ':443'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[::1]:" + ] + ], + "result": { + "registry.example": [ + "[::1]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[::]" + ] + ], + "result": { + "registry.example": [ + "[::]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[1:2:3:4:5:6:7:8]" + ] + ], + "result": { + "registry.example": [ + "[1:2:3:4:5:6:7:8]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[1::]" + ] + ], + "result": { + "registry.example": [ + "[1::]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[0:1:0:0:1:0:0:0]" + ] + ], + "result": { + "registry.example": [ + "[0:1:0:0:1::]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[1:0:2:0:3:0:4:0]" + ] + ], + "result": { + "registry.example": [ + "[1:0:2:0:3:0:4:0]" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=[fe80::1%25eth0]" + ] + ], + "error": "Invalid authority '[fe80::1%25eth0]'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:+443" + ] + ], + "error": "Invalid authority 'auth.example:+443'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=auth.example:-1" + ] + ], + "error": "Invalid authority 'auth.example:-1'." + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "a=b=c" + ] + ], + "error": "Invalid cross-origin auth host 'a=b=c'. Expected '='." + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://registry.example/token", + "https://registry.example/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://registry.example:443/token", + "https://registry.example/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://registry.example/token", + "https://registry.example:443/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://REGISTRY.example/token", + "https://registry.example/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "http://registry.example/token", + "https://registry.example/v2/", + [] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://registry.example:8443/token", + "https://registry.example/v2/", + [] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "http://localhost:5000/token", + "http://localhost:5000/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "http://LOCALHOST:5000/token", + "http://localhost:5000/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "http://localhost/token", + "http://localhost:80/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "http://127.0.0.1:5000/token", + "http://127.0.0.1:5000/v2/", + [] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "http://[::1]:5000/token", + "http://[::1]:5000/v2/", + [] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://127.0.0.1:5000/token", + "https://127.0.0.1:5000/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.docker.io/token", + "https://registry-1.docker.io/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.docker.io:443/token", + "https://registry-1.docker.io/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "http://auth.docker.io/token", + "https://registry-1.docker.io/v2/", + [] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://gitlab.com/jwt/auth", + "https://registry.gitlab.com/v2/", + [] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "https://registry.example/v2/", + [] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "https://registry.example/v2/", + [ + "registry.example=auth.example" + ] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "https://registry.example/v2/", + [ + "registry.example=auth.example:443" + ] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example:443/token", + "https://registry.example/v2/", + [ + "registry.example=auth.example" + ] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "https://registry.example:443/v2/", + [ + "registry.example=auth.example" + ] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "https://registry.example/v2/", + [ + "registry.example:443=AUTH.example/" + ] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example:8443/token", + "https://registry.example/v2/", + [ + "registry.example=auth.example" + ] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example:8443/token", + "https://registry.example/v2/", + [ + "registry.example=auth.example:8443" + ] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "https://registry.example:5000/v2/", + [ + "registry.example=auth.example" + ] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "https://registry.example:5000/v2/", + [ + "registry.example:5000=auth.example" + ] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "http://registry.example/v2/", + [ + "registry.example=auth.example" + ] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "http://registry.example:80/v2/", + [ + "registry.example=auth.example" + ] + ], + "result": true + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "http://auth.example/token", + "https://registry.example/v2/", + [ + "registry.example=auth.example" + ] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://auth.example/token", + "https://registry.example/v2/", + [ + "registry.example=auth.example/x" + ] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "not a url", + "https://registry.example/v2/", + [] + ], + "result": false + }, + { + "fn": "isAllowedTokenServiceRealm", + "args": [ + "https://registry.example/token", + "not a url", + [] + ], + "result": false + } + ] +} diff --git a/internal/oci/whatwghost.go b/internal/oci/whatwghost.go new file mode 100644 index 0000000..b8d93bc --- /dev/null +++ b/internal/oci/whatwghost.go @@ -0,0 +1,389 @@ +package oci + +import ( + "encoding/hex" + "errors" + "fmt" + "net/netip" + "strconv" + "strings" + "unicode/utf8" + + "golang.org/x/net/idna" +) + +// The reference CLI parses registry and auth-host authorities with Node's +// WHATWG URL parser (new URL(...)), whose host rules differ from net/url's: +// it drops the scheme's default port, lower-cases and IDNA-maps domains, +// normalizes IPv4 and IPv6 literals, and tolerates a path that resolves to "/" +// or an empty query or fragment after the authority. This file ports the parts +// of https://url.spec.whatwg.org/ that decide the `host` of an http(s) URL. + +var errInvalidHost = errors.New("invalid WHATWG host") + +// whatwgHost is the `host` WHATWG URL gives an http(s) authority's +// host[:port] (no userinfo): the canonical host, then ":port" unless the port +// is defaultPort. +func whatwgHost(hostport, defaultPort string) (string, error) { + host, port, err := splitWHATWGHostPort(hostport, defaultPort) + if err != nil { + return "", err + } + if port != "" { + return host + ":" + port, nil + } + return host, nil +} + +// splitWHATWGHostPort is whatwgHost's host and port apart; port is "" when it +// is absent, empty or defaultPort. +func splitWHATWGHostPort(hostport, defaultPort string) (host, port string, err error) { + // The host state: a ':' outside an IPv6 literal's brackets starts the port. + rawHost, rawPort, inBrackets := hostport, "", false + for i, c := range hostport { + if c == '[' { + inBrackets = true + } else if c == ']' { + inBrackets = false + } else if c == ':' && !inBrackets { + rawHost, rawPort = hostport[:i], hostport[i+1:] + break + } + } + port, err = parseWHATWGPort(rawPort, defaultPort) + if err != nil { + return "", "", err + } + if rawHost == "" { + return "", "", errInvalidHost + } + host, err = parseWHATWGHost(rawHost) + if err != nil { + return "", "", err + } + return host, port, nil +} + +// parseWHATWGPort is the port state: ASCII digits whose value is at most +// 65535, serialized without leading zeros, and "" for the default port. +func parseWHATWGPort(s, defaultPort string) (string, error) { + if s == "" { + return "", nil + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return "", errInvalidHost + } + } + // Digits only, so Atoi fails only out of range, returning the largest int. + n, _ := strconv.Atoi(s) + if n > 65535 { + return "", errInvalidHost + } + port := strconv.Itoa(n) + if port == defaultPort { + return "", nil + } + return port, nil +} + +// parseWHATWGHost is the host parser for a special (non-opaque) URL. +func parseWHATWGHost(input string) (string, error) { + if strings.HasPrefix(input, "[") { + if !strings.HasSuffix(input, "]") { + return "", errInvalidHost + } + return parseWHATWGIPv6(input[1 : len(input)-1]) + } + domain, err := domainToASCII(percentDecodeUTF8(input)) + if err != nil { + return "", err + } + if strings.ContainsFunc(domain, isForbiddenDomainCodePoint) { + return "", errInvalidHost + } + if endsInANumber(domain) { + return parseWHATWGIPv4(domain) + } + return domain, nil +} + +// percentDecodeUTF8 percent-decodes s (a '%' without two hex digits stays) and +// decodes the bytes as UTF-8, invalid sequences becoming U+FFFD. +func percentDecodeUTF8(s string) string { + var b []byte + for i := 0; i < len(s); i++ { + if s[i] == '%' && i+2 < len(s) { + if v, err := hex.DecodeString(s[i+1 : i+3]); err == nil { + b = append(b, v[0]) + i += 2 + continue + } + } + b = append(b, s[i]) + } + return strings.ToValidUTF8(string(b), string(utf8.RuneError)) +} + +// whatwgIDNA is UTS #46 ToASCII with the flags WHATWG domain to ASCII passes: +// CheckHyphens, UseSTD3ASCIIRules, Transitional_Processing and VerifyDnsLength +// off, CheckBidi and CheckJoiners on. +var whatwgIDNA = idna.New( + idna.MapForLookup(), + idna.Transitional(false), + idna.CheckHyphens(false), + idna.CheckJoiners(true), + idna.BidiRule(), + idna.StrictDomainName(false), + idna.VerifyDNSLength(false), +) + +// domainToASCII is WHATWG domain to ASCII (beStrict false). A pure-ASCII domain +// with no "xn--" label only lower-cases, as the standard allows. +func domainToASCII(domain string) (string, error) { + if isASCII(domain) && !hasPunycodeLabel(domain) { + return strings.ToLower(domain), nil + } + // x/net/idna decodes a bare "xn--" label to an empty one; UTS #46, as + // Node's ICU implements it, fails it as an empty Punycode label. + for label := range strings.SplitSeq(domain, ".") { + if strings.EqualFold(label, "xn--") { + return "", errInvalidHost + } + } + out, err := whatwgIDNA.ToASCII(domain) + if err != nil || out == "" { + return "", errInvalidHost + } + return out, nil +} + +func isASCII(s string) bool { + for _, r := range s { + if r >= utf8.RuneSelf { + return false + } + } + return true +} + +func hasPunycodeLabel(domain string) bool { + for label := range strings.SplitSeq(domain, ".") { + if strings.HasPrefix(strings.ToLower(label), "xn--") { + return true + } + } + return false +} + +// isForbiddenDomainCodePoint is the WHATWG forbidden domain code point set: +// the forbidden host code points, C0 controls, '%' and DEL. +func isForbiddenDomainCodePoint(r rune) bool { + return r <= 0x20 || r == 0x7f || strings.ContainsRune("#%/:<>?@[\\]^|", r) +} + +// endsInANumber is the WHATWG ends-in-a-number checker: the last label +// (ignoring one trailing empty label) is all digits or parses as an IPv4 +// number. +func endsInANumber(domain string) bool { + labels := ipv4Parts(domain) + last := labels[len(labels)-1] + if last != "" && strings.Trim(last, "0123456789") == "" { + return true + } + _, err := parseIPv4Number(last) + return err == nil +} + +// parseIPv4Number parses one IPv4 part: 0x-prefixed hex (an empty remainder is +// 0), 0-prefixed octal, or decimal. +func parseIPv4Number(s string) (uint64, error) { + if s == "" { + return 0, errInvalidHost + } + base := 10 + switch { + case len(s) >= 2 && (s[:2] == "0x" || s[:2] == "0X"): + s, base = s[2:], 16 + if s == "" { + return 0, nil + } + case len(s) >= 2 && s[0] == '0': + s, base = s[1:], 8 + } + n, err := strconv.ParseUint(s, base, 64) + if errors.Is(err, strconv.ErrSyntax) { + return 0, errInvalidHost + } + // Out of range, ParseUint returns the largest uint64: every digit is + // valid and the value exceeds any IPv4 part, as WHATWG has it. + return n, nil +} + +// ipv4Parts splits a domain on '.', dropping one trailing empty label (the +// host is never empty, so neither is the result). +func ipv4Parts(domain string) []string { + return strings.Split(strings.TrimSuffix(domain, "."), ".") +} + +// parseWHATWGIPv4 is the WHATWG IPv4 parser, serialized dotted-decimal. +func parseWHATWGIPv4(s string) (string, error) { + parts := ipv4Parts(s) + if len(parts) > 4 { + return "", errInvalidHost + } + numbers := make([]uint64, len(parts)) + for i, part := range parts { + n, err := parseIPv4Number(part) + if err != nil { + return "", err + } + if i < len(parts)-1 && n > 255 { + return "", errInvalidHost + } + numbers[i] = n + } + last := numbers[len(numbers)-1] + if last >= 1<<(8*(5-len(numbers))) { + return "", errInvalidHost + } + ipv4 := last + for i, n := range numbers[:len(numbers)-1] { + ipv4 += n << (8 * (3 - i)) + } + return fmt.Sprintf("%d.%d.%d.%d", byte(ipv4>>24), byte(ipv4>>16), byte(ipv4>>8), byte(ipv4)), nil +} + +// parseWHATWGIPv6 parses the inside of an IPv6 literal and serializes it the +// WHATWG way: lower-case hex pieces, the first longest run of two or more zero +// pieces compressed to "::", and no dotted IPv4 tail. +func parseWHATWGIPv6(s string) (string, error) { + addr, err := netip.ParseAddr(s) + if err != nil || !addr.Is6() || addr.Zone() != "" { + return "", errInvalidHost + } + b := addr.As16() + var pieces [8]uint16 + for i := range pieces { + pieces[i] = uint16(b[2*i])<<8 | uint16(b[2*i+1]) + } + // The first longest run of zero pieces; only a run of two or more + // compresses. + var start, length int + for i := range pieces { + n := 0 + for i+n < len(pieces) && pieces[i+n] == 0 { + n++ + } + if n > length { + start, length = i, n + } + } + compress := length >= 2 + var out strings.Builder + out.WriteByte('[') + for i := 0; i < len(pieces); i++ { + if compress && i == start { + out.WriteString("::") + i += length - 1 + continue + } + if i > 0 && (!compress || i != start+length) { + out.WriteByte(':') + } + out.WriteString(strconv.FormatUint(uint64(pieces[i]), 16)) + } + out.WriteByte(']') + return out.String(), nil +} + +var tabOrNewline = strings.NewReplacer("\t", "", "\n", "", "\r", "") + +// parseHTTPSAuthority is what TS normalizeHttpsAuthority reads from +// new URL(`https://${authority}`): its `host`, accepted only when the URL has +// no username or password, its pathname is "/" and its search and hash are +// empty. +func parseHTTPSAuthority(authority string) (string, error) { + // Basic URL parser preprocessing: trailing C0 controls and spaces are + // trimmed (the "https://" prefix leaves nothing leading to trim) and every + // ASCII tab or newline is removed. + s := strings.TrimRightFunc(authority, func(r rune) bool { return r <= 0x20 }) + s = tabOrNewline.Replace(s) + // The special authority ignore slashes state skips every leading '/' or '\'. + s = strings.TrimLeft(s, `/\`) + auth, rest := s, "" + if end := strings.IndexAny(s, `/\?#`); end != -1 { + auth, rest = s[:end], s[end:] + } + if at := strings.LastIndex(auth, "@"); at >= 0 { + // Userinfo before the last '@'; anything but "" or ":" makes a + // username or password (an extra '@' is a "%40" username). + if userinfo := auth[:at]; userinfo != "" && userinfo != ":" { + return "", errInvalidHost + } + auth = auth[at+1:] + } + if !resolvesToRootPath(rest) { + return "", errInvalidHost + } + return whatwgHost(auth, "443") +} + +// resolvesToRootPath reports whether what follows a special URL's authority +// leaves its pathname "/" and its search and hash empty: a path whose segments +// all resolve away (".", "..", empty last segment), then at most a bare '?' +// and a bare '#'. +func resolvesToRootPath(rest string) bool { + path := rest + if i := strings.IndexAny(rest, "?#"); i >= 0 { + path = rest[:i] + switch tail := rest[i:]; tail { + case "?", "#", "?#": + default: + return false + } + } + // The path start state consumes one leading separator. + if path != "" { + path = path[1:] + } + var segments []bool // true for an empty segment + for i, last := 0, false; !last; { + j := strings.IndexAny(path[i:], `/\`) + var segment string + if j == -1 { + segment, last = path[i:], true + } else { + segment = path[i : i+j] + i += j + 1 + } + switch { + case isDoubleDotSegment(segment): + if len(segments) > 0 { + segments = segments[:len(segments)-1] + } + if last { + segments = append(segments, true) + } + case isSingleDotSegment(segment): + if last { + segments = append(segments, true) + } + default: + segments = append(segments, segment == "") + } + } + return len(segments) == 1 && segments[0] +} + +func isSingleDotSegment(s string) bool { + return s == "." || strings.EqualFold(s, "%2e") +} + +func isDoubleDotSegment(s string) bool { + switch strings.ToLower(s) { + case "..", ".%2e", "%2e.", "%2e%2e": + return true + } + return false +} diff --git a/tools/tsoracle/oracle.ts b/tools/tsoracle/oracle.ts index 578559c..a9e5f6e 100644 --- a/tools/tsoracle/oracle.ts +++ b/tools/tsoracle/oracle.ts @@ -20,6 +20,7 @@ import { replaceTemplatedValues } from '../../reference/src/spec-configuration/c import { readVersionPrefix, convertMountToVolume, convertMountToVolumeTopLevelElement } from '../../reference/src/spec-node/dockerCompose'; import { qualifyImageName, isBuildxCacheToInline } from '../../reference/src/spec-node/utils'; import { logEventToTerminalText, createPlainLog } from '../../reference/src/spec-utils/log'; +import { parseCrossOriginAuthHosts, isAllowedTokenServiceRealm } from '../../reference/src/spec-configuration/httpOCIRegistry'; // escapeQuotesForShell, convertMountToVolume, convertMountToVolumeTopLevelElement // and replaceTemplatedValues are module-private in the reference; build.mjs exports them for this bundle. @@ -91,6 +92,12 @@ const fns: Record any> = { createPlainLog(t => { text = t; }, () => logLevel).event(e); return text; }, + // spec-configuration/httpOCIRegistry.ts: the registry → auth-hosts Map as an + // object of sorted arrays, so the golden is order-independent. + parseCrossOriginAuthHosts: (entries: string[]) => Object.fromEntries( + [...parseCrossOriginAuthHosts(entries)].map(([registry, hosts]) => [registry, [...hosts].sort()])), + isAllowedTokenServiceRealm: (realm: string, registryUrl: string, entries: string[]) => + isAllowedTokenServiceRealm(realm, registryUrl, entries), }; async function main() { From 8f55a981d1e66083c2d9a769ad886c6b4f65bd66 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:01:42 -0300 Subject: [PATCH 13/37] fix(oci)!: reach only a localhost registry over plain HTTP The reference picks the registry scheme with getRegistryScheme: plain HTTP when the WHATWG hostname is localhost, HTTPS otherwise. The Go client also sent 127.0.0.1 registries over plain HTTP, although Ref.Scheme, and with it the read-configuration featureRef.scheme, said https, and the auth policy's localhost-only HTTP realm rule assumed the reference's split. Derive PlainHTTP from Ref.Scheme. The hermetic registry fixtures now address httptest servers as localhost:, as the parity matrix already does for the reference. BREAKING CHANGE: a plain-HTTP registry referred to as 127.0.0.1: is now reached over HTTPS; refer to it as localhost:. Co-Authored-By: Claude Opus 5.5 --- docs/migration.md | 3 ++ internal/cli/features_info_held_test.go | 15 ++++++-- internal/oci/authpolicy_test.go | 2 +- internal/oci/client_held_test.go | 2 +- internal/oci/client_timeout_test.go | 3 +- .../oci/image_inspect_nodocker_held_test.go | 3 +- internal/oci/image_inspect_test.go | 6 +-- internal/oci/localhost_registry_test.go | 19 ++++++++++ internal/oci/orasclient.go | 12 ++---- internal/oci/orasclient_test.go | 38 +++++++++++-------- internal/oci/push_registry_test.go | 8 ++-- 11 files changed, 70 insertions(+), 41 deletions(-) create mode 100644 internal/oci/localhost_registry_test.go diff --git a/docs/migration.md b/docs/migration.md index bcbf0aa..a5f4f37 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -97,6 +97,9 @@ runs the official CLI is unaffected: are colored and end in `\r\n`, and Stop lines appear only at `trace`. Earlier releases always wrote `[N ms] text` without color. A script that parses the log should read the timestamped form, or use `--log-format json`. +- Only a registry whose host is `localhost` is reached over plain HTTP. Earlier + releases also used plain HTTP for `127.0.0.1`, which is now addressed over HTTPS + like any other host. Refer to a local plain-HTTP registry as `localhost:`. ## Rollback diff --git a/internal/cli/features_info_held_test.go b/internal/cli/features_info_held_test.go index 0cab488..520ce80 100644 --- a/internal/cli/features_info_held_test.go +++ b/internal/cli/features_info_held_test.go @@ -6,15 +6,16 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net" "net/http" "net/http/httptest" "strings" "testing" ) -// readRegistry is a read-only OCI distribution registry over plain HTTP on -// 127.0.0.1 (which the OCI client addresses without TLS): it serves the tag -// lists and manifests a test puts in it and 404s anything else. +// readRegistry is a read-only OCI distribution registry over plain HTTP, +// addressed as localhost (which the OCI client reaches without TLS): it serves +// the tag lists and manifests a test puts in it and 404s anything else. type readRegistry struct { host string tags map[string][]string // repository -> tags @@ -26,7 +27,13 @@ func newReadRegistry(t *testing.T) *readRegistry { r := &readRegistry{tags: map[string][]string{}, manifests: map[string][]byte{}} srv := httptest.NewServer(http.HandlerFunc(r.serve)) t.Cleanup(srv.Close) - r.host = strings.TrimPrefix(srv.URL, "http://") + // httptest listens on 127.0.0.1, which the OCI client (as the reference) + // addresses over HTTPS; a localhost registry is plain HTTP. + _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + r.host = "localhost:" + port return r } diff --git a/internal/oci/authpolicy_test.go b/internal/oci/authpolicy_test.go index 4ff334a..cc49479 100644 --- a/internal/oci/authpolicy_test.go +++ b/internal/oci/authpolicy_test.go @@ -280,7 +280,7 @@ func TestClientHonorsAuthPolicyEndToEnd(t *testing.T) { })) defer srv.Close() - ref, err := ParseRef(strings.TrimPrefix(srv.URL, "http://") + "/devcontainers/features/go:1") + ref, err := ParseRef(localhostRegistry(t, srv) + "/devcontainers/features/go:1") if err != nil { t.Fatalf("parse ref: %v", err) } diff --git a/internal/oci/client_held_test.go b/internal/oci/client_held_test.go index 464d0f2..0a9b98b 100644 --- a/internal/oci/client_held_test.go +++ b/internal/oci/client_held_test.go @@ -27,7 +27,7 @@ func TestClientGivesUpOnRegistryThatNeverAnswers(t *testing.T) { defer func() { responseHeaderTimeout = orig }() c := NewClientWithAuthPolicy(log.Null, map[string]string{"DOCKER_CONFIG": t.TempDir()}, DefaultAuthPolicy()) - ref := &Ref{Registry: strings.TrimPrefix(srv.URL, "http://"), Path: "me/feat", Resource: "me/feat"} + ref := &Ref{Registry: localhostRegistry(t, srv), Path: "me/feat", Resource: "me/feat"} done := make(chan error, 1) go func() { _, err := c.FetchBlobContext(context.Background(), ref, "sha256:"+strings.Repeat("a", 64)) diff --git a/internal/oci/client_timeout_test.go b/internal/oci/client_timeout_test.go index da14f98..be3ea9f 100644 --- a/internal/oci/client_timeout_test.go +++ b/internal/oci/client_timeout_test.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "strings" "testing" "time" @@ -56,7 +55,7 @@ func TestGetPublishedTags_HangingRegistryTimesOut(t *testing.T) { defer func() { metadataOpTimeout = orig }() c := NewClient(log.New(log.Options{Level: log.LevelError}), nil) - ref := &Ref{Registry: strings.TrimPrefix(srv.URL, "http://"), Path: "me/feat", Resource: "me/feat"} + ref := &Ref{Registry: localhostRegistry(t, srv), Path: "me/feat", Resource: "me/feat"} done := make(chan error, 1) start := time.Now() diff --git a/internal/oci/image_inspect_nodocker_held_test.go b/internal/oci/image_inspect_nodocker_held_test.go index 90e8e43..673027e 100644 --- a/internal/oci/image_inspect_nodocker_held_test.go +++ b/internal/oci/image_inspect_nodocker_held_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "strings" "testing" "github.com/opencontainers/image-spec/specs-go" @@ -63,7 +62,7 @@ func TestInspectImageInRegistryIndexVariant(t *testing.T) { reg := newImageRegistry() srv := httptest.NewServer(reg) defer srv.Close() - host := strings.TrimPrefix(srv.URL, "http://") + host := localhostRegistry(t, srv) v6 := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "v6"}) v7 := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "v7"}) v6.Platform = &ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v6"} diff --git a/internal/oci/image_inspect_test.go b/internal/oci/image_inspect_test.go index d111bcc..f588f2f 100644 --- a/internal/oci/image_inspect_test.go +++ b/internal/oci/image_inspect_test.go @@ -119,7 +119,7 @@ func TestInspectImageInRegistryManifest(t *testing.T) { reg := newImageRegistry() srv := httptest.NewServer(reg) defer srv.Close() - host := strings.TrimPrefix(srv.URL, "http://") + host := localhostRegistry(t, srv) want := ImageConfig{User: "node", Env: []string{"PATH=/usr/bin"}, Labels: map[string]string{"devcontainer.metadata": `[{"remoteUser":"node"}]`}} reg.addImage(t, "devcontainers/base", "1", "application/vnd.docker.distribution.manifest.v2+json", want) @@ -141,7 +141,7 @@ func TestInspectImageInRegistryIndexSelectsPlatform(t *testing.T) { reg := newImageRegistry() srv := httptest.NewServer(reg) defer srv.Close() - host := strings.TrimPrefix(srv.URL, "http://") + host := localhostRegistry(t, srv) amd := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "amd"}) arm := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "arm"}) armV7 := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "armv7"}) @@ -175,7 +175,7 @@ func TestInspectImageInRegistryMissingManifest(t *testing.T) { reg := newImageRegistry() srv := httptest.NewServer(reg) defer srv.Close() - host := strings.TrimPrefix(srv.URL, "http://") + host := localhostRegistry(t, srv) _, err := testImageClient().InspectImageInRegistry(context.Background(), host+"/org/missing:1", ocispec.Platform{OS: "linux", Architecture: "amd64"}) if err == nil || err.Error() != "No manifest found for "+host+"/org/missing:1." { t.Fatalf("err = %v", err) diff --git a/internal/oci/localhost_registry_test.go b/internal/oci/localhost_registry_test.go new file mode 100644 index 0000000..9efb3d0 --- /dev/null +++ b/internal/oci/localhost_registry_test.go @@ -0,0 +1,19 @@ +package oci + +import ( + "net" + "net/http/httptest" + "testing" +) + +// localhostRegistry is srv's authority as a localhost registry host. httptest +// listens on 127.0.0.1, which the reference and Ref.Scheme address over HTTPS; +// only a localhost registry is plain HTTP. +func localhostRegistry(t *testing.T, srv *httptest.Server) string { + t.Helper() + _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + return "localhost:" + port +} diff --git a/internal/oci/orasclient.go b/internal/oci/orasclient.go index 5b0911f..ac63dad 100644 --- a/internal/oci/orasclient.go +++ b/internal/oci/orasclient.go @@ -11,13 +11,6 @@ import ( "oras.land/oras-go/v2/registry/remote/retry" ) -// isLocalRegistry reports whether the registry is a local/insecure one that -// serves plain HTTP (localhost / loopback), as opposed to a real HTTPS registry. -func isLocalRegistry(registry string) bool { - host, _, _ := strings.Cut(registry, ":") - return host == "localhost" || host == "127.0.0.1" -} - // repository builds an oras-go remote.Repository for the given ref, wired to the // devcontainer credential resolution (DEVCONTAINERS_OCI_AUTH → docker config / // credential helpers → GITHUB_TOKEN → platform default → anonymous) and the @@ -29,8 +22,9 @@ func (c *Client) repository(ref *Ref) (*remote.Repository, error) { if err != nil { return nil, err } - // Local/insecure registries (localhost, 127.0.0.1) speak plain HTTP. - repo.PlainHTTP = isLocalRegistry(ref.Registry) + // The reference addresses only a localhost registry over plain HTTP + // (getRegistryScheme); 127.0.0.1 and [::1] get HTTPS like any other host. + repo.PlainHTTP = ref.Scheme() == "http" // Reuse the client-level auth cache across operations. Fall back to a // per-repository cache if the client was constructed without one (e.g. a // zero-value Client in a test), so repository() never wires a nil cache. diff --git a/internal/oci/orasclient_test.go b/internal/oci/orasclient_test.go index 5cac429..3ce09cc 100644 --- a/internal/oci/orasclient_test.go +++ b/internal/oci/orasclient_test.go @@ -2,21 +2,29 @@ package oci import "testing" -func TestIsLocalRegistry(t *testing.T) { - tests := []struct { - registry string - want bool - }{ - {"localhost:5000", true}, - {"127.0.0.1:5000", true}, - {"localhost", true}, - {"ghcr.io", false}, - {"myregistry.azurecr.io", false}, - {"registry.example.com:443", false}, - } - for _, tt := range tests { - if got := isLocalRegistry(tt.registry); got != tt.want { - t.Errorf("isLocalRegistry(%q) = %v, want %v", tt.registry, got, tt.want) +// The reference speaks plain HTTP only to a registry whose host is localhost +// (getRegistryScheme, containerCollectionsOCI.ts); a loopback IP literal gets +// HTTPS like any other registry. The transport must follow the same scheme the +// read-configuration output reports (Ref.Scheme). +func TestRepositoryPlainHTTPOnlyForLocalhost(t *testing.T) { + for registry, want := range map[string]bool{ + "localhost": true, + "localhost:5000": true, + "127.0.0.1:5000": false, + "[::1]:5000": false, + "ghcr.io": false, + "registry.example.com:443": false, + } { + ref, err := ParseRef(registry + "/owner/features/f:1") + if err != nil { + t.Fatalf("%s: %v", registry, err) + } + repo, err := (&Client{}).repository(ref) + if err != nil { + t.Fatalf("%s: %v", registry, err) + } + if repo.PlainHTTP != want { + t.Errorf("%s: PlainHTTP = %v, want %v (scheme %q)", registry, repo.PlainHTTP, want, ref.Scheme()) } } } diff --git a/internal/oci/push_registry_test.go b/internal/oci/push_registry_test.go index 842db5d..b26507a 100644 --- a/internal/oci/push_registry_test.go +++ b/internal/oci/push_registry_test.go @@ -23,7 +23,7 @@ import ( // - inject a mid-loop manifest failure (failTags) to reproduce the // no-rollback partial-publish risk in PushArtifact's tag loop. // -// It runs on 127.0.0.1 (httptest), so isLocalRegistry() flips PlainHTTP on and +// It is addressed as localhost (httptest), so Ref.Scheme() flips PlainHTTP on and // no TLS is needed. type fakeOCIRegistry struct { mu sync.Mutex @@ -65,9 +65,9 @@ func (r *fakeOCIRegistry) start(t *testing.T) string { t.Helper() srv := httptest.NewServer(http.HandlerFunc(r.handle)) t.Cleanup(srv.Close) - r.srvURL = srv.URL - // Strip the scheme; the registry host is what ParseRef expects. - return strings.TrimPrefix(srv.URL, "http://") + host := localhostRegistry(t, srv) + r.srvURL = "http://" + host + return host } func (r *fakeOCIRegistry) authorized(req *http.Request) bool { From 23e29db36a6d341e149ac5265f5a0957742bb712 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:03:26 -0300 Subject: [PATCH 14/37] fix(cli): fail --cache-key on an unreadable features lockfile read-configuration --cache-key ignored every ReadLockfile error, so a malformed devcontainer-lock.json keyed as if no lockfile were committed and the key stopped tracking the pinned digests without a word. The reference's readLockfile rethrows everything but ENOENT, failing each command that reads the lockfile. Fail the key with the lockfile path and the parse or read error, and report it as read-configuration reports its other runtime failures: the error result on stdout, exit code 1. Without --cache-key the command still does not read the lockfile, as the reference does not. Co-Authored-By: Claude Opus 5.5 --- docs/go-only-features.md | 4 +++ internal/cli/cache_key.go | 18 ++++++++--- internal/cli/cache_key_repo_held_test.go | 34 ++++++++++++++++++++ internal/cli/read_configuration.go | 2 +- internal/cli/read_configuration_held_test.go | 29 +++++++++++++++++ 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/docs/go-only-features.md b/docs/go-only-features.md index 1c7c190..c5e1d2f 100644 --- a/docs/go-only-features.md +++ b/docs/go-only-features.md @@ -166,6 +166,10 @@ digests, when committed) and the proxy environment (`HTTP(S)_PROXY`, `NO_PROXY`, **hermetic** — no network, no registry resolution — so identical inputs yield the same key on any host. +A lockfile that exists but cannot be read or parsed fails the command (exit code 1, error +result on stdout) instead of being left out of the key, as the upstream CLI fails every +command that reads it. An empty lockfile pins nothing and keys like no lockfile. + The flag is **additive**: without it, the output is byte-for-byte identical to the upstream CLI; with it, a `cacheKey` field is added. diff --git a/internal/cli/cache_key.go b/internal/cli/cache_key.go index cae8bec..1097d89 100644 --- a/internal/cli/cache_key.go +++ b/internal/cli/cache_key.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "os" "path/filepath" @@ -55,11 +56,20 @@ func computeCacheKey(result *config.LoadResult, env map[string]string) (string, } // ReadLockfile's bool reports an empty marker file, which pins nothing; a - // lockfile with content comes back as a non-nil lock. - if lock, _, _ := features.ReadLockfile(cfg.ConfigFilePath); lock != nil { - if lockJSON, lerr := json.Marshal(lock); lerr == nil { - in.Lockfile = lockJSON + // lockfile with content comes back as a non-nil lock. A lockfile that + // cannot be read or parsed fails the key, as it fails every reference + // command that reads it (lockfile.ts readLockfile rethrows all but ENOENT): + // a key computed without it would not track the pinned digests. + lock, _, err := features.ReadLockfile(cfg.ConfigFilePath) + if err != nil { + return "", fmt.Errorf("read features lockfile %s: %w", features.LockfilePath(cfg.ConfigFilePath), err) + } + if lock != nil { + lockJSON, err := json.Marshal(lock) + if err != nil { + return "", err } + in.Lockfile = lockJSON } proxy := map[string]string{} diff --git a/internal/cli/cache_key_repo_held_test.go b/internal/cli/cache_key_repo_held_test.go index b85b43e..54937c9 100644 --- a/internal/cli/cache_key_repo_held_test.go +++ b/internal/cli/cache_key_repo_held_test.go @@ -3,6 +3,7 @@ package cli import ( "os" "path/filepath" + "strings" "testing" ) @@ -60,3 +61,36 @@ func TestComputeCacheKeyIgnoresEmptyProxy(t *testing.T) { t.Error("an empty proxy variable changed the key") } } + +// A lockfile that is not JSON fails the key instead of keying as if no +// lockfile were committed: the reference's readLockfile rethrows the parse +// error, and a key without the lockfile would not track its pinned digests. +func TestComputeCacheKeyFailsOnAMalformedLockfile(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "devcontainer.json") + lockPath := filepath.Join(dir, "devcontainer-lock.json") + if err := os.WriteFile(lockPath, []byte(`{"features":`), 0o644); err != nil { + t.Fatal(err) + } + key, err := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), nil) + if err == nil { + t.Fatalf("key = %q, want the lockfile parse error", key) + } + if !strings.Contains(err.Error(), "read features lockfile "+lockPath+": parse lockfile:") { + t.Errorf("err = %v, want it to name the lockfile and the parse failure", err) + } +} + +// A lockfile that exists but cannot be read fails the key too: only a missing +// lockfile (ENOENT) means none is committed. +func TestComputeCacheKeyFailsOnAnUnreadableLockfile(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "devcontainer.json") + lockPath := filepath.Join(dir, "devcontainer-lock.json") + if err := os.Mkdir(lockPath, 0o755); err != nil { + t.Fatal(err) + } + if _, err := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), nil); err == nil || !strings.Contains(err.Error(), lockPath) { + t.Errorf("err = %v, want the read failure naming %s", err, lockPath) + } +} diff --git a/internal/cli/read_configuration.go b/internal/cli/read_configuration.go index ba4e951..67a4e6a 100644 --- a/internal/cli/read_configuration.go +++ b/internal/cli/read_configuration.go @@ -159,7 +159,7 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) if opts.cacheKey && result != nil { key, keyErr := computeCacheKey(result, proxyEnvFromEnviron()) if keyErr != nil { - return fmt.Errorf("compute cache key: %w", keyErr) + return writeErrorResult(out, fmt.Sprintf("compute cache key: %v", keyErr)) } output["cacheKey"] = key } diff --git a/internal/cli/read_configuration_held_test.go b/internal/cli/read_configuration_held_test.go index 82738ed..c27d2f4 100644 --- a/internal/cli/read_configuration_held_test.go +++ b/internal/cli/read_configuration_held_test.go @@ -444,6 +444,35 @@ func TestReadConfigurationCommandContainerIDWithoutWorkspace(t *testing.T) { } } +// --cache-key over a malformed devcontainer-lock.json is a runtime failure: +// the error envelope on stdout naming the lockfile, exit 1. Without the flag +// the command does not read the lockfile, as the reference's +// read-configuration does not, and succeeds. +func TestReadConfigurationCacheKeyFailsOnAMalformedLockfile(t *testing.T) { + ws := writeConfigFixture(t, map[string]string{ + ".devcontainer/devcontainer.json": `{"image": "img:1"}`, + ".devcontainer/devcontainer-lock.json": `not json`, + }) + if _, err := runReadConfigurationCmd(t, "--workspace-folder", ws); err != nil { + t.Fatalf("without --cache-key: %v", err) + } + var stdout bytes.Buffer + root := NewRootCommand() + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs([]string{"read-configuration", "--workspace-folder", ws, "--cache-key"}) + wantExitCode(t, root.Execute(), 1) + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("stdout is not the error envelope: %v\n%s", err, stdout.String()) + } + lockPath := filepath.Join(ws, ".devcontainer", "devcontainer-lock.json") + if msg, _ := envelope["message"].(string); envelope["outcome"] != "error" || + !strings.HasPrefix(msg, "compute cache key: read features lockfile "+lockPath+": parse lockfile:") { + t.Errorf("stdout = %s, want the error outcome naming %s", stdout.String(), lockPath) + } +} + func TestReadConfigurationCommandFeaturesAndCacheKey(t *testing.T) { files := map[string]string{".devcontainer/devcontainer.json": `{"image": "img:1", "features": {"./feat": {}}}`} for k, v := range localFeatureFiles { From 8aaeb48138af3c2410f0c24b58ddcd9b877924a0 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:06:13 -0300 Subject: [PATCH 15/37] fix(pfs): extract template tarballs the way node-tar does ExtractTarGz re-rooted every entry under the destination before its path-traversal guard ran, so the guard never fired: "../up.txt" and "a/../mid.txt" were written as up.txt and mid.txt, and Windows roots stayed in the name. Writes also followed a symlink already inside the destination, so an entry beneath it landed outside. Treat entry paths as node-tar, which extracts the reference's blobs, does: skip an entry with a ".." segment (either separator) and strip '/', '\' and drive-letter roots. Write through an os.Root on the destination, so a path that would escape it through a symlink fails the extraction, and report directory and chmod errors instead of dropping them. Co-Authored-By: Claude Opus 5.5 --- internal/pfs/extract_paths_test.go | 160 +++++++++++++++++++++++++++++ internal/pfs/pfs.go | 64 ++++++++++-- 2 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 internal/pfs/extract_paths_test.go diff --git a/internal/pfs/extract_paths_test.go b/internal/pfs/extract_paths_test.go new file mode 100644 index 0000000..a1e8d79 --- /dev/null +++ b/internal/pfs/extract_paths_test.go @@ -0,0 +1,160 @@ +//go:build unix + +package pfs + +import ( + "archive/tar" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// sandbox is a destination folder inside an otherwise empty parent, so a test +// can tell that an extraction wrote nothing beside it. +func sandbox(t *testing.T) (parent, dest string) { + t.Helper() + parent = t.TempDir() + dest = filepath.Join(parent, "dest") + if err := os.Mkdir(dest, 0o755); err != nil { + t.Fatal(err) + } + return parent, dest +} + +// tree lists every path under dir, relative to it. +func tree(t *testing.T, dir string) []string { + t.Helper() + var paths []string + err := filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if p != dir { + rel, _ := filepath.Rel(dir, p) + paths = append(paths, rel) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + slices.Sort(paths) + return paths +} + +func reg(name string) tar.Header { + return tar.Header{Name: name, Typeflag: tar.TypeReg, Mode: 0o644} +} + +// node-tar, which extracts the reference's blobs, skips an entry with a ".." +// segment (either separator) instead of re-rooting it: "../up.txt" is not +// written anywhere, not even as destDir/up.txt. A ".." inside a name is no +// segment. +func TestExtractTarGzSkipsDotDotEntries(t *testing.T) { + parent, dest := sandbox(t) + src := writeEntries(t, "x", + reg("../up.txt"), reg("a/../mid.txt"), reg(`a\..\back.txt`), reg(`..\win.txt`), + tar.Header{Name: "../updir/", Typeflag: tar.TypeDir, Mode: 0o755}, + reg("sub/..x"), reg("ok.txt"), + ) + if err := ExtractTarGz(src, dest); err != nil { + t.Fatal(err) + } + if got, want := tree(t, dest), []string{"ok.txt", "sub", "sub/..x"}; !slices.Equal(got, want) { + t.Errorf("destDir holds %q, want %q", got, want) + } + if got := tree(t, parent); !slices.Equal(got, append([]string{"dest"}, prefixed("dest", tree(t, dest))...)) { + t.Errorf("the extraction wrote beside destDir: %q", got) + } +} + +func prefixed(dir string, paths []string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = filepath.Join(dir, p) + } + return out +} + +// An absolute path loses its root, Windows roots included as node-tar strips +// them on every platform, and lands inside destDir. +func TestExtractTarGzStripsAbsolutePaths(t *testing.T) { + parent, dest := sandbox(t) + src := writeEntries(t, "x", + reg("/abs.txt"), reg("//abs2.txt"), reg(`\back.txt`), reg("c:/drive.txt"), reg("C:rel.txt"), + reg("/c:/nested/x.txt"), reg("1:digit.txt"), + // Drive letters are ASCII letters only: the neighbours of a-z and A-Z + // stay part of the name. + reg("a:a.txt"), reg("z:z.txt"), reg("A:A2.txt"), reg("Z:Z2.txt"), + reg("`:tick.txt"), reg("{:brace.txt"), reg("@:at.txt"), reg("[:bracket.txt"), + tar.Header{Name: "d:", Typeflag: tar.TypeDir, Mode: 0o755}, + ) + if err := ExtractTarGz(src, dest); err != nil { + t.Fatal(err) + } + want := []string{ + "1:digit.txt", "@:at.txt", "A2.txt", "Z2.txt", "[:bracket.txt", "`:tick.txt", "a.txt", + "abs.txt", "abs2.txt", "back.txt", "drive.txt", "nested", "nested/x.txt", "rel.txt", "z.txt", "{:brace.txt", + } + if got := tree(t, dest); !slices.Equal(got, want) { + t.Errorf("destDir holds %q, want %q", got, want) + } + if got := tree(t, parent); len(got) != len(want)+1 { + t.Errorf("the extraction wrote beside destDir: %q", got) + } +} + +// A symlink inside destDir that points outside it cannot carry a file or a +// directory out: the extraction fails, keeps the archive, and nothing appears +// outside. +func TestExtractTarGzCannotWriteThroughASymlink(t *testing.T) { + for name, entry := range map[string]tar.Header{ + "file": reg("link/pwned.txt"), + "directory": {Name: "link/pwned/", Typeflag: tar.TypeDir, Mode: 0o755}, + "nested": reg("link/deeper/pwned.txt"), + } { + t.Run(name, func(t *testing.T) { + parent, dest := sandbox(t) + outside := filepath.Join(parent, "outside") + if err := os.Mkdir(outside, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dest, "link")); err != nil { + t.Fatal(err) + } + src := writeEntries(t, "x", entry) + err := ExtractTarGz(src, dest) + if err == nil || !strings.Contains(err.Error(), "escapes") { + t.Errorf("err = %v, want the path escape refused", err) + } + if got := tree(t, outside); len(got) != 0 { + t.Errorf("the extraction wrote outside destDir: %q", got) + } + if _, statErr := os.Stat(src); statErr != nil { + t.Errorf("a failed extraction must keep the archive: %v", statErr) + } + }) + } +} + +// A symlink entry is not extracted, so a later entry beneath its name is an +// ordinary path inside destDir and cannot follow it out. +func TestExtractTarGzDoesNotCreateSymlinkEntries(t *testing.T) { + parent, dest := sandbox(t) + src := writeEntries(t, "x", + tar.Header{Name: "evil", Typeflag: tar.TypeSymlink, Linkname: parent}, + reg("evil/x.txt"), + ) + if err := ExtractTarGz(src, dest); err != nil { + t.Fatal(err) + } + info, err := os.Lstat(filepath.Join(dest, "evil")) + if err != nil || !info.IsDir() { + t.Fatalf("evil = %v, %v; want a plain directory", info, err) + } + if got := tree(t, parent); !slices.Equal(got, []string{"dest", "dest/evil", "dest/evil/x.txt"}) { + t.Errorf("parent holds %q", got) + } +} diff --git a/internal/pfs/pfs.go b/internal/pfs/pfs.go index 3d06326..f9960a7 100644 --- a/internal/pfs/pfs.go +++ b/internal/pfs/pfs.go @@ -11,9 +11,14 @@ import ( "strings" ) -// ExtractTarGz extracts a tar archive (gzip-compressed or plain) into destDir. -// Entries that would escape destDir (path traversal) are skipped. The archive -// file at archivePath is removed on success. +// ExtractTarGz extracts a tar archive (gzip-compressed or plain) into destDir, +// treating entry paths as node-tar's extract does for the reference CLI +// (containerCollectionsOCI.ts getBlob): an entry whose path has a ".." +// segment is skipped, and an absolute path has its root stripped so it lands +// inside destDir. Only directories and regular files are extracted. Every +// write goes through an os.Root on destDir, so a symlink already inside +// destDir cannot redirect one outside it: that fails the extraction. The +// archive file at archivePath is removed on success. func ExtractTarGz(archivePath, destDir string) error { f, err := os.Open(archivePath) if err != nil { @@ -36,7 +41,11 @@ func ExtractTarGz(archivePath, destDir string) error { reader = f2 } - cleanDest := filepath.Clean(destDir) + root, err := os.OpenRoot(destDir) + if err != nil { + return err + } + defer root.Close() tr := tar.NewReader(reader) for { header, err := tr.Next() @@ -47,18 +56,21 @@ func ExtractTarGz(archivePath, destDir string) error { return fmt.Errorf("tar read: %w", err) } - target := filepath.Join(cleanDest, filepath.Clean("/"+header.Name)) - // Guard against path traversal (../ entries escaping destDir). - if target != cleanDest && !strings.HasPrefix(target, cleanDest+string(os.PathSeparator)) { + name, ok := entryPath(header.Name) + if !ok { continue } switch header.Typeflag { case tar.TypeDir: - os.MkdirAll(target, 0755) + if err := root.MkdirAll(name, 0755); err != nil { + return err + } case tar.TypeReg: - os.MkdirAll(filepath.Dir(target), 0755) - out, err := os.Create(target) + if err := root.MkdirAll(filepath.Dir(name), 0755); err != nil { + return err + } + out, err := root.Create(name) if err != nil { return err } @@ -68,7 +80,9 @@ func ExtractTarGz(archivePath, destDir string) error { } out.Close() if header.Mode != 0 { - os.Chmod(target, os.FileMode(header.Mode)) + if err := root.Chmod(name, os.FileMode(header.Mode)); err != nil { + return err + } } } } @@ -76,6 +90,34 @@ func ExtractTarGz(archivePath, destDir string) error { return nil } +// entryPath is where an entry named name extracts, relative to the +// destination, and false for an entry node-tar skips. node-tar (unpack.js +// STRIPABSOLUTEPATH) strips the root of an absolute path, Windows roots +// included on every platform ('/', '\', a drive letter), and refuses a path +// with a ".." segment, splitting on both separators. +func entryPath(name string) (string, bool) { + for { + stripped := strings.TrimLeft(name, `/\`) + if len(stripped) >= 2 && stripped[1] == ':' && isASCIILetter(stripped[0]) { + stripped = stripped[2:] + } + if stripped == name { + break + } + name = stripped + } + for segment := range strings.FieldsFuncSeq(name, func(r rune) bool { return r == '/' || r == '\\' }) { + if segment == ".." { + return "", false + } + } + return filepath.Clean(name), true +} + +func isASCIILetter(c byte) bool { + return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' +} + // IsFile returns true if path exists and is a regular file. func IsFile(path string) bool { info, err := os.Stat(path) From 95056a1ded9e248791b5ed89c5fcace86a4e5a88 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:08:53 -0300 Subject: [PATCH 16/37] fix(config): default a compose workspace folder to the root A Docker Compose configuration without workspaceFolder now works in `/`, as the reference does (getWorkspaceConfiguration returns getRemoteWorkspaceFolder, `config.workspaceFolder || '/'`). Go derived /workspaces/, a folder the compose service never mounts, for read-configuration's workspace, ${containerWorkspaceFolder}, up's remoteWorkspaceFolder, the lifecycle commands' cwd and exec's cwd. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 14 ++++++ internal/config/compose_workspace_test.go | 56 +++++++++++++++++++++++ internal/config/loader.go | 21 +++++---- 3 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 internal/config/compose_workspace_test.go diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 884825c..029ffb8 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -1004,6 +1004,20 @@ initial_cases: current_status: match notes: "Without --image-name, TS doBuild reports a Compose build's image as a string (imageNameResult = originalImageName); with it, as the array of --image-name values. Go always wrote an array. The image keeps the name the sibling compose-image-without-features-minimal cases give it, so no cleanup removes it from under them." + - id: up.compose-without-workspace-folder-uses-root-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s\\n' 'services:' ' app:' ' image: alpine:3' ' command: sleep infinity' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"postCreateCommand\":\"pwd > /tmp/post-create; echo ${containerWorkspaceFolder} >> /tmp/post-create\"}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "docker exec ${CONTAINER_ID} cat /tmp/post-create; if [ \"${PARITY_SIDE}\" = \"ts\" ]; then CLI=${PARITY_CLI_TS}; else CLI=${PARITY_CLI_GO}; fi; $CLI exec --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} pwd 2>/dev/null" + cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + notes: "A compose configuration without workspaceFolder works in `/` (TS getWorkspaceConfiguration returns getRemoteWorkspaceFolder, `config.workspaceFolder || '/'`, dockerCompose.ts): up's remoteWorkspaceFolder, ${containerWorkspaceFolder}, the lifecycle commands' cwd and exec's cwd. Go used /workspaces/, a folder the compose service never mounts." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/config/compose_workspace_test.go b/internal/config/compose_workspace_test.go new file mode 100644 index 0000000..f68d518 --- /dev/null +++ b/internal/config/compose_workspace_test.go @@ -0,0 +1,56 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// A Docker Compose configuration's container workspace folder is its own +// workspaceFolder or `/`, never /workspaces/ (TS +// getWorkspaceConfiguration -> getRemoteWorkspaceFolder, dockerCompose.ts). +// ${containerWorkspaceFolder} resolves to the same folder, with or without a +// workspace. +func TestLoadComposeWorkspaceFolder(t *testing.T) { + tests := []struct { + name string + extra string + useWorkspace bool + want string + }{ + {name: "no workspaceFolder", useWorkspace: true, want: "/"}, + {name: "no workspaceFolder and no workspace", want: "/"}, + {name: "explicit workspaceFolder", extra: `,"workspaceFolder":"/work"`, useWorkspace: true, want: "/work"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ws := t.TempDir() + dir := filepath.Join(ws, ".devcontainer") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + cfg := filepath.Join(dir, "devcontainer.json") + body := `{"dockerComposeFile":"docker-compose.yml","service":"app","postCreateCommand":"echo ${containerWorkspaceFolder}"` + tt.extra + `}` + if err := os.WriteFile(cfg, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + folder := "" + if tt.useWorkspace { + folder = ws + } + res, err := LoadDevContainerConfig(folder, cfg, "") + if err != nil { + t.Fatal(err) + } + if got := res.WorkspaceConfig.WorkspaceFolder; got != tt.want { + t.Errorf("workspaceFolder = %q, want %q", got, tt.want) + } + if got := res.WorkspaceConfig.WorkspaceMount; got != "" { + t.Errorf("workspaceMount = %q, want none", got) + } + if got, want := res.Raw["postCreateCommand"], "echo "+tt.want; got != want { + t.Errorf("postCreateCommand = %q, want %q", got, want) + } + }) + } +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 3b53c76..590bca1 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -353,10 +353,20 @@ func DockerComposeFilePaths(config *DevContainer, env map[string]string, cwd str // --- Helpers --- func computeWorkspaceConfig(workspace *Workspace, config *DevContainer, mountWorkspaceGitRoot, mountGitWorktreeCommonDir bool) *WorkspaceConfig { + // Docker Compose manages its own mounts, so the workspace folder is the + // config's own or `/`, never derived from the host folder (TS + // getWorkspaceConfiguration via getRemoteWorkspaceFolder). + if config.IsComposeConfig() { + folder := config.WorkspaceFolder + if folder == "" { + folder = "/" + } + return &WorkspaceConfig{WorkspaceFolder: folder} + } // With no workspace (up --config without --workspace-folder) nothing is // mounted: the folder and mount are the config's own, possibly empty, as TS // getWorkspaceConfiguration leaves them for an undefined workspace. - if workspace.RootFolderPath == "" && !config.IsComposeConfig() { + if workspace.RootFolderPath == "" { return &WorkspaceConfig{WorkspaceFolder: config.WorkspaceFolder, WorkspaceMount: config.WorkspaceMount} } sourceFolder := workspace.RootFolderPath @@ -380,7 +390,7 @@ func computeWorkspaceConfig(workspace *Workspace, config *DevContainer, mountWor // gitdir still resolves to the mounted common dir inside the container. containerMountFolder := "/workspaces/" + filepath.Base(sourceFolder) var additionalMounts []string - if mountWorkspaceGitRoot && mountGitWorktreeCommonDir && !config.IsComposeConfig() { + if mountWorkspaceGitRoot && mountGitWorktreeCommonDir { // A custom workspaceMount defines where the worktree is actually mounted in // the container; resolve the common dir relative to that (substituted) // target so the relative gitdir still resolves. Otherwise fall back to the @@ -406,15 +416,10 @@ func computeWorkspaceConfig(workspace *Workspace, config *DevContainer, mountWor wc := &WorkspaceConfig{ WorkspaceFolder: containerFolder, + WorkspaceMount: bindMount(sourceFolder, containerMountFolder, consistency), AdditionalMounts: additionalMounts, } - // Only compute workspaceMount for non-compose configs; Docker Compose manages - // its own mounts via the compose file. - if !config.IsComposeConfig() { - wc.WorkspaceMount = bindMount(sourceFolder, containerMountFolder, consistency) - } - if config.WorkspaceFolder != "" { wc.WorkspaceFolder = config.WorkspaceFolder } From ef41b6d4b0b3162e8ee0378d91e98e892b603c91 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:13:42 -0300 Subject: [PATCH 17/37] fix(cli): report frozen lockfile failures as the reference does --frozen-lockfile without a lockfile now fails with "Lockfile does not exist." instead of passing unchecked, and both frozen failures report the reference's envelope: the lockfile error as the message and the command's "An error occurred building/setting up the container." as the description (lockfile.ts writeLockfile, wrapped as the originalError of the command's ContainerError). Go wrote "lockfile: lockfile does not match" into both fields. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 39 +++++++++++++++++++++++ internal/cli/build.go | 7 ++++ internal/cli/pure_helpers_test.go | 13 +++++++- internal/features/lockfile.go | 17 +++++++--- internal/features/lockfile_frozen_test.go | 28 ++++++++++++++++ 5 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 internal/features/lockfile_frozen_test.go diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 029ffb8..02ee553 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -1018,6 +1018,45 @@ initial_cases: current_status: match notes: "A compose configuration without workspaceFolder works in `/` (TS getWorkspaceConfiguration returns getRemoteWorkspaceFolder, `config.workspaceFolder || '/'`, dockerCompose.ts): up's remoteWorkspaceFolder, ${containerWorkspaceFolder}, the lifecycle commands' cwd and exec's cwd. Go used /workspaces/, a folder the compose service never mounts." + - id: build.frozen-lockfile-missing-failure + lane: runtime + command: build + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./f\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "build --workspace-folder ${WORKSPACE} --buildkit=never --frozen-lockfile" + cleanup_cmd: "rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: lockfile + current_status: match + notes: "--frozen-lockfile without a lockfile fails with TS writeLockfile's 'Lockfile does not exist.' (lockfile.ts), wrapped as the build's ContainerError: message is that text, description 'An error occurred building the container.'. Go skipped the check when no lockfile existed and built the image." + + - id: up.frozen-lockfile-mismatch-failure + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"features\":{\"stale\":{}}}' > $WORKSPACE/.devcontainer/devcontainer-lock.json; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./f\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --frozen-lockfile --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + cleanup_cmd: "rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: lockfile + current_status: match + notes: "A stale lockfile with --frozen-lockfile fails with TS's 'Lockfile does not match.' as the message and 'An error occurred setting up the container.' as the description. Go reported 'An error occurred setting up the container: install features: lockfile: lockfile does not match' in both." + + - id: up.compose-frozen-lockfile-missing-failure + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s\\n' 'services:' ' app:' ' image: alpine:3' ' command: sleep infinity' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"features\":{\"./f\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --frozen-lockfile --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: lockfile + current_status: match + notes: "The compose path checks the frozen lockfile too (TS generateFeaturesConfig -> writeLockfile, wrapped by dockerCompose.ts as 'An error occurred setting up the container.'). Go skipped it when no lockfile existed and started the service." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/build.go b/internal/cli/build.go index 359ab16..d3cd040 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -761,6 +761,13 @@ func commandErrorOutput(err error, description string) coreerrors.ErrorOutput { if ce, ok := coreerrors.AsContainerError(err); ok { return coreerrors.ToErrorOutput(ce) } + // TS wraps a plain Error it throws as its originalError, so the message is + // that error's own text (a frozen lockfile's "Lockfile does not match."). + for _, plain := range []error{features.ErrLockfileMissing, features.ErrLockfileMismatch} { + if errors.Is(err, plain) { + return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: description + ".", OriginalError: plain}) + } + } return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: fmt.Sprintf("%s: %v", description, err)}) } diff --git a/internal/cli/pure_helpers_test.go b/internal/cli/pure_helpers_test.go index f0aaf8c..fbf4dc2 100644 --- a/internal/cli/pure_helpers_test.go +++ b/internal/cli/pure_helpers_test.go @@ -11,6 +11,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/features" "github.com/devcontainers/cli/internal/oci" ) @@ -268,7 +269,7 @@ func TestBuildArgsAndOptionsFromConfig(t *testing.T) { // up and build report a ContainerError as it is, as TS does for the errors it // throws with their own description (a legacy Feature id, a failing compose // config), wherever it is wrapped; any other error gets the command's -// description with the error appended. +// description with the error appended, except a frozen lockfile's error. func TestCommandErrorOutputKeepsAContainerError(t *testing.T) { legacy := checkLegacyFeatures(map[string]any{"myfeature": true}) out := commandErrorOutput(fmt.Errorf("install features: %w", legacy), "An error occurred setting up the container") @@ -276,6 +277,16 @@ func TestCommandErrorOutputKeepsAContainerError(t *testing.T) { if out.Description != want || out.Message != want { t.Errorf("legacy Feature error = %+v", out) } + // A frozen lockfile's plain error is TS's originalError: its text is the + // message, the command's sentence the description. + out = commandErrorOutput(fmt.Errorf("install features: lockfile: %w", features.ErrLockfileMismatch), "An error occurred setting up the container") + if out.Message != "Lockfile does not match." || out.Description != "An error occurred setting up the container." { + t.Errorf("frozen lockfile error = %+v", out) + } + out = commandErrorOutput(fmt.Errorf("lockfile: %w", features.ErrLockfileMissing), "An error occurred building the container") + if out.Message != "Lockfile does not exist." || out.Description != "An error occurred building the container." { + t.Errorf("missing lockfile error = %+v", out) + } out = commandErrorOutput(errorString("some internal failure"), "An error occurred building the container") if want := "An error occurred building the container: some internal failure"; out.Description != want || out.Message != want { t.Errorf("other error = %+v", out) diff --git a/internal/features/lockfile.go b/internal/features/lockfile.go index 2b1c0f8..8e68243 100644 --- a/internal/features/lockfile.go +++ b/internal/features/lockfile.go @@ -133,6 +133,12 @@ func ReadLockfile(configPath string) (*Lockfile, bool, error) { return &lf, false, nil } +// The errors a frozen WriteLockfile fails with, TS writeLockfile's own text. +var ( + ErrLockfileMissing = errors.New("Lockfile does not exist.") + ErrLockfileMismatch = errors.New("Lockfile does not match.") +) + // WriteOptions controls how WriteLockfile behaves. type WriteOptions struct { // Frozen fails (rather than writing) if the lockfile is missing or would change. @@ -157,8 +163,12 @@ func WriteLockfile(configPath string, lockfile *Lockfile, opts WriteOptions) err return readErr } + missing := errors.Is(readErr, os.ErrNotExist) + if opts.Frozen && missing { + return ErrLockfileMissing + } // If no old lockfile and not explicitly enabled, skip - if !opts.Force && errors.Is(readErr, os.ErrNotExist) { + if !opts.Force && missing { return nil } @@ -168,11 +178,8 @@ func WriteLockfile(configPath string, lockfile *Lockfile, opts WriteOptions) err unchanged := parsed && oldNormalized == string(newData) if opts.Frozen { - if errors.Is(readErr, os.ErrNotExist) { - return fmt.Errorf("lockfile does not exist") - } if !unchanged { - return fmt.Errorf("lockfile does not match") + return ErrLockfileMismatch } return nil } diff --git a/internal/features/lockfile_frozen_test.go b/internal/features/lockfile_frozen_test.go new file mode 100644 index 0000000..b67ec8e --- /dev/null +++ b/internal/features/lockfile_frozen_test.go @@ -0,0 +1,28 @@ +package features + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// A frozen lockfile fails with TS writeLockfile's own errors: "Lockfile does +// not exist." when there is none (also without --experimental-lockfile, and +// without writing one) and "Lockfile does not match." when it is stale. +func TestWriteLockfileFrozenErrors(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "devcontainer.json") + err := WriteLockfile(configPath, heldLockfile(), WriteOptions{Frozen: true}) + if !errors.Is(err, ErrLockfileMissing) || err.Error() != "Lockfile does not exist." { + t.Errorf("frozen without a lockfile: err = %v", err) + } + if _, statErr := os.Stat(LockfilePath(configPath)); !errors.Is(statErr, os.ErrNotExist) { + t.Errorf("frozen wrote a lockfile: %v", statErr) + } + + configPath, _ = writeHeldLockfile(t, `{"features":{"stale":{}}}`) + err = WriteLockfile(configPath, heldLockfile(), WriteOptions{Frozen: true}) + if !errors.Is(err, ErrLockfileMismatch) || err.Error() != "Lockfile does not match." { + t.Errorf("frozen with a stale lockfile: err = %v", err) + } +} From d830d6edfbe24b58c79c78229d96ed9d77bc7676 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:26:24 -0300 Subject: [PATCH 18/37] fix(cli): write command results in the reference's key order The results of up, build, set-up, run-user-commands and read-configuration now list their properties as the reference's JSON.stringify does, in the insertion order of the object literals that build them: outcome first, read-configuration's configuration, workspace, featuresConfiguration, mergedConfiguration, then ociAuthDiagnostics. A configuration keeps its document's top-level order with configFilePath last, and a merged configuration the order mergeConfiguration produces. Go wrote every object with sorted keys, which the parity matrix could not see because it sorts keys; its new key_order field compares the order against the reference. Objects nested deeper still have sorted keys, a decoded Go map keeping no order; docs/DIVERGENCES.md records it. Co-Authored-By: Claude Opus 5.5 --- docs/DIVERGENCES.md | 12 ++ docs/parity/parity-matrix.yaml | 32 ++++++ internal/cli/build.go | 11 +- internal/cli/parity_matrix_test.go | 56 +++++++++ internal/cli/read_configuration.go | 7 +- internal/cli/read_configuration_held_test.go | 24 ++-- internal/cli/result_order.go | 115 +++++++++++++++++++ internal/cli/result_order_test.go | 105 +++++++++++++++++ internal/cli/run_user_commands.go | 2 +- internal/cli/setup.go | 3 +- internal/cli/up.go | 3 +- internal/config/key_order_test.go | 29 +++++ internal/config/loader.go | 8 ++ internal/jsonc/jsonc.go | 37 ++++-- internal/jsonc/members_test.go | 14 +++ 15 files changed, 430 insertions(+), 28 deletions(-) create mode 100644 internal/cli/result_order.go create mode 100644 internal/cli/result_order_test.go create mode 100644 internal/config/key_order_test.go diff --git a/docs/DIVERGENCES.md b/docs/DIVERGENCES.md index c78f524..d169894 100644 --- a/docs/DIVERGENCES.md +++ b/docs/DIVERGENCES.md @@ -95,6 +95,18 @@ touches a compared surface, reflected in the parity matrix. given in another order (a base image's label written by another tool) is likewise rewritten in pick order. The parsed metadata is the same; only the label's bytes differ (`internal/imagemeta/testdata/ts-oracle/imagemeta.json` records both cases). +- **Command results write nested objects with their keys sorted.** TS writes each result + with `JSON.stringify`, so properties come in insertion order. Go matches that order for + the results of `up`, `build`, `set-up`, `run-user-commands` and `read-configuration`, + for `featuresConfiguration`, for the top level of `configuration` (the document's + order, with `customizations` moved from deprecated properties and then `configFilePath` + last) and for the top level of `mergedConfiguration` (the configuration without + `mergeConfiguration`'s `replaceProperties`, then the properties it sets). Objects nested + deeper are written with sorted keys, because a decoded Go map keeps no order: a + configuration's `containerEnv` or `customizations`, merged objects such as `remoteEnv` + or `portsAttributes`, and each Feature in `featuresConfiguration.featureSets`, which + TS writes in its `devcontainer-feature.json` order. The parsed values are the same; the + parity matrix's `key_order` cases hold the orders that match. ## Firm decisions & scope diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 02ee553..9795b2b 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -1013,6 +1013,7 @@ initial_cases: ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" verify_cmd: "docker exec ${CONTAINER_ID} cat /tmp/post-create; if [ \"${PARITY_SIDE}\" = \"ts\" ]; then CLI=${PARITY_CLI_TS}; else CLI=${PARITY_CLI_GO}; fi; $CLI exec --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} pwd 2>/dev/null" cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + key_order: [""] asserts: [exit_code, stdout_normalized] class: up-runtime current_status: match @@ -1057,6 +1058,34 @@ initial_cases: current_status: match notes: "The compose path checks the frozen lockfile too (TS generateFeaturesConfig -> writeLockfile, wrapped by dockerCompose.ts as 'An error occurred setting up the container.'). Go skipped it when no lockfile existed and started the service." + - id: read-configuration.key-order-success + lane: semantic + command: read-configuration + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"name\":\"ko\",\"image\":\"alpine:3\",\"remoteUser\":\"root\",\"features\":{\"./f\":{}},\"containerEnv\":{\"K\":\"v\"},\"postCreateCommand\":\"true\",\"forwardPorts\":[3000],\"capAdd\":[\"SYS_PTRACE\"]}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --include-features-configuration --include-merged-configuration" + cleanup_cmd: "rm -rf ${WORKSPACE}; true" + key_order: ["", "configuration", "featuresConfiguration", "mergedConfiguration"] + asserts: [exit_code, stdout_normalized] + class: output-shape + current_status: match + notes: "JSON.stringify writes properties in insertion order: the result as readConfiguration's literal lists them, the configuration in its document's order with configFilePath last, featuresConfiguration as FeaturesConfig's literal (featureSets, dstFolder), mergedConfiguration as mergeConfiguration builds it (the configuration without replaceProperties, then the properties it sets). Go wrote every object with sorted keys. Nested user objects keep sorted keys (docs/DIVERGENCES.md)." + + - id: up.include-configurations-key-order-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"name\":\"ko\",\"image\":\"alpine:3\",\"remoteUser\":\"root\",\"features\":{\"./f\":{}},\"containerEnv\":{\"K\":\"v\"},\"postCreateCommand\":\"true\",\"forwardPorts\":[3000],\"capAdd\":[\"SYS_PTRACE\"]}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --skip-post-create --include-configuration --include-merged-configuration --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + cleanup_cmd: "IDS=$(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + key_order: ["", "configuration", "mergedConfiguration"] + asserts: [exit_code] + class: output-shape + current_status: match + notes: "up's result follows doProvision's literal (outcome, then launch's containerId, composeProjectName, remoteUser, remoteWorkspaceFolder, configuration, mergedConfiguration, ociAuthDiagnostics), its configuration the document's order and its mergedConfiguration mergeConfiguration's. Go wrote sorted keys. Only the order is compared: TS writes configFilePath's fsPath here (vscode-uri toJSON writes it once something read it) and Go does not, a content gap outside this case." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade @@ -1939,6 +1968,7 @@ initial_cases: setup_cmd: "CONTAINER_ID=$(docker run -d -e TEST_CE=TEST_VALUE alpine:3.17 sleep inf); echo CONTAINER_ID=$CONTAINER_ID" verify_cmd: "docker exec ${CONTAINER_ID} test -f /postAttachCommand.txt" cleanup_cmd: "docker rm -f ${CONTAINER_ID} >/dev/null 2>&1 || true" + key_order: ["", "configuration", "mergedConfiguration"] asserts: [exit_code, stdout_normalized] class: lifecycle-runtime current_status: match @@ -1963,6 +1993,7 @@ initial_cases: docker_required: true ts_cmd: "up --workspace-folder src/test/configs/image --buildkit=never --skip-post-create --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} --remove-existing-container" cleanup_cmd: "IDS=$(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1 || true" + key_order: [""] asserts: [exit_code, stdout_normalized] class: up-runtime current_status: match @@ -2655,6 +2686,7 @@ initial_cases: ts_cmd: "build --workspace-folder src/test/configs/image --buildkit=never --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}" setup_cmd: "echo DOCKER_DEFAULT_PLATFORM=linux/amd64" cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1 || true" + key_order: [""] asserts: [exit_code, stdout_normalized] class: build-runtime current_status: match diff --git a/internal/cli/build.go b/internal/cli/build.go index d3cd040..671ad45 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -224,7 +224,7 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { "outcome": "success", "imageName": buildResultImageName(cfg.IsComposeConfig(), opts.imageNames, imageNameResult), "ociAuthDiagnostics": ociAuthDiagnostics(ctx), - }) + }, buildResultKeys...) } // buildResultImageName is the result's imageName: the image names built, except @@ -724,8 +724,13 @@ func buildOptionsFromConfig(cfg *config.DevContainer) []string { return cfg.Build.Options } -func writeSuccessJSON(out Output, data map[string]any) error { - b, _ := json.Marshal(data) +// writeSuccessJSON writes a command's result with its keys in order (see +// orderedObject). +func writeSuccessJSON(out Output, data map[string]any, order ...string) error { + b, err := json.Marshal(orderedObject{data, order}) + if err != nil { + return fmt.Errorf("marshal result: %w", err) + } fmt.Fprintln(out.Stdout(), string(b)) return nil } diff --git a/internal/cli/parity_matrix_test.go b/internal/cli/parity_matrix_test.go index 87df046..847d6e4 100644 --- a/internal/cli/parity_matrix_test.go +++ b/internal/cli/parity_matrix_test.go @@ -95,6 +95,10 @@ type parityCase struct { // classify TS's failure as an unusable-oracle skip → inconclusive; with it, when // Go also failed the harness compares exit codes instead. ExpectInfraFailure bool `yaml:"expect_infra_failure"` + // KeyOrder names JSON objects in the result on stdout whose keys must come in + // TS's order, which stdout_normalized cannot see (it sorts keys): "" is the + // result itself, "configuration" its configuration property, and so on. + KeyOrder []string `yaml:"key_order"` } type paritySideResult struct { @@ -449,6 +453,22 @@ func TestParityMatrix(t *testing.T) { } } + for _, path := range tc.KeyOrder { + tsKeys, err := jsonKeyOrder(extractJSONCandidate(tsRes.Stdout), path) + if err != nil { + t.Errorf("key_order %q: TS: %v", path, err) + continue + } + goKeys, err := jsonKeyOrder(extractJSONCandidate(goRes.Stdout), path) + if err != nil { + t.Errorf("key_order %q: Go: %v", path, err) + continue + } + if !slices.Equal(tsKeys, goKeys) { + t.Errorf("key order of %q differs:\n TS: %v\n Go: %v", path, tsKeys, goKeys) + } + } + if tc.VerifyCmd != "" { if tsRes.VerifyExit != goRes.VerifyExit { t.Errorf("verify exit code: TS=%d Go=%d", tsRes.VerifyExit, goRes.VerifyExit) @@ -1022,6 +1042,42 @@ func normalizeRequired(raw string) string { // --- Helpers --- +// jsonKeyOrder is the keys of the JSON object at path in the document data, in +// the order data lists them: "" is the document itself, "a.b" the object at +// property b of property a. +func jsonKeyOrder(data, path string) ([]string, error) { + raw := json.RawMessage(data) + if path != "" { + for name := range strings.SplitSeq(path, ".") { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + var ok bool + if raw, ok = obj[name]; !ok { + return nil, fmt.Errorf("%s: no property %q", path, name) + } + } + } + dec := json.NewDecoder(bytes.NewReader(raw)) + if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { + return nil, fmt.Errorf("%s: not an object", path) + } + var keys []string + for dec.More() { + tok, err := dec.Token() + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + keys = append(keys, tok.(string)) + var skip json.RawMessage + if err := dec.Decode(&skip); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + } + return keys, nil +} + func extractJSONCandidate(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/cli/read_configuration.go b/internal/cli/read_configuration.go index 67a4e6a..aaeead2 100644 --- a/internal/cli/read_configuration.go +++ b/internal/cli/read_configuration.go @@ -181,10 +181,10 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) } if featResult != nil { defer os.RemoveAll(featResult.TmpDir) - output["featuresConfiguration"] = map[string]any{ + output["featuresConfiguration"] = orderedObject{map[string]any{ "featureSets": featResult.FeatureSets, "dstFolder": featResult.TmpDir, - } + }, featuresConfigurationKeys} } } @@ -226,8 +226,9 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) // OCI auth diagnostics for this invocation (TS readConfiguration emits it // unconditionally, even when no registry was contacted). output["ociAuthDiagnostics"] = ociAuthDiagnostics(ctx) + orderConfigurations(output, result) - data, err := json.Marshal(output) + data, err := json.Marshal(orderedObject{output, readConfigurationKeys}) if err != nil { return fmt.Errorf("marshal output: %w", err) } diff --git a/internal/cli/read_configuration_held_test.go b/internal/cli/read_configuration_held_test.go index c27d2f4..08f1d2e 100644 --- a/internal/cli/read_configuration_held_test.go +++ b/internal/cli/read_configuration_held_test.go @@ -399,21 +399,29 @@ func TestConfigMetadataEntry(t *testing.T) { // stdout parsed, or the command's error. func runReadConfigurationCmd(t *testing.T, args ...string) (map[string]any, error) { t.Helper() - var stdout bytes.Buffer - root := NewRootCommand() - root.SetOut(&stdout) - root.SetErr(io.Discard) - root.SetArgs(append([]string{"read-configuration"}, args...)) - if err := root.Execute(); err != nil { + stdout, err := readConfigurationStdout(args...) + if err != nil { return nil, err } var out map[string]any - if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { - t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout) } return out, nil } +// readConfigurationStdout runs read-configuration with args and returns its +// stdout as written, or the command's error. +func readConfigurationStdout(args ...string) (string, error) { + var stdout bytes.Buffer + root := NewRootCommand() + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs(append([]string{"read-configuration"}, args...)) + err := root.Execute() + return stdout.String(), err +} + func wantExitCode(t *testing.T, err error, code int) { t.Helper() exitErr, ok := errors.AsType[*coreerrors.ExitCodeError](err) diff --git a/internal/cli/result_order.go b/internal/cli/result_order.go new file mode 100644 index 0000000..93fbf70 --- /dev/null +++ b/internal/cli/result_order.go @@ -0,0 +1,115 @@ +package cli + +import ( + "bytes" + "encoding/json" + "maps" + "slices" + + "github.com/devcontainers/cli/internal/config" +) + +// orderedObject writes fields as a JSON object in the order TS writes it: +// JSON.stringify follows property insertion order, which order gives. The keys +// order names come first, each once, then any other key sorted, as json.Marshal +// writes a map (a Go-only property, such as read-configuration's cacheKey). +type orderedObject struct { + fields map[string]any + order []string +} + +func (o orderedObject) MarshalJSON() ([]byte, error) { + keys := make([]string, 0, len(o.fields)) + for _, k := range o.order { + if _, ok := o.fields[k]; ok && !slices.Contains(keys, k) { + keys = append(keys, k) + } + } + for _, k := range slices.Sorted(maps.Keys(o.fields)) { + if !slices.Contains(keys, k) { + keys = append(keys, k) + } + } + var out bytes.Buffer + out.WriteByte('{') + for i, k := range keys { + if i > 0 { + out.WriteByte(',') + } + name, _ := json.Marshal(k) + value, err := json.Marshal(o.fields[k]) + if err != nil { + return nil, err + } + out.Write(name) + out.WriteByte(':') + out.Write(value) + } + out.WriteByte('}') + return out.Bytes(), nil +} + +// The order of each command's result, as the TS object literal that builds it +// lists its properties. +var ( + // doProvision: outcome, then launch's result (devContainers.ts). + upResultKeys = []string{"outcome", "containerId", "composeProjectName", "remoteUser", "remoteWorkspaceFolder", "configuration", "mergedConfiguration", "ociAuthDiagnostics"} + // doBuild. + buildResultKeys = []string{"outcome", "imageName", "ociAuthDiagnostics"} + // doSetUp. + setUpResultKeys = []string{"outcome", "configuration", "mergedConfiguration"} + // doRunUserCommands. + runUserCommandsResultKeys = []string{"outcome", "result"} + // readConfiguration. + readConfigurationKeys = []string{"configuration", "workspace", "featuresConfiguration", "mergedConfiguration", "ociAuthDiagnostics"} + // generateFeaturesConfig's FeaturesConfig. + featuresConfigurationKeys = []string{"featureSets", "dstFolder"} + // The properties mergeConfiguration (imageMetadata.ts) sets after spreading + // the configuration without its replaceProperties. + mergedConfigurationKeys = []string{ + "init", "privileged", "capAdd", "securityOpt", "entrypoints", "mounts", "customizations", + "onCreateCommands", "updateContentCommands", "postCreateCommands", "postStartCommands", "postAttachCommands", + "waitFor", "remoteUser", "containerUser", "userEnvProbe", "remoteEnv", "containerEnv", "overrideCommand", + "portsAttributes", "otherPortsAttributes", "forwardPorts", "shutdownAction", "updateRemoteUserUID", "hostRequirements", + } + // mergeConfiguration's replaceProperties, which it deletes from the spread + // configuration. + mergedReplacedKeys = []string{ + "customizations", "entrypoint", "onCreateCommand", "updateContentCommand", "postCreateCommand", + "postStartCommand", "postAttachCommand", "shutdownAction", + } +) + +// configurationKeys is the order of a configuration's properties: its +// document's, then customizations, which updateFromOldProperties adds when it +// moves a deprecated property, then the configFilePath TS sets last. +func configurationKeys(result *config.LoadResult) []string { + var keys []string + if result != nil { + keys = slices.Clone(result.KeyOrder) + } + return append(keys, "customizations", "configFilePath") +} + +// mergedKeys is the order of a merged configuration's properties: the +// configuration's, without the ones mergeConfiguration replaces, then the ones +// it sets; a property the configuration already has keeps its place, as in a +// JavaScript object literal. +func mergedKeys(configurationKeys []string) []string { + keys := slices.DeleteFunc(slices.Clone(configurationKeys), func(k string) bool { + return slices.Contains(mergedReplacedKeys, k) + }) + return append(keys, mergedConfigurationKeys...) +} + +// orderConfigurations gives result's configuration and mergedConfiguration, +// when it has them as objects, their TS order. +func orderConfigurations(result map[string]any, loaded *config.LoadResult) { + cfgKeys := configurationKeys(loaded) + if m, ok := result["configuration"].(map[string]any); ok { + result["configuration"] = orderedObject{m, cfgKeys} + } + if m, ok := result["mergedConfiguration"].(map[string]any); ok { + result["mergedConfiguration"] = orderedObject{m, mergedKeys(cfgKeys)} + } +} diff --git a/internal/cli/result_order_test.go b/internal/cli/result_order_test.go new file mode 100644 index 0000000..dde0a21 --- /dev/null +++ b/internal/cli/result_order_test.go @@ -0,0 +1,105 @@ +package cli + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/devcontainers/cli/internal/config" +) + +// read-configuration writes its result, the configuration and the Features +// configuration in TS's order (readConfiguration's object literal, the +// document's order with the deprecated properties' customizations and then +// configFilePath last, FeaturesConfig's literal), as the TS oracle printed +// for these documents. +func TestReadConfigurationWritesTSKeyOrder(t *testing.T) { + files := map[string]string{".devcontainer/devcontainer.json": `{ + // a comment + "name": "ko", + "image": "alpine:3", + "remoteUser": "root", + "features": {"./feat": {}}, + "containerEnv": {"Z": "1"}, + "postCreateCommand": "true", + "forwardPorts": [3000], + "capAdd": ["SYS_PTRACE"], + }`} + for k, v := range localFeatureFiles { + files[k] = v + } + stdout, err := readConfigurationStdout("--workspace-folder", writeConfigFixture(t, files), "--include-features-configuration") + if err != nil { + t.Fatal(err) + } + for path, want := range map[string][]string{ + "": {"configuration", "workspace", "featuresConfiguration", "ociAuthDiagnostics"}, + "configuration": {"name", "image", "remoteUser", "features", "containerEnv", "postCreateCommand", "forwardPorts", "capAdd", "configFilePath"}, + "featuresConfiguration": {"featureSets", "dstFolder"}, + } { + if got, err := jsonKeyOrder(stdout, path); err != nil || !slices.Equal(got, want) { + t.Errorf("keys of %q = %v (%v), want %v", path, got, err, want) + } + } + + stdout, err = readConfigurationStdout("--workspace-folder", writeConfigFixture(t, map[string]string{ + ".devcontainer.json": `{"image": "alpine:3", "extensions": ["a.b"], "name": "d", "forwardPorts": [1]}`, + })) + if err != nil { + t.Fatal(err) + } + want := []string{"image", "name", "forwardPorts", "customizations", "configFilePath"} + if got, err := jsonKeyOrder(stdout, "configuration"); err != nil || !slices.Equal(got, want) { + t.Errorf("configuration with a deprecated property: keys = %v (%v), want %v", got, err, want) + } +} + +// A merged configuration has the configuration's properties but the ones +// mergeConfiguration replaces, each in its place, then the ones it sets, as TS +// printed for up and set-up with this configuration; customizations moves to +// mergeConfiguration's place. Unknown keys follow, sorted. +func TestOrderConfigurationsWritesTSMergedOrder(t *testing.T) { + loaded := &config.LoadResult{KeyOrder: []string{"name", "customizations", "image", "remoteUser", "postCreateCommand", "containerEnv"}} + fields := func(keys ...string) map[string]any { + m := map[string]any{} + for _, k := range keys { + m[k] = 1 + } + return m + } + result := map[string]any{ + "outcome": "success", + "configuration": fields("name", "customizations", "image", "remoteUser", "postCreateCommand", "containerEnv", "configFilePath"), + "mergedConfiguration": fields("name", "image", "remoteUser", "containerEnv", "configFilePath", "init", "privileged", + "onCreateCommands", "updateContentCommands", "postCreateCommands", "postStartCommands", "postAttachCommands", + "remoteEnv", "portsAttributes", "customizations", "zGoOnly", "aGoOnly"), + } + orderConfigurations(result, loaded) + data, err := json.Marshal(orderedObject{result, setUpResultKeys}) + if err != nil { + t.Fatal(err) + } + for path, want := range map[string][]string{ + "": {"outcome", "configuration", "mergedConfiguration"}, + "configuration": {"name", "customizations", "image", "remoteUser", "postCreateCommand", "containerEnv", "configFilePath"}, + "mergedConfiguration": {"name", "image", "remoteUser", "containerEnv", "configFilePath", "init", "privileged", + "customizations", "onCreateCommands", "updateContentCommands", "postCreateCommands", "postStartCommands", + "postAttachCommands", "remoteEnv", "portsAttributes", "aGoOnly", "zGoOnly"}, + } { + if got, err := jsonKeyOrder(string(data), path); err != nil || !slices.Equal(got, want) { + t.Errorf("keys of %q = %v (%v), want %v", path, got, err, want) + } + } +} + +// A value encoding/json cannot write is the result's error, not a truncated +// result. +func TestOrderedObjectReportsAValueItCannotWrite(t *testing.T) { + if _, err := json.Marshal(orderedObject{map[string]any{"x": make(chan int)}, nil}); err == nil { + t.Error("a channel value was written") + } + out := &captureOutput{} + if err := writeSuccessJSON(out, map[string]any{"x": func() {}}); err == nil || out.out.Len() != 0 { + t.Errorf("writeSuccessJSON = %v, stdout %q", err, out.out.String()) + } +} diff --git a/internal/cli/run_user_commands.go b/internal/cli/run_user_commands.go index 8fc41fa..b5f12ba 100644 --- a/internal/cli/run_user_commands.go +++ b/internal/cli/run_user_commands.go @@ -231,7 +231,7 @@ func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCo return writeSuccessJSON(out, map[string]any{ "outcome": "success", "result": "done", - }) + }, runUserCommandsResultKeys...) } // configFromFlag is the config --config names when no --workspace-folder loaded diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 14180ee..7e64d24 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -194,7 +194,8 @@ func setUpContainer(ctx context.Context, out Output, engine *docker.EngineClient if err != nil { return writeErrorResult(out, err.Error()) } - return writeSuccessJSON(out, result) + orderConfigurations(result, loaded) + return writeSuccessJSON(out, result, setUpResultKeys...) } // setUpConfig loads the --config file set-up merges over the container's diff --git a/internal/cli/up.go b/internal/cli/up.go index a62c2d3..50b9edd 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -463,8 +463,9 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { if err := addUpConfiguration(result, opts, cfg, inspectResp, run.metadataOfContainer, logger); err != nil { return writeErrorResult(out, err.Error()) } + orderConfigurations(result, loadResult) - return writeSuccessJSON(out, result) + return writeSuccessJSON(out, result, upResultKeys...) } // imageLabels are the labels of the image ref names, nil when there is no ref diff --git a/internal/config/key_order_test.go b/internal/config/key_order_test.go new file mode 100644 index 0000000..6c7a1c1 --- /dev/null +++ b/internal/config/key_order_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +// KeyOrder is the configuration document's top-level order, then the override +// document's. +func TestLoadKeepsTheDocumentKeyOrder(t *testing.T) { + dir := t.TempDir() + base := filepath.Join(dir, "devcontainer.json") + override := filepath.Join(dir, "override.json") + if err := os.WriteFile(base, []byte(`{"name": "n", "image": "i", "capAdd": []}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(override, []byte(`{"remoteUser": "u", "name": "o"}`), 0o644); err != nil { + t.Fatal(err) + } + res, err := LoadDevContainerConfig(dir, base, override) + if err != nil { + t.Fatal(err) + } + if want := []string{"name", "image", "capAdd", "remoteUser", "name"}; !slices.Equal(res.KeyOrder, want) { + t.Errorf("KeyOrder = %v, want %v", res.KeyOrder, want) + } +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 590bca1..62bcec7 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -76,6 +76,10 @@ type LoadResult struct { // HostSub is the host-side substitution context used for the config; reuse it // so Feature metadata (mounts/containerEnv) resolves ${localEnv:…} the same way. HostSub HostSubContext + // KeyOrder is the configuration's top-level properties in the order its + // document lists them (the override's after the base's), the order TS + // writes them in (jsonc.parse keeps it). + KeyOrder []string } // WorkspaceConfig describes the workspace mount and folder inside the container. @@ -178,6 +182,7 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi // does (uriToFsPath(configFile)). objectPath := cmp.Or(configPath, overrideConfigPath) var raw map[string]any + var keyOrder []string if configPath != "" { baseData, err := os.ReadFile(configPath) switch { @@ -185,6 +190,7 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi if raw, err = parseConfigObject(baseData, objectPath); err != nil { return nil, wrapParseError("parse config", configPath, err) } + keyOrder = jsonc.Names(baseData) case overrideConfigPath == "": return nil, fmt.Errorf("read config %s: %w", configPath, err) } @@ -199,6 +205,7 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi return nil, wrapParseError("parse override config", overrideConfigPath, perr) } raw = deepMergeConfig(raw, overrideRaw) + keyOrder = append(keyOrder, jsonc.Names(overrideData)...) } // TS moves the deprecated properties before anything reads the config, so the @@ -262,6 +269,7 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi RawConfig: rawConfig, WorkspaceConfig: wsConfig, HostSub: ctx, + KeyOrder: keyOrder, }, nil } diff --git a/internal/jsonc/jsonc.go b/internal/jsonc/jsonc.go index cb9daa4..a332036 100644 --- a/internal/jsonc/jsonc.go +++ b/internal/jsonc/jsonc.go @@ -32,6 +32,16 @@ func StripComments(data []byte) ([]byte, error) { return hujson.Standardize(stripBOM(data)) } +// Names returns the names of the members of the top-level JSONC object, in +// the order the document lists them; nil when the document is not an object. +func Names(data []byte) []string { + v, err := hujson.Parse(stripBOM(data)) + if err != nil { + return nil + } + return objectNames(v) +} + // MemberNames returns the names of the members of the object at field of the // top-level JSONC object, in the order the document lists them; nil when the // document or the field is not an object. @@ -46,18 +56,23 @@ func MemberNames(data []byte, field string) []string { } for _, m := range top.Members { if lit, ok := m.Name.Value.(hujson.Literal); ok && lit.String() == field { - obj, ok := m.Value.Value.(*hujson.Object) - if !ok { - return nil - } - names := make([]string, 0, len(obj.Members)) - for _, f := range obj.Members { - if lit, ok := f.Name.Value.(hujson.Literal); ok { - names = append(names, lit.String()) - } - } - return names + return objectNames(m.Value) } } return nil } + +// objectNames returns the member names of v, nil when v is not an object. +func objectNames(v hujson.Value) []string { + obj, ok := v.Value.(*hujson.Object) + if !ok { + return nil + } + names := make([]string, 0, len(obj.Members)) + for _, f := range obj.Members { + if lit, ok := f.Name.Value.(hujson.Literal); ok { + names = append(names, lit.String()) + } + } + return names +} diff --git a/internal/jsonc/members_test.go b/internal/jsonc/members_test.go index a875477..2e9eaf4 100644 --- a/internal/jsonc/members_test.go +++ b/internal/jsonc/members_test.go @@ -24,3 +24,17 @@ func TestMemberNamesKeepsDocumentOrder(t *testing.T) { } } } + +// Names keeps the top-level object's document order; a document that is not +// an object, or not JSON, gives nil. +func TestNamesKeepsDocumentOrder(t *testing.T) { + doc := []byte("\ufeff{\n // comment\n \"name\": \"x\",\n \"image\": \"y\",\n \"capAdd\": [],\n}") + if got := Names(doc); !slices.Equal(got, []string{"name", "image", "capAdd"}) { + t.Errorf("Names = %v, want [name image capAdd]", got) + } + for _, d := range []string{`[]`, `{`} { + if got := Names([]byte(d)); got != nil { + t.Errorf("Names(%s) = %v, want nil", d, got) + } + } +} From 72f3d8c51f739dc417f72158d8b7406d48cdf7e9 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:33:17 -0300 Subject: [PATCH 19/37] fix(features): fail a malformed lockfile with the reference's message The reference's readLockfile rethrows every failure but ENOENT: a file that cannot be read fails with the Node.js fs error ("EISDIR: illegal operation on a directory, read", "EACCES: permission denied, open ''") and one that is not JSON with V8's JSON.parse SyntaxError ("Unexpected token 'o', "not json" is not valid JSON", "Expected ',' or '}' after property value in JSON at position 72 (line 2 column 1)"). ReadLockfile returned Go's own decoding error, and its callers dropped it. Return a *LockfileError carrying the class, message and fs properties TS reports. The V8 parser's error reporting is ported over the UTF-16 text Buffer.toString decodes (WHATWG UTF-8); the empty-marker check trims as String.prototype.trim does, and null, false, 0 and "" are no lockfile, as TS reads the parsed value through `?.`. JSON shaped unlike a lockfile stays an error (documented in DIVERGENCES.md). A ts-oracle golden runs the reference's readLockfile over the same files. Co-Authored-By: Claude Opus 5.5 --- docs/DIVERGENCES.md | 7 + internal/cli/cache_key_repo_held_test.go | 4 +- internal/cli/read_configuration_held_test.go | 2 +- internal/features/lockfile.go | 203 +++- internal/features/lockfile_oracle_test.go | 56 + internal/features/lockfile_read_test.go | 103 ++ .../testdata/ts-oracle/lockfile-read.json | 1026 +++++++++++++++++ internal/features/v8json.go | 383 ++++++ tools/tsoracle/oracle.ts | 23 + 9 files changed, 1798 insertions(+), 9 deletions(-) create mode 100644 internal/features/lockfile_oracle_test.go create mode 100644 internal/features/lockfile_read_test.go create mode 100644 internal/features/testdata/ts-oracle/lockfile-read.json create mode 100644 internal/features/v8json.go diff --git a/docs/DIVERGENCES.md b/docs/DIVERGENCES.md index d169894..3c52fa2 100644 --- a/docs/DIVERGENCES.md +++ b/docs/DIVERGENCES.md @@ -86,6 +86,13 @@ touches a compared surface, reflected in the parity matrix. inherited function's source text (`function Object() { [native code] }`) in the applied template. This CLI treats such a name like every other unknown option (`internal/templates/testdata/ts-oracle/templates.json`). +- **A `devcontainer-lock.json` that parses but is not shaped like a lockfile is an + error.** The reference keeps whatever `JSON.parse` returns and looks each Feature up with + `lockfile?.features[id]?.…`: an array, a number or a string throws a `TypeError` once a + Feature is looked up, and a `features` value that is not an object pins nothing. This CLI + refuses such a file when it reads it (`parse lockfile: …`). JSON that does not parse, or a + file that cannot be read, fails with the reference's own message, and `null`, `false`, `0` + and `""` are no lockfile, as in TS (`internal/features/testdata/ts-oracle/lockfile.json`). - **The `devcontainer.metadata` label writes object values with their keys sorted.** The label is TS's `JSON.stringify` of the entries: each entry's properties in TS's pick order (`pickFeatureProperties` for a Feature, `pickConfigProperties` for the diff --git a/internal/cli/cache_key_repo_held_test.go b/internal/cli/cache_key_repo_held_test.go index 54937c9..3938004 100644 --- a/internal/cli/cache_key_repo_held_test.go +++ b/internal/cli/cache_key_repo_held_test.go @@ -76,8 +76,8 @@ func TestComputeCacheKeyFailsOnAMalformedLockfile(t *testing.T) { if err == nil { t.Fatalf("key = %q, want the lockfile parse error", key) } - if !strings.Contains(err.Error(), "read features lockfile "+lockPath+": parse lockfile:") { - t.Errorf("err = %v, want it to name the lockfile and the parse failure", err) + if want := "read features lockfile " + lockPath + ": Unexpected end of JSON input"; err.Error() != want { + t.Errorf("err = %v, want %q: the lockfile and the reference's parse error", err, want) } } diff --git a/internal/cli/read_configuration_held_test.go b/internal/cli/read_configuration_held_test.go index 08f1d2e..dee59e5 100644 --- a/internal/cli/read_configuration_held_test.go +++ b/internal/cli/read_configuration_held_test.go @@ -476,7 +476,7 @@ func TestReadConfigurationCacheKeyFailsOnAMalformedLockfile(t *testing.T) { } lockPath := filepath.Join(ws, ".devcontainer", "devcontainer-lock.json") if msg, _ := envelope["message"].(string); envelope["outcome"] != "error" || - !strings.HasPrefix(msg, "compute cache key: read features lockfile "+lockPath+": parse lockfile:") { + msg != "compute cache key: read features lockfile "+lockPath+`: Unexpected token 'o', "not json" is not valid JSON` { t.Errorf("stdout = %s, want the error outcome naming %s", stdout.String(), lockPath) } } diff --git a/internal/features/lockfile.go b/internal/features/lockfile.go index 8e68243..b7c2f15 100644 --- a/internal/features/lockfile.go +++ b/internal/features/lockfile.go @@ -6,11 +6,15 @@ import ( "errors" "fmt" "io" + "io/fs" "maps" "os" "path/filepath" "slices" "strings" + "syscall" + "unicode" + "unicode/utf8" "golang.org/x/text/collate" "golang.org/x/text/language" @@ -108,9 +112,14 @@ func GenerateLockfile(config *Config, excludeUserFeatureIDs map[string]bool) *Lo return lf } -// ReadLockfile reads a lockfile from disk. -// Returns (lockfile, initLockfile, error). -// If the file is empty, initLockfile=true (marker to initialize on next build). +// ReadLockfile reads a lockfile from disk, as the reference's readLockfile +// (lockfile.ts) does. It returns (lockfile, initLockfile, error): a missing +// file is no lockfile, and a file that is blank once trimmed is the marker +// that asks for one to be written (initLockfile). Every other failure is an +// error, as readLockfile rethrows everything but ENOENT: a file that cannot be +// read or is not JSON comes back as a *LockfileError carrying the message the +// reference reports. JSON null is no lockfile (TS reads it through `?.`), and +// JSON that is not shaped like a lockfile is an error too. func ReadLockfile(configPath string) (*Lockfile, bool, error) { path := LockfilePath(configPath) data, err := os.ReadFile(path) @@ -118,14 +127,21 @@ func ReadLockfile(configPath string) (*Lockfile, bool, error) { if errors.Is(err, os.ErrNotExist) { return nil, false, nil } - return nil, false, err + return nil, false, nodeFSError(err) } - content := strings.TrimSpace(string(data)) - if content == "" { + // readLocalFile's Buffer, decoded by toString() and trimmed by trim(). + content := decodeUTF8Lossy(data) + if strings.TrimFunc(content, isJSWhitespace) == "" { return nil, true, nil // empty marker } + if msg := v8JSONSyntaxError(content); msg != "" { + return nil, false, &LockfileError{Name: "SyntaxError", Message: msg} + } + if !jsonTruthy(data) { + return nil, false, nil + } var lf Lockfile if err := json.Unmarshal(data, &lf); err != nil { return nil, false, fmt.Errorf("parse lockfile: %w", err) @@ -139,6 +155,181 @@ var ( ErrLockfileMismatch = errors.New("Lockfile does not match.") ) +// jsonTruthy is the truthiness of JSON.parse(data), valid JSON: TS reads the +// parsed lockfile through `lockfile?.` and `if (lockfile)`, so null, false, 0 +// and "" are no lockfile. +func jsonTruthy(data []byte) bool { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return true // valid JSON V8 parses; encoding/json reports the rest + } + switch v := v.(type) { + case nil: + return false + case bool: + return v + case string: + return v != "" + case json.Number: + f, _ := v.Float64() // out of range: ±Inf, truthy + return f != 0 + } + return true +} + +// LockfileError is a lockfile the reference's readLockfile fails on, carrying +// what the reference reports: the class and message of the JavaScript error, +// and for a file system error the properties Node.js gives it. +type LockfileError struct { + // Name is the error's class: SyntaxError for JSON.parse, Error for the + // file system. + Name string + Message string + // Errno, Code and Syscall are a file system error's properties; Path is + // set only when the open failed, as Node.js sets it. + Errno int + Code string + Syscall string + Path string + Err error +} + +func (e *LockfileError) Error() string { return e.Message } + +func (e *LockfileError) Unwrap() error { return e.Err } + +// ReferenceError marks the message as the reference's own, the message of a +// plain JavaScript error it throws (internal/errors.ReferenceError). +func (e *LockfileError) ReferenceError() {} + +// Stack is the error's stack without its frames, as the reference logs it. +func (e *LockfileError) Stack() string { return e.Name + ": " + e.Message } + +// Inspect is what console.error prints for the error, frames left out: a +// SyntaxError's stack, and for a file system error, which Node.js raises with +// no frames, util.inspect's "[Error: message]" and its properties. +func (e *LockfileError) Inspect() string { + if e.Code == "" { + return e.Stack() + } + props := fmt.Sprintf(" errno: %d,\n code: %s,\n syscall: %s", e.Errno, jsQuote(e.Code), jsQuote(e.Syscall)) + if e.Path != "" { + props += ",\n path: " + jsQuote(e.Path) + } + return fmt.Sprintf("[%s] {\n%s\n}", e.Stack(), props) +} + +// nodeErrno is the libuv code and description Node.js names a file system +// error with. +var nodeErrno = map[syscall.Errno][2]string{ + syscall.EACCES: {"EACCES", "permission denied"}, + syscall.EISDIR: {"EISDIR", "illegal operation on a directory"}, + syscall.ENOTDIR: {"ENOTDIR", "not a directory"}, + syscall.ELOOP: {"ELOOP", "too many symbolic links encountered"}, + syscall.ENAMETOOLONG: {"ENAMETOOLONG", "name too long"}, + syscall.EPERM: {"EPERM", "operation not permitted"}, + syscall.EIO: {"EIO", "i/o error"}, +} + +// nodeFSError is the error fs.readFile fails with for err: ": +// , ", with the path quoted when the open failed. An +// error Node.js would name differently keeps its own message. +func nodeFSError(err error) error { + var pathErr *fs.PathError + var errno syscall.Errno + if !errors.As(err, &pathErr) || !errors.As(err, &errno) { + return err + } + name, ok := nodeErrno[errno] + if !ok { + return err + } + e := &LockfileError{Name: "Error", Errno: -int(errno), Code: name[0], Syscall: pathErr.Op, Err: err} + e.Message = fmt.Sprintf("%s: %s, %s", name[0], name[1], pathErr.Op) + if pathErr.Op == "open" { + e.Path = pathErr.Path + e.Message += " '" + pathErr.Path + "'" + } + return e +} + +// jsQuote quotes s as util.inspect does: in single quotes, or in double +// quotes or backticks when s holds a single quote, escaping backslashes and +// the quote. +func jsQuote(s string) string { + quote := "'" + if strings.Contains(s, "'") { + switch { + case !strings.Contains(s, `"`): + quote = `"` + case !strings.Contains(s, "`"): + quote = "`" + } + } + s = strings.ReplaceAll(s, `\`, `\\`) + return quote + strings.ReplaceAll(s, quote, `\`+quote) + quote +} + +// isJSWhitespace is what String.prototype.trim removes: WhiteSpace (Zs, tab, +// vertical tab, form feed, no-break space, BOM) and LineTerminator. +func isJSWhitespace(r rune) bool { + switch r { + case '\t', '\n', '\v', '\f', '\r', '\u2028', '\u2029', '\ufeff': + return true + } + return unicode.Is(unicode.Zs, r) +} + +// decodeUTF8Lossy decodes b as Buffer.toString('utf8') does, the WHATWG UTF-8 +// decoder: each maximal invalid subpart becomes one U+FFFD. +func decodeUTF8Lossy(b []byte) string { + var out strings.Builder + for len(b) > 0 { + n := sequenceLen(b) + r, _ := utf8.DecodeRune(b[:n]) // U+FFFD for an invalid subpart + out.WriteRune(r) + b = b[n:] + } + return out.String() +} + +// sequenceLen is the length of the UTF-8 sequence at the start of b, or of +// its maximal subpart when it is invalid: a lead byte and the continuation +// bytes that could still have completed it. +func sequenceLen(b []byte) int { + lead := b[0] + var need int + lo, hi := byte(0x80), byte(0xbf) + switch { + case 0xc2 <= lead && lead <= 0xdf: + need = 1 + case 0xe0 <= lead && lead <= 0xef: + need = 2 + switch lead { + case 0xe0: + lo = 0xa0 + case 0xed: + hi = 0x9f + } + case 0xf0 <= lead && lead <= 0xf4: + need = 3 + switch lead { + case 0xf0: + lo = 0x90 + case 0xf4: + hi = 0x8f + } + } + n := 1 + for n <= need && n < len(b) && lo <= b[n] && b[n] <= hi { + n++ + lo, hi = 0x80, 0xbf + } + return n +} + // WriteOptions controls how WriteLockfile behaves. type WriteOptions struct { // Frozen fails (rather than writing) if the lockfile is missing or would change. diff --git a/internal/features/lockfile_oracle_test.go b/internal/features/lockfile_oracle_test.go new file mode 100644 index 0000000..9d47a35 --- /dev/null +++ b/internal/features/lockfile_oracle_test.go @@ -0,0 +1,56 @@ +package features + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/devcontainers/cli/internal/tsoracle/oracletest" +) + +// TestTSOracleReadLockfile asserts ReadLockfile against the reference's +// readLockfile over the same file (go tool task testdata:ts-oracle): what it +// makes of the file, or the message of the error it throws, which for JSON +// that does not parse is V8's JSON.parse message. +func TestTSOracleReadLockfile(t *testing.T) { + oracletest.Run(t, "testdata/ts-oracle/lockfile-read.json", map[string]oracletest.Func{ + "readLockfile": func(args []json.RawMessage) (any, error) { + var content any + if err := oracletest.Args(args, &content); err != nil { + return nil, err + } + dir := t.TempDir() + lockfilePath := filepath.Join(dir, "devcontainer-lock.json") + var err error + switch c := content.(type) { + case string: + err = os.WriteFile(lockfilePath, []byte(c), 0o644) + case map[string]any: + if b64, ok := c["base64"].(string); ok { + data, derr := base64.StdEncoding.DecodeString(b64) + if derr != nil { + return nil, derr + } + err = os.WriteFile(lockfilePath, data, 0o644) + } else if c["directory"] == true { + err = os.Mkdir(lockfilePath, 0o755) + } + } + if err != nil { + return nil, err + } + lockfile, initLockfile, err := ReadLockfile(filepath.Join(dir, "devcontainer.json")) + switch { + case err != nil: + return nil, err + case initLockfile: + return "init", nil + case lockfile != nil: + return "lockfile", nil + } + return "none", nil + }, + }) +} diff --git a/internal/features/lockfile_read_test.go b/internal/features/lockfile_read_test.go new file mode 100644 index 0000000..e99a6d6 --- /dev/null +++ b/internal/features/lockfile_read_test.go @@ -0,0 +1,103 @@ +package features + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +// JSON nested deeper than encoding/json decodes (V8 parses it) is still read +// as a lockfile, and refused for its shape, not taken for an empty one. +func TestReadLockfileRefusesTooDeepJSON(t *testing.T) { + dir := t.TempDir() + deep := strings.Repeat("[", 10001) + strings.Repeat("]", 10001) + if err := os.WriteFile(filepath.Join(dir, "devcontainer-lock.json"), []byte(deep), 0o644); err != nil { + t.Fatal(err) + } + lockfile, initLockfile, err := ReadLockfile(filepath.Join(dir, "devcontainer.json")) + if err == nil || lockfile != nil || initLockfile { + t.Errorf("ReadLockfile = %v, %v, %v; want the decoding error", lockfile, initLockfile, err) + } +} + +// A lockfile that cannot be opened fails as fs.readFile fails in the +// reference: "EACCES: permission denied, open ''", and console.error +// shows it with Node's error properties. +func TestReadLockfileUnreadableIsNodesEACCES(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root reads a mode 000 file") + } + dir := t.TempDir() + lockPath := filepath.Join(dir, "devcontainer-lock.json") + if err := os.WriteFile(lockPath, []byte("{}"), 0o000); err != nil { + t.Fatal(err) + } + _, _, err := ReadLockfile(filepath.Join(dir, "devcontainer.json")) + lerr, ok := err.(*LockfileError) + if !ok { + t.Fatalf("err = %#v, want a *LockfileError", err) + } + if want := "EACCES: permission denied, open '" + lockPath + "'"; lerr.Error() != want { + t.Errorf("message = %q, want %q", lerr.Error(), want) + } + want := "[Error: EACCES: permission denied, open '" + lockPath + "'] {\n errno: -13,\n code: 'EACCES',\n syscall: 'open',\n path: '" + lockPath + "'\n}" + if got := lerr.Inspect(); got != want { + t.Errorf("Inspect() =\n%s\nwant\n%s", got, want) + } +} + +// A lockfile that is a directory fails on the read, which Node.js reports +// without the path; a JSON error is a SyntaxError whose stack console.error +// prints. +func TestLockfileErrorForms(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "devcontainer-lock.json"), 0o755); err != nil { + t.Fatal(err) + } + _, _, err := ReadLockfile(filepath.Join(dir, "devcontainer.json")) + lerr, ok := err.(*LockfileError) + if !ok { + t.Fatalf("err = %#v, want a *LockfileError", err) + } + if got, want := lerr.Inspect(), "[Error: EISDIR: illegal operation on a directory, read] {\n errno: -21,\n code: 'EISDIR',\n syscall: 'read'\n}"; got != want { + t.Errorf("Inspect() =\n%s\nwant\n%s", got, want) + } + if got, want := lerr.Stack(), "Error: EISDIR: illegal operation on a directory, read"; got != want { + t.Errorf("Stack() = %q, want %q", got, want) + } + + syntax := &LockfileError{Name: "SyntaxError", Message: "Unexpected end of JSON input"} + if got, want := syntax.Inspect(), "SyntaxError: Unexpected end of JSON input"; got != want { + t.Errorf("SyntaxError Inspect() = %q, want %q", got, want) + } +} + +// util.inspect quotes a string in single quotes, else double quotes, else +// backticks, escaping backslashes and the quote it chose. +func TestJSQuote(t *testing.T) { + for in, want := range map[string]string{ + `/a/b`: `'/a/b'`, + `/a'b`: `"/a'b"`, + `/a'b"c`: "`/a'b\"c`", + "/a'b\"c`d": `'/a\'b"c` + "`" + `d'`, + `/a\b`: `'/a\\b'`, + } { + if got := jsQuote(in); got != want { + t.Errorf("jsQuote(%q) = %s, want %s", in, got, want) + } + } +} + +// An error Node.js would name some other way keeps its own message. +func TestNodeFSErrorKeepsAnUnnamedError(t *testing.T) { + plain := os.ErrClosed + if got := nodeFSError(plain); got != plain { + t.Errorf("nodeFSError(%v) = %v, want it unchanged", plain, got) + } + unnamed := &os.PathError{Op: "read", Path: "/x", Err: syscall.EAGAIN} + if got := nodeFSError(unnamed); got != error(unnamed) { + t.Errorf("nodeFSError(%v) = %v, want it unchanged", unnamed, got) + } +} diff --git a/internal/features/testdata/ts-oracle/lockfile-read.json b/internal/features/testdata/ts-oracle/lockfile-read.json new file mode 100644 index 0000000..2da16d0 --- /dev/null +++ b/internal/features/testdata/ts-oracle/lockfile-read.json @@ -0,0 +1,1026 @@ +{ + "comment": "lockfile.ts readLockfile over a devcontainer-lock.json holding each case's text (or base64 bytes, a directory, or nothing); ported in lockfile.go (ReadLockfile) and v8json.go, whose messages are V8 JSON.parse's. The result is none (no lockfile), init (the empty marker) or lockfile.", + "cases": [ + { + "fn": "readLockfile", + "args": [ + "" + ], + "result": "init" + }, + { + "fn": "readLockfile", + "args": [ + " \n\t" + ], + "result": "init" + }, + { + "fn": "readLockfile", + "args": [ + "" + ], + "result": "init" + }, + { + "fn": "readLockfile", + "args": [ + " \u2028\u2029 \u000b\f" + ], + "result": "init" + }, + { + "fn": "readLockfile", + "args": [ + "…" + ], + "error": "Unexpected token '…', \"…\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{\n \"features\": {\n \"ghcr.io/devcontainers/features/node:1\": {\n \"version\": \"1.6.3\",\n \"resolved\": \"ghcr.io/devcontainers/features/node@sha256:abc\",\n \"integrity\": \"sha256:abc\"\n }\n }\n}\n" + ], + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "{}" + ], + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "{\"features\":{}}" + ], + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "null" + ], + "result": "none" + }, + { + "fn": "readLockfile", + "args": [ + "{}" + ], + "error": "Unexpected token '', \"{}\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "not json" + ], + "error": "Unexpected token 'o', \"not json\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "x" + ], + "error": "Unexpected token 'x', \"x\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{" + ], + "error": "Expected property name or '}' in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "[" + ], + "error": "Unexpected end of JSON input" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\"" + ], + "error": "Expected ':' after property name in JSON at position 4 (line 1 column 5)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":" + ], + "error": "Unexpected end of JSON input" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":1" + ], + "error": "Expected ',' or '}' after property value in JSON at position 6 (line 1 column 7)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":1," + ], + "error": "Expected double-quoted property name in JSON at position 7 (line 1 column 8)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":1 \"b\":2}" + ], + "error": "Expected ',' or '}' after property value in JSON at position 7 (line 1 column 8)" + }, + { + "fn": "readLockfile", + "args": [ + "[1 2]" + ], + "error": "Expected ',' or ']' after array element in JSON at position 3 (line 1 column 4)" + }, + { + "fn": "readLockfile", + "args": [ + "[1,]" + ], + "error": "Unexpected token ']', \"[1,]\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":1,}" + ], + "error": "Expected double-quoted property name in JSON at position 7 (line 1 column 8)" + }, + { + "fn": "readLockfile", + "args": [ + "{a:1}" + ], + "error": "Expected property name or '}' in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\" 1}" + ], + "error": "Expected ':' after property name in JSON at position 5 (line 1 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + "\"abc" + ], + "error": "Unterminated string in JSON at position 4 (line 1 column 5)" + }, + { + "fn": "readLockfile", + "args": [ + "\"a\u0001b\"" + ], + "error": "Bad control character in string literal in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "\"\\x\"" + ], + "error": "Bad escaped character in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "\"\\u12g4\"" + ], + "error": "Bad Unicode escape in JSON at position 5 (line 1 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + "\"\\u12" + ], + "error": "Bad Unicode escape in JSON at position 5 (line 1 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + "\"\\u" + ], + "error": "Bad Unicode escape in JSON at position 3 (line 1 column 4)" + }, + { + "fn": "readLockfile", + "args": [ + "\"\\" + ], + "error": "Unexpected end of JSON input" + }, + { + "fn": "readLockfile", + "args": [ + "\"\\€\"" + ], + "error": "Unexpected token '€', \"\"\\€\"\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "\"\\é\"" + ], + "error": "Bad escaped character in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":\"\\/\\b\\f\\n\\r\\t\\\"\\\\\\u00Af\"}" + ], + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "-" + ], + "error": "No number after minus sign in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "-a" + ], + "error": "No number after minus sign in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "-0" + ], + "result": "none" + }, + { + "fn": "readLockfile", + "args": [ + "-01" + ], + "error": "Unexpected number in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "-0." + ], + "error": "Unterminated fractional number in JSON at position 3 (line 1 column 4)" + }, + { + "fn": "readLockfile", + "args": [ + "1." + ], + "error": "Unterminated fractional number in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "1.a" + ], + "error": "Unterminated fractional number in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "1e" + ], + "error": "Exponent part is missing a number in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "1e+" + ], + "error": "Exponent part is missing a number in JSON at position 3 (line 1 column 4)" + }, + { + "fn": "readLockfile", + "args": [ + "0e" + ], + "error": "Exponent part is missing a number in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "0.1e-" + ], + "error": "Exponent part is missing a number in JSON at position 5 (line 1 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":1E+5}" + ], + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "1e5.5" + ], + "error": "Unexpected non-whitespace character after JSON at position 3 (line 1 column 4)" + }, + { + "fn": "readLockfile", + "args": [ + "01" + ], + "error": "Unexpected number in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "0x10" + ], + "error": "Unexpected non-whitespace character after JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "1.5e5x" + ], + "error": "Unexpected non-whitespace character after JSON at position 5 (line 1 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + "[-]" + ], + "error": "No number after minus sign in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":-}" + ], + "error": "No number after minus sign in JSON at position 6 (line 1 column 7)" + }, + { + "fn": "readLockfile", + "args": [ + "+1" + ], + "error": "Unexpected token '+', \"+1\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + ".5" + ], + "error": "Unexpected token '.', \".5\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{}x" + ], + "error": "Unexpected non-whitespace character after JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "{} x" + ], + "error": "Unexpected non-whitespace character after JSON at position 3 (line 1 column 4)" + }, + { + "fn": "readLockfile", + "args": [ + "nul" + ], + "error": "Unexpected end of JSON input" + }, + { + "fn": "readLockfile", + "args": [ + "tru" + ], + "error": "Unexpected end of JSON input" + }, + { + "fn": "readLockfile", + "args": [ + "tx" + ], + "error": "Unexpected token 'x', \"tx\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "t\"" + ], + "error": "Unexpected string in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "t1" + ], + "error": "Unexpected number in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "f-" + ], + "error": "Unexpected number in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "nx" + ], + "error": "Unexpected token 'x', \"nx\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "falsey" + ], + "error": "Unexpected non-whitespace character after JSON at position 5 (line 1 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + "null null" + ], + "error": "Unexpected non-whitespace character after JSON at position 5 (line 1 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + "[1,2,3]]" + ], + "error": "Unexpected non-whitespace character after JSON at position 7 (line 1 column 8)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":tr}" + ], + "error": "Unexpected token '}', \"{\"a\":tr}\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":nulll}" + ], + "error": "Expected ',' or '}' after property value in JSON at position 9 (line 1 column 10)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":[1,{\"b\":tx}]}" + ], + "error": "Unexpected token 'x', \"{\"a\":[1,{\"b\":tx}]}\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "Infinity" + ], + "error": "\"Infinity\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "NaN" + ], + "error": "\"NaN\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "undefined" + ], + "error": "\"undefined\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "[object Object]" + ], + "error": "\"[object Object]\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + " NaN" + ], + "error": "Unexpected token 'N', \" NaN\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "NaN " + ], + "error": "Unexpected token 'N', \"NaN \" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "-Infinity" + ], + "error": "No number after minus sign in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "xyyyyyyyyyyyyyyyyyyy" + ], + "error": "Unexpected token 'x', \"xyyyyyyyyyyyyyyyyyyy\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "xyyyyyyyyyyyyyyyyyyyy" + ], + "error": "Unexpected token 'x', \"xyyyyyyyyy\"... is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "[1,2,3,4,5,6,7,8,9,10,x,11,12,13,14,15]" + ], + "error": "Unexpected token 'x', ...\",7,8,9,10,x,11,12,13\"... is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,x]" + ], + "error": "Unexpected token 'x', ...\",16,17,18,x]\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "[1,2,3,4,5,6,7,8,9,x,11,12,13,14,15,16,17,18,19]" + ], + "error": "Unexpected token 'x', ...\"5,6,7,8,9,x,11,12,13\"... is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,x2,3,4,5,6,7,8,9,1]" + ], + "error": "Unexpected token 'x', ...\",17,18,19,x2,3,4,5,6\"... is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":\r\n1,\r\"b\" 2}" + ], + "error": "Expected ':' after property name in JSON at position 14 (line 3 column 5)" + }, + { + "fn": "readLockfile", + "args": [ + "{\r\n\r\n\"a\" 1}" + ], + "error": "Expected ':' after property name in JSON at position 9 (line 3 column 5)" + }, + { + "fn": "readLockfile", + "args": [ + "[1]\n\n\nx" + ], + "error": "Unexpected non-whitespace character after JSON at position 6 (line 4 column 1)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":\"b\"}\n}" + ], + "error": "Unexpected non-whitespace character after JSON at position 10 (line 2 column 1)" + }, + { + "fn": "readLockfile", + "args": [ + "\r" + ], + "result": "init" + }, + { + "fn": "readLockfile", + "args": [ + "[1\r]x" + ], + "error": "Unexpected non-whitespace character after JSON at position 4 (line 2 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "[é]" + ], + "error": "Unexpected token 'é', \"[é]\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "[1,€]" + ], + "error": "Unexpected token '€', \"[1,€]\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{\"€\" 1}" + ], + "error": "Expected ':' after property name in JSON at position 5 (line 1 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"😀\" 1}" + ], + "error": "Expected ':' after property name in JSON at position 6 (line 1 column 7)" + }, + { + "fn": "readLockfile", + "args": [ + "\"😀\" x" + ], + "error": "Unexpected non-whitespace character after JSON at position 5 (line 1 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + "[ ]" + ], + "error": "Unexpected token ' ', \"[ ]\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":\"\"}" + ], + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "[1}" + ], + "error": "Expected ',' or ']' after array element in JSON at position 2 (line 1 column 3)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":1]" + ], + "error": "Expected ',' or '}' after property value in JSON at position 6 (line 1 column 7)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":1 " + ], + "error": "Expected ',' or '}' after property value in JSON at position 7 (line 1 column 8)" + }, + { + "fn": "readLockfile", + "args": [ + "[1 " + ], + "error": "Expected ',' or ']' after array element in JSON at position 3 (line 1 column 4)" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\" :" + ], + "error": "Unexpected end of JSON input" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":[ ]}" + ], + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "{ }" + ], + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "{,}" + ], + "error": "Expected property name or '}' in JSON at position 1 (line 1 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "[,]" + ], + "error": "Unexpected token ',', \"[,]\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":[}]" + ], + "error": "Unexpected token '}', \"{\"a\":[}]\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":1,,\"b\":2}" + ], + "error": "Expected double-quoted property name in JSON at position 7 (line 1 column 8)" + }, + { + "fn": "readLockfile", + "args": [ + "<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> branch\n" + ], + "error": "Unexpected token '<', \"<<<<<<< HE\"... is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + "{\n \"features\": {\n \"ghcr.io/devcontainers/features/node:1\": {\n \"version\": \"1.6.3\"\n \"resolved\": \"ghcr.io/devcontainers/features/node@sha256:abc\",\n \"integrity\": \"sha256:abc\"\n }\n }\n}\n" + ], + "error": "Expected ',' or '}' after property value in JSON at position 96 (line 5 column 7)" + }, + { + "fn": "readLockfile", + "args": [ + "{\n \"features\": {\n \"ghcr.io/devcontainers/features/node:1\": {\n \"version\": \"1.6.3\",\n \"resolved\": \"ghcr.io/devcontainers/features/node@sha256:abc\",\n \"integrity\": \"sha256:abc\",\n }\n }\n}\n" + ], + "error": "Expected double-quoted property name in JSON at position 196 (line 7 column 5)" + }, + { + "fn": "readLockfile", + "args": [ + "{\n \"features\": {\n \"ghcr.io/devcontainers/features/node:1\": {\n \"version\": \"1.6.3\",\n \"resolved\": \"ghcr.io/devcontainers/features/node@sha256:abc\",\n \"integrity\": \"sha256:abc\"\n }\n }" + ], + "error": "Expected ',' or '}' after property value in JSON at position 200 (line 8 column 4)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "4oJ7fQ==" + } + ], + "error": "Unexpected token '�', \"�{}\" is not valid JSON" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoi4oIifSB4" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 10 (line 1 column 11)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoi//4ifSB4" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 11 (line 1 column 12)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoi7aCAIn0geA==" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 12 (line 1 column 13)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoi8J+YIn0geA==" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 10 (line 1 column 11)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoiwK8ifSB4" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 11 (line 1 column 12)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoi9JCAgCJ9IHg=" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 13 (line 1 column 14)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoi4IAifSB4" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 11 (line 1 column 12)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoiwyJ9IHg=" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 10 (line 1 column 11)" + }, + { + "fn": "readLockfile", + "args": [ + "\n{" + ], + "error": "Expected property name or '}' in JSON at position 2 (line 2 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "\r\n{" + ], + "error": "Expected property name or '}' in JSON at position 3 (line 2 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "\n\r{" + ], + "error": "Expected property name or '}' in JSON at position 3 (line 3 column 2)" + }, + { + "fn": "readLockfile", + "args": [ + "\r\n\r\n{\"a\" 1}" + ], + "error": "Expected ':' after property name in JSON at position 9 (line 3 column 6)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoiw6mpIn0geA==" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 11 (line 1 column 12)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoi4oKsgCJ9IHg=" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 11 (line 1 column 12)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoi8J+YgIAifSB4" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 12 (line 1 column 13)" + }, + { + "fn": "readLockfile", + "args": [ + { + "base64": "eyJhIjoi77+9In0geA==" + } + ], + "error": "Unexpected non-whitespace character after JSON at position 10 (line 1 column 11)" + }, + { + "fn": "readLockfile", + "args": [ + { + "directory": true + } + ], + "error": "EISDIR: illegal operation on a directory, read" + }, + { + "fn": "readLockfile", + "args": [ + null + ], + "result": "none" + }, + { + "fn": "readLockfile", + "args": [ + "[]" + ], + "divergence": "JSON that is not an object: TS keeps it as a lockfile and looks Features up in it with ?. (an undefined `features` then throws a TypeError); Go refuses JSON that is not shaped like a lockfile", + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "{\"features\":[]}" + ], + "divergence": "a `features` that is not an object: TS finds no pins in it, Go refuses JSON that is not shaped like a lockfile", + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "5" + ], + "divergence": "JSON that is not an object: TS keeps it as a lockfile (looking a Feature up in it throws a TypeError), Go refuses JSON that is not shaped like a lockfile", + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "false" + ], + "result": "none" + }, + { + "fn": "readLockfile", + "args": [ + "0" + ], + "result": "none" + }, + { + "fn": "readLockfile", + "args": [ + "\"\"" + ], + "result": "none" + }, + { + "fn": "readLockfile", + "args": [ + "0e5" + ], + "result": "none" + }, + { + "fn": "readLockfile", + "args": [ + "-0.0" + ], + "result": "none" + }, + { + "fn": "readLockfile", + "args": [ + "1e999" + ], + "divergence": "JSON that is not an object (Infinity to TS): TS keeps it as a lockfile (looking a Feature up in it throws a TypeError), Go refuses JSON that is not shaped like a lockfile", + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "{\"a\":1e999}" + ], + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "true" + ], + "divergence": "JSON that is not an object: TS keeps it as a lockfile (looking a Feature up in it throws a TypeError), Go refuses JSON that is not shaped like a lockfile", + "result": "lockfile" + }, + { + "fn": "readLockfile", + "args": [ + "\"x\"" + ], + "divergence": "JSON that is not an object: TS keeps it as a lockfile (looking a Feature up in it throws a TypeError), Go refuses JSON that is not shaped like a lockfile", + "result": "lockfile" + } + ] +} diff --git a/internal/features/v8json.go b/internal/features/v8json.go new file mode 100644 index 0000000..5144750 --- /dev/null +++ b/internal/features/v8json.go @@ -0,0 +1,383 @@ +package features + +import ( + "fmt" + "unicode/utf16" +) + +// This file ports the error reporting of V8's JSON.parse (json-parser.cc, the +// V8 12.4 Node.js 22 ships), which the reference's readLockfile surfaces as +// the message of the SyntaxError it rethrows. It only validates: encoding/json +// still decodes a lockfile that parses. +// +// V8 parses a JavaScript string, so positions, columns and the quoted context +// count UTF-16 code units; a lone surrogate V8 would quote comes out as U+FFFD. + +// v8JSONSyntaxError is the message JSON.parse(src) throws with, or "" when src +// is valid JSON. +func v8JSONSyntaxError(src string) string { + p := &v8Parser{s: utf16.Encode([]rune(src))} + if p.value() && !p.check(tokEOS) { + p.report(p.peek(), msgNonWhitespaceAfterJSON) + } + return p.err +} + +type jsonToken int + +const ( + tokIllegal jsonToken = iota + tokEOS + tokString + tokNumber + tokTrue + tokFalse + tokNull + tokWhitespace + tokColon + tokComma + tokLBrack + tokRBrack + tokLBrace + tokRBrace +) + +// tokenOf is V8's one_char_json_tokens: the token a character starts. +func tokenOf(c uint16) jsonToken { + switch { + case c == '"': + return tokString + case c == '-' || '0' <= c && c <= '9': + return tokNumber + case c == ' ' || c == '\t' || c == '\r' || c == '\n': + return tokWhitespace + } + switch c { + case 't': + return tokTrue + case 'f': + return tokFalse + case 'n': + return tokNull + case ':': + return tokColon + case ',': + return tokComma + case '[': + return tokLBrack + case ']': + return tokRBrack + case '{': + return tokLBrace + case '}': + return tokRBrace + } + return tokIllegal +} + +// The message templates V8 reports with a position (message-template.h), up +// to " at position". +const ( + msgPropNameOrRBrace = "Expected property name or '}' in JSON" + msgDoubleQuotedPropName = "Expected double-quoted property name in JSON" + msgColonAfterPropName = "Expected ':' after property name in JSON" + msgCommaOrRBrace = "Expected ',' or '}' after property value in JSON" + msgCommaOrRBrack = "Expected ',' or ']' after array element in JSON" + msgNonWhitespaceAfterJSON = "Unexpected non-whitespace character after JSON" + msgUnterminatedString = "Unterminated string in JSON" + msgBadControlCharacter = "Bad control character in string literal in JSON" + msgBadEscapedCharacter = "Bad escaped character in JSON" + msgBadUnicodeEscape = "Bad Unicode escape in JSON" + msgNoNumberAfterMinus = "No number after minus sign in JSON" + msgUnterminatedFraction = "Unterminated fractional number in JSON" + msgExponentMissingNumber = "Exponent part is missing a number in JSON" + msgUnexpectedNumber = "Unexpected number in JSON" + msgUnexpectedString = "Unexpected string in JSON" +) + +// kMaxContextCharacters: how much of a long source an "Unexpected token" +// message quotes on each side of the token. +const v8ContextCharacters = 10 + +type v8Parser struct { + s []uint16 + pos int + err string +} + +// peek is SkipWhitespace: it moves past whitespace and returns the token the +// next character starts (EOS at the end). +func (p *v8Parser) peek() jsonToken { + for ; p.pos < len(p.s); p.pos++ { + if t := tokenOf(p.s[p.pos]); t != tokWhitespace { + return t + } + } + return tokEOS +} + +// check consumes the next token when it is t. +func (p *v8Parser) check(t jsonToken) bool { + if p.peek() == t { + p.pos++ + return true + } + return false +} + +// expect consumes the next token, which must be t, else reports msg. +func (p *v8Parser) expect(t jsonToken, msg string) bool { + if p.check(t) { + return true + } + p.report(p.peek(), msg) + return false +} + +// value parses one JSON value (ParseJsonValue). +func (p *v8Parser) value() bool { + switch p.peek() { + case tokString: + p.pos++ + return p.scanString() + case tokNumber: + return p.number() + case tokLBrace: + p.pos++ + if p.check(tokRBrace) { + return true + } + if !p.property(msgPropNameOrRBrace) { + return false + } + for { + if !p.value() { + return false + } + if !p.check(tokComma) { + return p.expect(tokRBrace, msgCommaOrRBrace) + } + if !p.property(msgDoubleQuotedPropName) { + return false + } + } + case tokLBrack: + p.pos++ + if p.check(tokRBrack) { + return true + } + for { + if !p.value() { + return false + } + if !p.check(tokComma) { + return p.expect(tokRBrack, msgCommaOrRBrack) + } + } + case tokTrue: + return p.literal("true") + case tokFalse: + return p.literal("false") + case tokNull: + return p.literal("null") + } + p.reportUnexpectedCharacter() + return false +} + +// property parses a property key and its colon; missing is the message for a +// missing key. +func (p *v8Parser) property(missing string) bool { + return p.expect(tokString, missing) && p.scanString() && p.expect(tokColon, msgColonAfterPropName) +} + +// literal is ScanLiteral: the first character of word already matched. +func (p *v8Parser) literal(word string) bool { + for i := 1; i < len(word); i++ { + p.pos++ + if p.pos == len(p.s) { + p.report(tokEOS, "") + return false + } + if p.s[p.pos] != uint16(word[i]) { + p.reportUnexpectedCharacter() + return false + } + } + p.pos++ + return true +} + +// scanString is ScanJsonString, from just after the opening quote. +func (p *v8Parser) scanString() bool { + for ; p.pos < len(p.s); p.pos++ { + switch c := p.s[p.pos]; { + case c == '"': + p.pos++ + return true + case c == '\\': + if !p.escape() { + return false + } + case c < 0x20: + p.report(tokIllegal, msgBadControlCharacter) + return false + } + } + p.report(tokIllegal, msgUnterminatedString) + return false +} + +// escape checks the escape sequence whose backslash is at pos, leaving pos on +// its last character. +func (p *v8Parser) escape() bool { + p.pos++ + if p.pos == len(p.s) || p.s[p.pos] > 0xff { + p.reportUnexpectedCharacter() + return false + } + switch p.s[p.pos] { + case '"', '\\', '/', 'b', 'f', 'n', 'r', 't': + return true + case 'u': + for range 4 { + p.pos++ + if p.pos == len(p.s) || !isHexDigit(p.s[p.pos]) { + p.report(tokIllegal, msgBadUnicodeEscape) + return false + } + } + return true + } + p.report(tokIllegal, msgBadEscapedCharacter) + return false +} + +// number is ParseJsonNumber's validation. +func (p *v8Parser) number() bool { + if p.s[p.pos] == '-' { + p.pos++ + } + if p.at('0') { + p.pos++ + if p.isDigit() { + p.report(tokNumber, "") + return false + } + } else if !p.digits() { + p.report(tokIllegal, msgNoNumberAfterMinus) + return false + } + if p.at('.') { + p.pos++ + if !p.digits() { + p.report(tokIllegal, msgUnterminatedFraction) + return false + } + } + if p.at('e') || p.at('E') { + p.pos++ + if p.at('-') || p.at('+') { + p.pos++ + } + if !p.digits() { + p.report(tokIllegal, msgExponentMissingNumber) + return false + } + } + return true +} + +func (p *v8Parser) at(c uint16) bool { + return p.pos < len(p.s) && p.s[p.pos] == c +} + +func (p *v8Parser) isDigit() bool { + return p.pos < len(p.s) && '0' <= p.s[p.pos] && p.s[p.pos] <= '9' +} + +// digits consumes a run of decimal digits, reporting whether there was one. +func (p *v8Parser) digits() bool { + start := p.pos + for p.isDigit() { + p.pos++ + } + return p.pos > start +} + +func isHexDigit(c uint16) bool { + return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F' +} + +// reportUnexpectedCharacter reports the character at pos by the token it +// starts. +func (p *v8Parser) reportUnexpectedCharacter() { + if p.pos == len(p.s) { + p.report(tokEOS, "") + return + } + p.report(tokenOf(p.s[p.pos]), "") +} + +// report is ReportUnexpectedToken: msg, a positioned template, wins over the +// token's own message (LookUpErrorMessageForJsonToken). +func (p *v8Parser) report(t jsonToken, msg string) { + switch { + case msg == "" && t == tokEOS: + p.err = "Unexpected end of JSON input" + return + case msg == "" && t == tokNumber: + msg = msgUnexpectedNumber + case msg == "" && t == tokString: + msg = msgUnexpectedString + case msg == "": + p.err = p.unexpectedToken() + return + } + line, column := p.location() + p.err = fmt.Sprintf("%s at position %d (line %d column %d)", msg, p.pos, line, column) +} + +// unexpectedToken is the message for an unexpected character, quoting the +// source, or for a long source the context around the character. +func (p *v8Parser) unexpectedToken() string { + src := p.text(0, len(p.s)) + switch src { + case "NaN", "Infinity", "undefined", "[object Object]": + return fmt.Sprintf("%q is not valid JSON", src) + } + token := p.text(p.pos, p.pos+1) + n := len(p.s) + switch { + case n < 2*v8ContextCharacters+1: + return fmt.Sprintf("Unexpected token '%s', \"%s\" is not valid JSON", token, src) + case p.pos < v8ContextCharacters: + return fmt.Sprintf("Unexpected token '%s', \"%s\"... is not valid JSON", token, p.text(0, p.pos+v8ContextCharacters)) + case p.pos < n-v8ContextCharacters: + return fmt.Sprintf("Unexpected token '%s', ...\"%s\"... is not valid JSON", token, p.text(p.pos-v8ContextCharacters, p.pos+v8ContextCharacters)) + } + return fmt.Sprintf("Unexpected token '%s', ...\"%s\" is not valid JSON", token, p.text(p.pos-v8ContextCharacters, n)) +} + +func (p *v8Parser) text(from, to int) string { + return string(utf16.Decode(p.s[from:to])) +} + +// location is CalculateFileLocation: the 1-based line and column of pos, with +// "\r\n", "\r" and "\n" each ending a line. +func (p *v8Parser) location() (line, column int) { + line, lineStart := 1, 0 + for i, c := range p.s[:p.pos] { + switch c { + case '\r': + line, lineStart = line+1, i+1 + case '\n': + // The '\n' of a "\r\n" ends no second line. + if i == 0 || p.s[i-1] != '\r' { + line++ + } + lineStart = i + 1 + } + } + return line, p.pos - lineStart + 1 +} diff --git a/tools/tsoracle/oracle.ts b/tools/tsoracle/oracle.ts index a9e5f6e..4a4a5c1 100644 --- a/tools/tsoracle/oracle.ts +++ b/tools/tsoracle/oracle.ts @@ -21,6 +21,7 @@ import { readVersionPrefix, convertMountToVolume, convertMountToVolumeTopLevelEl import { qualifyImageName, isBuildxCacheToInline } from '../../reference/src/spec-node/utils'; import { logEventToTerminalText, createPlainLog } from '../../reference/src/spec-utils/log'; import { parseCrossOriginAuthHosts, isAllowedTokenServiceRealm } from '../../reference/src/spec-configuration/httpOCIRegistry'; +import { readLockfile } from '../../reference/src/spec-configuration/lockfile'; // escapeQuotesForShell, convertMountToVolume, convertMountToVolumeTopLevelElement // and replaceTemplatedValues are module-private in the reference; build.mjs exports them for this bundle. @@ -98,6 +99,28 @@ const fns: Record any> = { [...parseCrossOriginAuthHosts(entries)].map(([registry, hosts]) => [registry, [...hosts].sort()])), isAllowedTokenServiceRealm: (realm: string, registryUrl: string, entries: string[]) => isAllowedTokenServiceRealm(realm, registryUrl, entries), + // spec-configuration/lockfile.ts: the devcontainer-lock.json next to a + // devcontainer.json in a fresh folder holds the case's text, its base64 + // bytes, or is a directory or missing. The result is what the reader made of + // it: no lockfile (undefined, or null), the empty marker, or a lockfile. + readLockfile: async (content: string | { base64?: string; directory?: boolean } | null) => { + const fs = require('fs'), os = require('os'), path = require('path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-oracle-lockfile-')); + try { + const lockfilePath = path.join(dir, 'devcontainer-lock.json'); + if (typeof content === 'string') { + fs.writeFileSync(lockfilePath, content); + } else if (content?.base64 !== undefined) { + fs.writeFileSync(lockfilePath, Buffer.from(content.base64, 'base64')); + } else if (content?.directory) { + fs.mkdirSync(lockfilePath); + } + const { lockfile, initLockfile } = await readLockfile({ configFilePath: URI.file(path.join(dir, 'devcontainer.json')) } as any); + return initLockfile ? 'init' : lockfile ? 'lockfile' : 'none'; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, }; async function main() { From 2598f62f26e25fc614416e292070b3407d105915 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:35:16 -0300 Subject: [PATCH 20/37] fix(cli): fail outdated on a lockfile it cannot read outdated dropped every ReadLockfile error and reported versions as if no lockfile were committed. In the reference, loadVersionInfo calls readLockfile, which rethrows all but ENOENT, and outdated's catch logs the error's stack (output.write, debug level) and exits 1 with nothing on stdout. Do the same. A unit test and the contract parity case outdated.malformed-lockfile-error hold it. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 13 +++++++++++++ internal/cli/outdated.go | 22 ++++++++++++++++++++-- internal/cli/outdated_held_test.go | 23 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 9795b2b..944bfd7 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -297,6 +297,19 @@ initial_cases: current_status: match notes: "XOR from yargs .check(): --feature and --target-version must be used together. Only --feature => exit 1 'The '--target-version' and '--feature' flag must be used together.'. Go ignored both flags; now RunE applies the XOR with the same message." + - id: outdated.malformed-lockfile-error + lane: contract + command: outdated + priority: p1 + docker_required: false + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"ghcr.io/devcontainers/features/node:1\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; printf '%s' 'not json' > $WORKSPACE/.devcontainer/devcontainer-lock.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "outdated --workspace-folder ${WORKSPACE} --output-format json" + asserts: [exit_code, stdout_normalized, stderr_normalized] + class: lockfile-error + current_status: match + notes: "readLockfile rethrows JSON.parse's SyntaxError; outdated's catch logs its stack at debug level and exits 1, so at the default log level stdout and stderr say nothing (TS prints only its banner). Go used to ignore the lockfile and report the versions." + - id: outdated.image-with-features-text lane: runtime command: outdated diff --git a/internal/cli/outdated.go b/internal/cli/outdated.go index 22ec8ca..4f631ee 100644 --- a/internal/cli/outdated.go +++ b/internal/cli/outdated.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "regexp" @@ -13,6 +14,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/devcontainers/cli/internal/config" + coreerrors "github.com/devcontainers/cli/internal/errors" "github.com/devcontainers/cli/internal/features" "github.com/devcontainers/cli/internal/jsonc" "github.com/devcontainers/cli/internal/log" @@ -186,8 +188,14 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { ociClient := newOCIClient(ctx, logger) // Lockfile pins the concrete "current" version when present (matches TS - // loadVersionInfo: current = lockfileVersion || wanted). - lockfile, _, _ := features.ReadLockfile(cfg.ConfigFilePath) + // loadVersionInfo: current = lockfileVersion || wanted). One that cannot be + // read fails the command as TS outdated's catch does: the error's stack + // logged at debug level, nothing on stdout, exit code 1. + lockfile, _, err := features.ReadLockfile(cfg.ConfigFilePath) + if err != nil { + logger.Write(errorStack(err), log.LevelDebug) + return &coreerrors.ExitCodeError{Code: 1, Err: err} + } result := make(map[string]outdatedEntry) @@ -543,3 +551,13 @@ func resolveFeatureSets(cfg *config.DevContainer, ociClient oci.Registry, logger } return sets } + +// errorStack is how the reference logs an error it catches (err.stack, frames +// left out): a lockfile error as the JavaScript error TS throws, any other +// with its message. +func errorStack(err error) string { + if lerr, ok := errors.AsType[*features.LockfileError](err); ok { + return lerr.Stack() + } + return err.Error() +} diff --git a/internal/cli/outdated_held_test.go b/internal/cli/outdated_held_test.go index c633b99..6c12302 100644 --- a/internal/cli/outdated_held_test.go +++ b/internal/cli/outdated_held_test.go @@ -134,3 +134,26 @@ func TestOrderedFeatureKeysOnlyListsFeatures(t *testing.T) { t.Errorf("keys = %q, want %q", got, want) } } + +// A lockfile that is not JSON fails outdated as it fails the reference's: +// exit code 1 and nothing on stdout, the SyntaxError's stack logged at debug +// level only (outdated's catch writes it with output.write). +func TestOutdatedFailsOnAMalformedLockfile(t *testing.T) { + ws := writeOutdatedWorkspace(t, `{"ghcr.io/devcontainers/features/node:1":{}}`, "not json") + const stack = `SyntaxError: Unexpected token 'o', "not json" is not valid JSON` + for level, logged := range map[string]bool{"info": false, "debug": true} { + logFile := filepath.Join(t.TempDir(), "log") + got, err := runOutdatedFor(t, outdatedOpts{workspaceFolder: ws, outputFormat: "json", logLevel: level, logFile: logFile}) + wantExitCode(t, err, 1) + if got != "" { + t.Errorf("%s: stdout = %q, want nothing", level, got) + } + data, rerr := os.ReadFile(logFile) + if rerr != nil { + t.Fatal(rerr) + } + if strings.Contains(string(data), stack) != logged { + t.Errorf("%s: log =\n%s\nwant the stack logged: %v", level, data, logged) + } + } +} From 287b9a2435735193eef3e3072431bfa90f72bfee Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:36:24 -0300 Subject: [PATCH 21/37] fix(cli): fail resolve-dependencies on a lockfile it cannot read features resolve-dependencies dropped every ReadLockfile error and printed the graph as if no lockfile were committed. In the reference, readLockfile rethrows all but ENOENT and runAsyncHandler reports the uncaught error with console.error and exit code 1: the SyntaxError's stack, or for a file system error Node's "[Error: ...] { errno, code, syscall, path }". Print the same on stderr (frames left out) and exit 1. A unit test and the contract parity case features.resolve-dependencies-malformed-lockfile-error hold it. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 13 +++++++ internal/cli/features_resolve_deps.go | 19 ++++++++- .../cli/features_resolve_deps_held_test.go | 39 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 944bfd7..ebdb258 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -185,6 +185,19 @@ initial_cases: current_status: match notes: "config with no features → TS errors to stderr exit 1; Go previously printed {installOrder:[]} exit 0." + - id: features.resolve-dependencies-malformed-lockfile-error + lane: contract + command: features + priority: p1 + docker_required: false + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/feat; printf '%s' '{\"id\":\"feat\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/feat/devcontainer-feature.json; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./feat\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; printf '%s' '{\"features\":{} x' > $WORKSPACE/.devcontainer/devcontainer-lock.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "features resolve-dependencies --workspace-folder ${WORKSPACE}" + asserts: [exit_code, stdout_normalized, stderr_normalized] + class: lockfile-error + current_status: match + notes: "readLockfile rethrows JSON.parse's SyntaxError and runAsyncHandler console.errors it: 'SyntaxError: Expected ',' or '}' after property value in JSON at position 15 (line 1 column 16)' on stderr, exit 1, nothing on stdout. Go used to ignore the lockfile and print the graph." + # --- Wave A: new cases (some uncovered divergences, now fixed) --- - id: features-publish.missing-required lane: contract diff --git a/internal/cli/features_resolve_deps.go b/internal/cli/features_resolve_deps.go index f1f7dbd..75f2db8 100644 --- a/internal/cli/features_resolve_deps.go +++ b/internal/cli/features_resolve_deps.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -51,9 +52,15 @@ func realFeaturesResolveDepsCmd() *cobra.Command { ociClient := newOCIClient(cmd.Context(), logger) // Read the lockfile (if any) so tarball/OCI resolution can pin digests. + // One that cannot be read fails the command as an uncaught error does + // in TS (runAsyncHandler): console.error(err) on stderr, exit code 1. var lockfile *features.Lockfile if cfg.ConfigFilePath != "" { - lockfile, _, _ = features.ReadLockfile(cfg.ConfigFilePath) + lockfile, _, err = features.ReadLockfile(cfg.ConfigFilePath) + if err != nil { + fmt.Fprintln(out.Stderr(), errorInspect(err)) + return &coreerrors.ExitCodeError{Code: 1, Err: err} + } } basePath := filepath.Dir(cfg.ConfigFilePath) @@ -107,3 +114,13 @@ func realFeaturesResolveDepsCmd() *cobra.Command { return cmd } + +// errorInspect is what console.error prints for an error the reference +// throws, frames left out: a lockfile error as Node.js shows the JavaScript +// error, any other with its message. +func errorInspect(err error) string { + if lerr, ok := errors.AsType[*features.LockfileError](err); ok { + return lerr.Inspect() + } + return err.Error() +} diff --git a/internal/cli/features_resolve_deps_held_test.go b/internal/cli/features_resolve_deps_held_test.go index 463b67f..56fc496 100644 --- a/internal/cli/features_resolve_deps_held_test.go +++ b/internal/cli/features_resolve_deps_held_test.go @@ -135,3 +135,42 @@ func TestFeaturesResolveDependenciesFailures(t *testing.T) { } }) } + +// A lockfile the reference's readLockfile throws on fails the command as an +// uncaught error does in TS: what console.error prints for it on stderr +// (frames left out), nothing on stdout, exit code 1. +func TestFeaturesResolveDependenciesFailsOnAnUnreadableLockfile(t *testing.T) { + for name, tc := range map[string]struct { + write func(path string) error + stderr string + }{ + "not JSON": { + write: func(path string) error { return os.WriteFile(path, []byte("not json"), 0o644) }, + stderr: "SyntaxError: Unexpected token 'o', \"not json\" is not valid JSON\n", + }, + "a directory": { + write: func(path string) error { return os.Mkdir(path, 0o755) }, + stderr: "[Error: EISDIR: illegal operation on a directory, read] {\n errno: -21,\n code: 'EISDIR',\n syscall: 'read'\n}\n", + }, + } { + t.Run(name, func(t *testing.T) { + ws, configPath := resolveDepsWorkspace(t, `{"./a":{}}`, map[string]string{"a": `{"id":"a"}`}) + if err := tc.write(features.LockfilePath(configPath)); err != nil { + t.Fatal(err) + } + cmd := realFeaturesResolveDepsCmd() + cmd.SilenceUsage, cmd.SilenceErrors = true, true + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--workspace-folder", ws}) + err := cmd.ExecuteContext(t.Context()) + if exit, ok := errors.AsType[*coreerrors.ExitCodeError](err); !ok || exit.Code != 1 { + t.Fatalf("err = %v, want exit code 1", err) + } + if stdout.Len() != 0 || stderr.String() != tc.stderr { + t.Errorf("stdout = %q, stderr = %q; want no stdout and stderr %q", stdout.String(), stderr.String(), tc.stderr) + } + }) + } +} From e15c00d75bce7386fed3f8ebe296bb56969369ce Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:38:33 -0300 Subject: [PATCH 22/37] fix(cli): fail up and build on a lockfile they cannot read extendImageWithFeatures dropped every ReadLockfile error, so up and build went on unpinned when devcontainer-lock.json was malformed or unreadable. The reference's generateFeaturesConfig calls readLockfile, which rethrows all but ENOENT, and up and build wrap the error in a ContainerError: message the error's own, description "An error occurred setting up/building the container.", exit code 1. --no-lockfile still skips the file. Return the error, and let commandErrorOutput report an error carrying the reference's own message (errors.ReferenceError, which LockfileError implements) the way TS reports any error it wraps. Unit tests and the runtime parity cases build.malformed-lockfile-error and up.malformed-lockfile-error hold it. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 26 +++++++++++++++++++++++ internal/cli/build.go | 7 +++++- internal/cli/feature_install.go | 7 +++++- internal/cli/feature_install_held_test.go | 16 ++++++++++++++ internal/cli/pure_helpers_test.go | 7 ++++++ internal/errors/errors.go | 9 ++++++++ 6 files changed, 70 insertions(+), 2 deletions(-) diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index ebdb258..45d39af 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -493,6 +493,32 @@ initial_cases: current_status: match notes: "TS reads the config before looking for a container (configContainer.ts): with no workspace and no config it fails with 'No dev container config and no workspace found.' even though --id-label finds a container. Go used to reuse the container and report success with an empty remoteWorkspaceFolder." + - id: build.malformed-lockfile-error + lane: runtime + command: build + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/feat; printf '%s' '{\"id\":\"feat\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/feat/devcontainer-feature.json; printf '#!/bin/sh\\necho installed\\n' > $WORKSPACE/.devcontainer/feat/install.sh; chmod +x $WORKSPACE/.devcontainer/feat/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./feat\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; printf '%s' 'not json' > $WORKSPACE/.devcontainer/devcontainer-lock.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "build --workspace-folder ${WORKSPACE}" + asserts: [exit_code, stdout_normalized] + class: lockfile-error + current_status: match + notes: "readLockfile rethrows JSON.parse's SyntaxError while the Features are resolved; build wraps it in a ContainerError: message the SyntaxError's, description 'An error occurred building the container.'. Go used to ignore the lockfile and build." + + - id: up.malformed-lockfile-error + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/feat; printf '%s' '{\"id\":\"feat\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/feat/devcontainer-feature.json; printf '#!/bin/sh\\necho installed\\n' > $WORKSPACE/.devcontainer/feat/install.sh; chmod +x $WORKSPACE/.devcontainer/feat/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./feat\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; printf '%s' 'not json' > $WORKSPACE/.devcontainer/devcontainer-lock.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "up --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: lockfile-error + current_status: match + notes: "As build.malformed-lockfile-error, with up's description 'An error occurred setting up the container.'; no container is created." + - id: up.config-without-workspace-success lane: runtime command: up diff --git a/internal/cli/build.go b/internal/cli/build.go index 671ad45..d97a1c5 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -761,7 +761,9 @@ func writeErrorJSON(out Output, errOutput coreerrors.ErrorOutput) error { // commandErrorOutput is the error result of up and build: a ContainerError (TS // throws one with its own description, which the commands report as it is) -// as it is, any other error described as ": ". +// as it is; an error carrying the reference's own message as TS reports any +// error it wraps, that message under "."; any other error +// described as ": ". func commandErrorOutput(err error, description string) coreerrors.ErrorOutput { if ce, ok := coreerrors.AsContainerError(err); ok { return coreerrors.ToErrorOutput(ce) @@ -773,6 +775,9 @@ func commandErrorOutput(err error, description string) coreerrors.ErrorOutput { return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: description + ".", OriginalError: plain}) } } + if re, ok := errors.AsType[coreerrors.ReferenceError](err); ok { + return coreerrors.ErrorOutput{Outcome: "error", Message: re.Error(), Description: description + "."} + } return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: fmt.Sprintf("%s: %v", description, err)}) } diff --git a/internal/cli/feature_install.go b/internal/cli/feature_install.go index 93c3ef8..b0b1a6e 100644 --- a/internal/cli/feature_install.go +++ b/internal/cli/feature_install.go @@ -650,9 +650,14 @@ func extendImageWithFeatures( fbOpts *FeatureBuildOptions, ) ([]string, error) { // Read the lockfile (if any) to pin feature resolution to recorded digests. + // One that cannot be read fails the build, as TS generateFeaturesConfig's + // readLockfile rethrows. var lockfile *features.Lockfile if fbOpts.ConfigPath != "" && !fbOpts.NoLockfile { - lockfile, _, _ = features.ReadLockfile(fbOpts.ConfigPath) + var err error + if lockfile, _, err = features.ReadLockfile(fbOpts.ConfigPath); err != nil { + return nil, err + } } result, err := fetchFeatureSetsWithOrder(ctx, logger, featuresCfg, fbOpts.FeaturesBasePath, fbOpts.SkipFeatureAutoMapping, lockfile, fbOpts.OverrideFeatureInstallOrder) if err != nil { diff --git a/internal/cli/feature_install_held_test.go b/internal/cli/feature_install_held_test.go index 7f427d7..0befab8 100644 --- a/internal/cli/feature_install_held_test.go +++ b/internal/cli/feature_install_held_test.go @@ -763,6 +763,22 @@ func TestExtendImageWithFeaturesLockfile(t *testing.T) { t.Fatalf("err = %v, build = %v; want a lockfile error before any build", res.err, res.build) } }) + t.Run("malformed", func(t *testing.T) { + // TS readLockfile rethrows the JSON.parse error: the build fails with + // its message before anything is built; --no-lockfile does not read it. + configPath := filepath.Join(t.TempDir(), "devcontainer.json") + if err := os.WriteFile(features.LockfilePath(configPath), []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + res := extendRun{fbOpts: FeatureBuildOptions{ConfigPath: configPath}}.run(t) + lerr, ok := errors.AsType[*features.LockfileError](res.err) + if !ok || lerr.Error() != `Unexpected token 'o', "not json" is not valid JSON` || res.build != nil { + t.Fatalf("err = %v, build = %v; want the lockfile's SyntaxError before any build", res.err, res.build) + } + if unlocked := (extendRun{fbOpts: FeatureBuildOptions{ConfigPath: configPath, NoLockfile: true}}).run(t); unlocked.err != nil { + t.Fatalf("--no-lockfile: err = %v", unlocked.err) + } + }) t.Run("pins on read", func(t *testing.T) { uri := serveBytes(t, featureTgzBytes(t, map[string]string{"devcontainer-feature.json": `{"id":"tb"}`}), http.StatusOK) + "/devcontainer-feature-tb.tgz" configPath := filepath.Join(t.TempDir(), "devcontainer.json") diff --git a/internal/cli/pure_helpers_test.go b/internal/cli/pure_helpers_test.go index fbf4dc2..a857bfb 100644 --- a/internal/cli/pure_helpers_test.go +++ b/internal/cli/pure_helpers_test.go @@ -291,6 +291,13 @@ func TestCommandErrorOutputKeepsAContainerError(t *testing.T) { if want := "An error occurred building the container: some internal failure"; out.Description != want || out.Message != want { t.Errorf("other error = %+v", out) } + // An error carrying the reference's own message (a lockfile it cannot + // read) is reported as TS reports any error it wraps in a ContainerError. + lockErr := &features.LockfileError{Name: "SyntaxError", Message: "Unexpected end of JSON input"} + out = commandErrorOutput(fmt.Errorf("fetch features: %w", lockErr), "An error occurred setting up the container") + if out.Message != "Unexpected end of JSON input" || out.Description != "An error occurred setting up the container." { + t.Errorf("reference error = %+v", out) + } } type errorString string diff --git a/internal/errors/errors.go b/internal/errors/errors.go index ac4fa3d..a2fb62c 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -30,6 +30,15 @@ func (e *ContainerError) Unwrap() error { return e.OriginalError } +// ReferenceError is an error whose message is the reference CLI's own, the +// message of a plain JavaScript error it throws (a lockfile JSON.parse or fs +// error, say). up and build report it as TS reports such an error: its +// message under the command's generic description. +type ReferenceError interface { + error + ReferenceError() +} + // ExitCodeError wraps an error with a specific exit code for the process. type ExitCodeError struct { Code int From 5021a266a7cb36bbeb090016d6b41d049def95a4 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:39:51 -0300 Subject: [PATCH 23/37] fix(cli): fail Compose up and build on a lockfile they cannot read composeFeatureSets dropped every ReadLockfile error, so a Compose configuration's Features were resolved unpinned when devcontainer-lock.json was malformed or unreadable. The reference rethrows readLockfile's error there too, and up and build report it as their error result: message the error's own, description "An error occurred setting up/building the container.", exit code 1. --no-lockfile still skips the file. Return the error. A unit test and the runtime parity cases build.compose-malformed-lockfile-error and up.compose-malformed-lockfile-error hold it. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 26 ++++++++++++++++++++++++++ internal/cli/compose_up.go | 7 ++++++- internal/cli/compose_up_held_test.go | 15 +++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 45d39af..36f385e 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -519,6 +519,32 @@ initial_cases: current_status: match notes: "As build.malformed-lockfile-error, with up's description 'An error occurred setting up the container.'; no container is created." + - id: build.compose-malformed-lockfile-error + lane: runtime + command: build + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/feat; printf '%s' '{\"id\":\"feat\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/feat/devcontainer-feature.json; printf '#!/bin/sh\\necho installed\\n' > $WORKSPACE/.devcontainer/feat/install.sh; chmod +x $WORKSPACE/.devcontainer/feat/install.sh; printf 'services:\\n app:\\n image: alpine:3\\n command: sleep infinity\\n' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"workspaceFolder\":\"/workspace\",\"features\":{\"./feat\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; printf '%s' 'not json' > $WORKSPACE/.devcontainer/devcontainer-lock.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "build --workspace-folder ${WORKSPACE}" + asserts: [exit_code, stdout_normalized] + class: lockfile-error + current_status: match + notes: "A Compose configuration reads the lockfile while its Features are resolved for the build override; readLockfile's SyntaxError is build's message under 'An error occurred building the container.'. Go used to ignore the lockfile and build." + + - id: up.compose-malformed-lockfile-error + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/feat; printf '%s' '{\"id\":\"feat\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/feat/devcontainer-feature.json; printf '#!/bin/sh\\necho installed\\n' > $WORKSPACE/.devcontainer/feat/install.sh; chmod +x $WORKSPACE/.devcontainer/feat/install.sh; printf 'services:\\n app:\\n image: alpine:3\\n command: sleep infinity\\n' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"workspaceFolder\":\"/workspace\",\"features\":{\"./feat\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; printf '%s' 'not json' > $WORKSPACE/.devcontainer/devcontainer-lock.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "up --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: lockfile-error + current_status: match + notes: "As build.compose-malformed-lockfile-error, with up's description 'An error occurred setting up the container.'; no service is started." + - id: up.config-without-workspace-success lane: runtime command: up diff --git a/internal/cli/compose_up.go b/internal/cli/compose_up.go index f6c41f7..ab50e9a 100644 --- a/internal/cli/compose_up.go +++ b/internal/cli/compose_up.go @@ -299,9 +299,14 @@ type composeExtendResult struct { // override references it (TS keeps its features folder too). func (r *upRunner) composeFeatureSets(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult) ([]*features.Set, string, error) { opts := r.opts + // A lockfile that cannot be read fails the compose flow as it fails TS's + // (readLockfile rethrows), reported by up and build as their error result. var lockfile *features.Lockfile if cfg.ConfigFilePath != "" && !opts.noLockfile { - lockfile, _, _ = features.ReadLockfile(cfg.ConfigFilePath) + var err error + if lockfile, _, err = features.ReadLockfile(cfg.ConfigFilePath); err != nil { + return nil, "", err + } } res, err := fetchFeatureSetsWithOrder(ctx, r.log, cfg.Features, filepath.Dir(cfg.ConfigFilePath), opts.skipFeatureAutoMapping, lockfile, cfg.OverrideFeatureInstallOrder) if err != nil { diff --git a/internal/cli/compose_up_held_test.go b/internal/cli/compose_up_held_test.go index 6c7a40b..802fcc5 100644 --- a/internal/cli/compose_up_held_test.go +++ b/internal/cli/compose_up_held_test.go @@ -20,6 +20,7 @@ import ( "github.com/devcontainers/cli/internal/config" "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/features" "github.com/devcontainers/cli/internal/imagemeta" "github.com/devcontainers/cli/internal/log" dockerspec "github.com/moby/docker-image-spec/specs-go/v1" @@ -663,6 +664,20 @@ func TestComposeFeatureSets(t *testing.T) { t.Errorf("lockfile rewritten without a lockfile flag: %s", got) } }) + t.Run("malformed lockfile", func(t *testing.T) { + // TS readLockfile rethrows the JSON.parse error; --no-lockfile skips it. + for _, noLockfile := range []bool{false, true} { + h := newComposeHarness(t) + h.r.opts.noLockfile = noLockfile + h.write("devcontainer-lock.json", "not json") + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: h.localFeature()} + _, _, err := h.r.composeFeatureSets(ctx, cfg, lr) + lerr, failed := errors.AsType[*features.LockfileError](err) + if failed == noLockfile || failed && lerr.Error() != `Unexpected token 'o', "not json" is not valid JSON` { + t.Errorf("noLockfile=%t: err = %v", noLockfile, err) + } + } + }) t.Run("lockfile pins a tarball", func(t *testing.T) { tgz := featureTarball(t, "tarred") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write(tgz) })) From 0cb2210f3781def6061d467b1a10e0c5d75fb62a Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:41:04 -0300 Subject: [PATCH 24/37] fix(cli): fail --cache-key on a Dockerfile it cannot read read-configuration --cache-key folded the Dockerfile of a Dockerfile-based configuration into the key only when it could read it, so a missing or unreadable Dockerfile keyed as if the configuration had none and the key stopped tracking the image's recipe without a word. Fail the key with "read Dockerfile: ", the file build reads and would fail on, reported as read-configuration's other runtime failures are (error result on stdout, exit code 1). Without --cache-key the command still does not read the Dockerfile, as the reference does not. Documented in docs/go-only-features.md. Co-Authored-By: Claude Opus 5.5 --- docs/go-only-features.md | 4 ++- internal/cli/cache_key.go | 9 +++++-- internal/cli/cache_key_repo_held_test.go | 16 ++++++++++++ internal/cli/read_configuration_held_test.go | 26 ++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/go-only-features.md b/docs/go-only-features.md index c5e1d2f..c76dfda 100644 --- a/docs/go-only-features.md +++ b/docs/go-only-features.md @@ -168,7 +168,9 @@ on any host. A lockfile that exists but cannot be read or parsed fails the command (exit code 1, error result on stdout) instead of being left out of the key, as the upstream CLI fails every -command that reads it. An empty lockfile pins nothing and keys like no lockfile. +command that reads it. An empty lockfile pins nothing and keys like no lockfile. Likewise, a +Dockerfile-based configuration whose Dockerfile cannot be read fails the command with +`compute cache key: read Dockerfile: …`, the file `build` would fail on. The flag is **additive**: without it, the output is byte-for-byte identical to the upstream CLI; with it, a `cacheKey` field is added. diff --git a/internal/cli/cache_key.go b/internal/cli/cache_key.go index 1097d89..ff38ec7 100644 --- a/internal/cli/cache_key.go +++ b/internal/cli/cache_key.go @@ -50,9 +50,14 @@ func computeCacheKey(result *config.LoadResult, env map[string]string) (string, configDir := filepath.Dir(cfg.ConfigFilePath) if df := cfg.Dockerfile(); df != "" { - if content, rerr := os.ReadFile(filepath.Join(configDir, df)); rerr == nil { - in.Dockerfile = string(content) + // The Dockerfile build reads (prepareDockerfileBuild). One that cannot + // be read fails the key, which would otherwise not track the image's + // recipe. + content, err := os.ReadFile(filepath.Join(configDir, df)) + if err != nil { + return "", fmt.Errorf("read Dockerfile: %w", err) } + in.Dockerfile = string(content) } // ReadLockfile's bool reports an empty marker file, which pins nothing; a diff --git a/internal/cli/cache_key_repo_held_test.go b/internal/cli/cache_key_repo_held_test.go index 3938004..983946c 100644 --- a/internal/cli/cache_key_repo_held_test.go +++ b/internal/cli/cache_key_repo_held_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/devcontainers/cli/internal/config" ) // A committed features lockfile pins resolved digests, so changing it changes @@ -94,3 +96,17 @@ func TestComputeCacheKeyFailsOnAnUnreadableLockfile(t *testing.T) { t.Errorf("err = %v, want the read failure naming %s", err, lockPath) } } + +// A Dockerfile-based configuration whose Dockerfile cannot be read fails the +// key instead of keying as if it had no Dockerfile, naming the file. +func TestComputeCacheKeyFailsOnAMissingDockerfile(t *testing.T) { + dir := t.TempDir() + r := &config.LoadResult{Config: &config.DevContainer{ + Build: &config.Build{Dockerfile: "Dockerfile"}, + ConfigFilePath: filepath.Join(dir, "devcontainer.json"), + }} + key, err := computeCacheKey(r, nil) + if want := "read Dockerfile: open " + filepath.Join(dir, "Dockerfile") + ": no such file or directory"; err == nil || err.Error() != want { + t.Errorf("key, err = %q, %v; want %q", key, err, want) + } +} diff --git a/internal/cli/read_configuration_held_test.go b/internal/cli/read_configuration_held_test.go index dee59e5..d9efcbd 100644 --- a/internal/cli/read_configuration_held_test.go +++ b/internal/cli/read_configuration_held_test.go @@ -481,6 +481,32 @@ func TestReadConfigurationCacheKeyFailsOnAMalformedLockfile(t *testing.T) { } } +// --cache-key over a Dockerfile configuration whose Dockerfile is missing +// fails with the error result naming the file; without the flag the command +// does not read the Dockerfile, as the reference's does not, and succeeds. +func TestReadConfigurationCacheKeyFailsOnAMissingDockerfile(t *testing.T) { + ws := writeConfigFixture(t, map[string]string{ + ".devcontainer/devcontainer.json": `{"build": {"dockerfile": "Dockerfile"}}`, + }) + if _, err := runReadConfigurationCmd(t, "--workspace-folder", ws); err != nil { + t.Fatalf("without --cache-key: %v", err) + } + var stdout bytes.Buffer + root := NewRootCommand() + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs([]string{"read-configuration", "--workspace-folder", ws, "--cache-key"}) + wantExitCode(t, root.Execute(), 1) + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("stdout is not the error envelope: %v\n%s", err, stdout.String()) + } + want := "compute cache key: read Dockerfile: open " + filepath.Join(ws, ".devcontainer", "Dockerfile") + ": no such file or directory" + if envelope["outcome"] != "error" || envelope["message"] != want { + t.Errorf("stdout = %s, want the error outcome %q", stdout.String(), want) + } +} + func TestReadConfigurationCommandFeaturesAndCacheKey(t *testing.T) { files := map[string]string{".devcontainer/devcontainer.json": `{"image": "img:1", "features": {"./feat": {}}}`} for k, v := range localFeatureFiles { From e39725183b92d6c8480f6918ff3c0406ea22da39 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 22:55:09 -0300 Subject: [PATCH 25/37] fix(cli): write command results without escaping html characters The reference writes every command result with JSON.stringify, which writes &, <, > and U+2028/U+2029 in strings as they are. encoding/json escaped them, so a result holding `a && b ` was not byte-compatible with the reference's. Results and error results now undo those escapes after encoding, which also covers strings nested json.Marshaler implementations write. The parity matrix gains a stdout_text assert that compares stdout as text, since stdout_normalized parses the JSON and cannot see escaping. Co-Authored-By: Claude Opus 5.5 --- docs/DIVERGENCES.md | 7 ++++ docs/parity/parity-matrix.yaml | 13 +++++++ internal/cli/build.go | 5 +-- internal/cli/collection_commands.go | 2 +- internal/cli/features_info.go | 4 +- internal/cli/features_resolve_deps.go | 3 +- internal/cli/outdated.go | 4 +- internal/cli/parity_matrix_test.go | 11 ++++++ internal/cli/read_configuration.go | 2 +- internal/cli/stringify.go | 54 ++++++++++++++++++++++++++ internal/cli/stringify_test.go | 56 +++++++++++++++++++++++++++ internal/cli/templates_apply.go | 2 +- 12 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 internal/cli/stringify.go create mode 100644 internal/cli/stringify_test.go diff --git a/docs/DIVERGENCES.md b/docs/DIVERGENCES.md index 3c52fa2..6df3000 100644 --- a/docs/DIVERGENCES.md +++ b/docs/DIVERGENCES.md @@ -168,6 +168,13 @@ oversight. config, metadata and merge; `TestHookErrorDescribesTheCommandAndItsOrigin` pins the current choice. +- **A lone surrogate in a string is written as U+FFFD.** Command results are written as + `JSON.stringify` writes them: `&`, `<`, `>`, U+2028 and U+2029 as they are, not + escaped as `encoding/json` would (`read-configuration.json-stringify-escapes-success`). + A configuration string holding a lone UTF-16 surrogate escape (`"\ud800"`) is the + exception: a Go string cannot carry one, so the JSON decoder replaces it with U+FFFD + when the file is read, and the result holds that character where TS writes the + escape back. - **Byte-for-byte tarball parity is unattainable** because of `mtime` differences; tarball contents are compared by parsed structure, not raw bytes. - **Cloud registry auth matrix is out of default CI scope.** The hermetic auth paths diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 36f385e..3a40764 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -1164,6 +1164,19 @@ initial_cases: current_status: match notes: "up's result follows doProvision's literal (outcome, then launch's containerId, composeProjectName, remoteUser, remoteWorkspaceFolder, configuration, mergedConfiguration, ociAuthDiagnostics), its configuration the document's order and its mergedConfiguration mergeConfiguration's. Go wrote sorted keys. Only the order is compared: TS writes configFilePath's fsPath here (vscode-uri toJSON writes it once something read it) and Go does not, a content gap outside this case." + - id: read-configuration.json-stringify-escapes-success + lane: semantic + command: read-configuration + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\",\"postCreateCommand\":\"a && b \\u2028\\u2029 \\\\u0026\",\"containerEnv\":{\"\":\"v\"}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --include-merged-configuration" + cleanup_cmd: "rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized, stdout_text] + class: output-shape + current_status: match + notes: "JSON.stringify writes &, <, > and U+2028/U+2029 in strings as they are; encoding/json escapes them (\\u0026, \\u003c, \\u003e, \\u2028, \\u2029), which stdout_normalized's JSON parse cannot see. stdout_text compares the raw bytes. An escaped backslash followed by u0026 stays as written. A lone surrogate escape is not covered: Go's JSON decoder turns it into U+FFFD (docs/DIVERGENCES.md)." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/build.go b/internal/cli/build.go index d97a1c5..e927162 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -2,7 +2,6 @@ package cli import ( "context" - "encoding/json" "errors" "fmt" "os" @@ -727,7 +726,7 @@ func buildOptionsFromConfig(cfg *config.DevContainer) []string { // writeSuccessJSON writes a command's result with its keys in order (see // orderedObject). func writeSuccessJSON(out Output, data map[string]any, order ...string) error { - b, err := json.Marshal(orderedObject{data, order}) + b, err := stringifyJSON(orderedObject{data, order}, "") if err != nil { return fmt.Errorf("marshal result: %w", err) } @@ -754,7 +753,7 @@ func writeValidationError(out Output, message string) error { } func writeErrorJSON(out Output, errOutput coreerrors.ErrorOutput) error { - b, _ := json.Marshal(errOutput) + b, _ := stringifyJSON(errOutput, "") fmt.Fprintln(out.Stdout(), string(b)) return &coreerrors.ExitCodeError{Code: 1, Err: errors.New(errOutput.Description)} } diff --git a/internal/cli/collection_commands.go b/internal/cli/collection_commands.go index 0eda8aa..ba31efd 100644 --- a/internal/cli/collection_commands.go +++ b/internal/cli/collection_commands.go @@ -450,7 +450,7 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta } logger.Write("Published collection metadata.", log.LevelInfo) - data, _ := json.Marshal(result) + data, _ := stringifyJSON(result, "") fmt.Fprintln(out.Stdout(), string(data)) return nil } diff --git a/internal/cli/features_info.go b/internal/cli/features_info.go index 130ae0c..2f9f2e2 100644 --- a/internal/cli/features_info.go +++ b/internal/cli/features_info.go @@ -85,7 +85,7 @@ func runFeaturesInfo(ctx context.Context, out Output, mode, featureID, logLevel, if json.Indent(&buf, manifest.ManifestBytes, "", " ") == nil { fmt.Fprintln(out.Stdout(), buf.String()) } else { - data, _ := json.MarshalIndent(manifest.Manifest, "", " ") + data, _ := stringifyJSON(manifest.Manifest, " ") fmt.Fprintln(out.Stdout(), string(data)) } fmt.Fprintln(out.Stdout()) @@ -126,7 +126,7 @@ func runFeaturesInfo(ctx context.Context, out Output, mode, featureID, logLevel, } if outputFormat == "json" { - data, _ := json.MarshalIndent(jsonOutput, "", " ") + data, _ := stringifyJSON(jsonOutput, " ") fmt.Fprintln(out.Stdout(), string(data)) } diff --git a/internal/cli/features_resolve_deps.go b/internal/cli/features_resolve_deps.go index 75f2db8..9e55eb4 100644 --- a/internal/cli/features_resolve_deps.go +++ b/internal/cli/features_resolve_deps.go @@ -1,7 +1,6 @@ package cli import ( - "encoding/json" "errors" "fmt" "os" @@ -103,7 +102,7 @@ func realFeaturesResolveDepsCmd() *cobra.Command { // install-order JSON. fmt.Fprintln(out.Stdout(), features.GenerateMermaidDiagram(graph)) - data, _ := json.MarshalIndent(map[string]any{"installOrder": entries}, "", " ") + data, _ := stringifyJSON(map[string]any{"installOrder": entries}, " ") fmt.Fprintln(out.Stdout(), string(data)) return nil }, diff --git a/internal/cli/outdated.go b/internal/cli/outdated.go index 4f631ee..9d7a2e3 100644 --- a/internal/cli/outdated.go +++ b/internal/cli/outdated.go @@ -235,7 +235,7 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { if opts.outputFormat == "json" { payload := map[string]any{"features": result} - data, _ := json.MarshalIndent(payload, "", " ") + data, _ := stringifyJSON(payload, " ") fmt.Fprintln(out.Stdout(), string(data)) } else { // Text table. Rows follow the order features were declared in the config @@ -462,7 +462,7 @@ func newUpgradeCmd() *cobra.Command { lf := features.GenerateLockfile(&features.Config{FeatureSets: featureSets}, nil) if dryRun { - data, _ := json.MarshalIndent(lf, "", " ") + data, _ := stringifyJSON(lf, " ") fmt.Fprintln(outputFor(cmd).Stdout(), string(data)) return nil } diff --git a/internal/cli/parity_matrix_test.go b/internal/cli/parity_matrix_test.go index 847d6e4..07b65c3 100644 --- a/internal/cli/parity_matrix_test.go +++ b/internal/cli/parity_matrix_test.go @@ -445,6 +445,17 @@ func TestParityMatrix(t *testing.T) { } } + // stdout_text compares stdout as text, only scrubbing paths and hashes, + // so what stdout_normalized's JSON parse hides (how a string is + // escaped) is compared byte for byte. + if asserts["stdout_text"] { + tsText := normalizeStringOpts(tsRes.Stdout, scrubHashes) + goText := normalizeStringOpts(goRes.Stdout, scrubHashes) + if tsText != goText { + t.Errorf("stdout text differs:\n--- TS\n%s\n--- Go\n%s", tsText, goText) + } + } + if asserts["stderr_normalized"] { tsNorm := normalizeTextOpts(tsRes.Stderr, scrubHashes) goNorm := normalizeTextOpts(goRes.Stderr, scrubHashes) diff --git a/internal/cli/read_configuration.go b/internal/cli/read_configuration.go index aaeead2..ae860c6 100644 --- a/internal/cli/read_configuration.go +++ b/internal/cli/read_configuration.go @@ -228,7 +228,7 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) output["ociAuthDiagnostics"] = ociAuthDiagnostics(ctx) orderConfigurations(output, result) - data, err := json.Marshal(orderedObject{output, readConfigurationKeys}) + data, err := stringifyJSON(orderedObject{output, readConfigurationKeys}, "") if err != nil { return fmt.Errorf("marshal output: %w", err) } diff --git a/internal/cli/stringify.go b/internal/cli/stringify.go new file mode 100644 index 0000000..f2f5696 --- /dev/null +++ b/internal/cli/stringify.go @@ -0,0 +1,54 @@ +package cli + +import "encoding/json" + +// stringifyEscapes are the escapes encoding/json writes that JSON.stringify +// does not: it writes &, <, > and U+2028/U+2029 as they are. +var stringifyEscapes = map[string]string{ + "\\u0026": "&", + "\\u003c": "<", + "\\u003e": ">", + "\\u2028": "\u2028", + "\\u2029": "\u2029", +} + +// stringifyJSON encodes v as JSON.stringify(v) would, so a command result is +// byte-compatible with the TS CLI's. encoding/json HTML-escapes every string, +// including the ones nested json.Marshaler implementations write, so the +// escapes are undone on the encoded document. A non-empty indent indents it +// as JSON.stringify(v, undefined, indent) does. +func stringifyJSON(v any, indent string) ([]byte, error) { + var data []byte + var err error + if indent == "" { + data, err = json.Marshal(v) + } else { + data, err = json.MarshalIndent(v, "", indent) + } + if err != nil { + return nil, err + } + return unescapeStringify(data), nil +} + +// unescapeStringify rewrites, in encoded JSON, the escapes in +// stringifyEscapes as the characters they stand for. A backslash only occurs +// in a string, always as the start of an escape, so taking escapes in pairs +// leaves an escaped backslash followed by "u0026" alone. +func unescapeStringify(data []byte) []byte { + out := make([]byte, 0, len(data)) + for i := 0; i < len(data); i++ { + if data[i] != '\\' { + out = append(out, data[i]) + continue + } + if s, ok := stringifyEscapes[string(data[i:min(i+6, len(data))])]; ok { + out = append(out, s...) + i += 5 + continue + } + out = append(out, data[i], data[i+1]) + i++ + } + return out +} diff --git a/internal/cli/stringify_test.go b/internal/cli/stringify_test.go new file mode 100644 index 0000000..7086ebe --- /dev/null +++ b/internal/cli/stringify_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "testing" + + coreerrors "github.com/devcontainers/cli/internal/errors" +) + +// JSON.stringify writes &, <, > and U+2028/U+2029 as they are, and every +// other escape as encoding/json does. +func TestStringifyJSONWritesWhatJSONStringifyWrites(t *testing.T) { + cases := []struct { + name string + v any + indent string + want string + }{ + {"html characters", "a && b ", "", "\"a && b \""}, + {"line and paragraph separators", "\u2028\u2029", "", "\"\u2028\u2029\""}, + {"an escaped backslash before u0026", `\u0026`, "", `"\\u0026"`}, + {"an escaped quote at the end", `"`, "", `"\""`}, + {"control characters", "\b\f\n\r\t\x01", "", `"\b\f\n\r\t\u0001"`}, + {"a nested marshaler", orderedObject{map[string]any{"": "&"}, nil}, "", `{"":"&"}`}, + {"indented", map[string]any{"k": ""}, " ", "{\n \"k\": \"\"\n}"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := stringifyJSON(tc.v, tc.indent) + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Errorf("stringifyJSON = %s, want %s", got, tc.want) + } + }) + } +} + +func TestStringifyJSONReportsAnUnencodableValue(t *testing.T) { + if _, err := stringifyJSON(make(chan int), ""); err == nil { + t.Error("stringifyJSON of a channel succeeded") + } + if _, err := stringifyJSON(make(chan int), " "); err == nil { + t.Error("indented stringifyJSON of a channel succeeded") + } +} + +// A command's error result is written as JSON.stringify writes it. +func TestWriteErrorJSONDoesNotEscapeHTML(t *testing.T) { + var out captureOutput + _ = writeErrorJSON(&out, coreerrors.ErrorOutput{Outcome: "error", Message: "a && ", Description: "a && "}) + want := `{"outcome":"error","message":"a && ","description":"a && "}` + "\n" + if got := out.out.String(); got != want { + t.Errorf("writeErrorJSON wrote %q, want %q", got, want) + } +} diff --git a/internal/cli/templates_apply.go b/internal/cli/templates_apply.go index 3a3fbb3..4bced62 100644 --- a/internal/cli/templates_apply.go +++ b/internal/cli/templates_apply.go @@ -51,7 +51,7 @@ func realTemplatesApplyCmd() *cobra.Command { return err } - data, _ := json.Marshal(map[string]any{"files": files}) + data, _ := stringifyJSON(map[string]any{"files": files}, "") fmt.Fprintln(outputFor(cmd).Stdout(), string(data)) return nil }, From e596abe87f30f94d6573fc7c10ab25a246f550c7 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 23:02:49 -0300 Subject: [PATCH 26/37] fix(cli): write configFilePath's fsPath in up where the reference does vscode-uri's toJSON writes fsPath only once something read that getter, so the reference's up writes it in configFilePath when its code path read it: without --id-label (findContainerAndIdLabels), for a --config file read without --override-config (readDocument), and when Features are processed with the lockfile on (getLockfilePath). Go never wrote it in up. read-configuration and set-up always read it and already wrote it; both now build the URI through the same helper. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 43 ++++++++++++++++++++++-- internal/cli/compose_up.go | 6 ++++ internal/cli/read_configuration.go | 22 ++++++++++--- internal/cli/up.go | 25 ++++++++++---- internal/cli/up_held_test.go | 52 ++++++++++++++++++++++++++++-- 5 files changed, 131 insertions(+), 17 deletions(-) diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 3a40764..a0a8ca1 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -1159,10 +1159,10 @@ initial_cases: ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --skip-post-create --include-configuration --include-merged-configuration --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" cleanup_cmd: "IDS=$(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" key_order: ["", "configuration", "mergedConfiguration"] - asserts: [exit_code] + asserts: [exit_code, stdout_normalized] class: output-shape current_status: match - notes: "up's result follows doProvision's literal (outcome, then launch's containerId, composeProjectName, remoteUser, remoteWorkspaceFolder, configuration, mergedConfiguration, ociAuthDiagnostics), its configuration the document's order and its mergedConfiguration mergeConfiguration's. Go wrote sorted keys. Only the order is compared: TS writes configFilePath's fsPath here (vscode-uri toJSON writes it once something read it) and Go does not, a content gap outside this case." + notes: "up's result follows doProvision's literal (outcome, then launch's containerId, composeProjectName, remoteUser, remoteWorkspaceFolder, configuration, mergedConfiguration, ociAuthDiagnostics), its configuration the document's order and its mergedConfiguration mergeConfiguration's. Go wrote sorted keys. The content is compared too: with --id-label, TS writes configFilePath's fsPath here because processing the Features reads the lockfile next to the configuration (getLockfilePath reads configFilePath.fsPath, and vscode-uri's toJSON writes fsPath once something read it)." - id: read-configuration.json-stringify-escapes-success lane: semantic @@ -1177,6 +1177,45 @@ initial_cases: current_status: match notes: "JSON.stringify writes &, <, > and U+2028/U+2029 in strings as they are; encoding/json escapes them (\\u0026, \\u003c, \\u003e, \\u2028, \\u2029), which stdout_normalized's JSON parse cannot see. stdout_text compares the raw bytes. An escaped backslash followed by u0026 stays as written. A lone surrogate escape is not covered: Go's JSON decoder turns it into U+FFFD (docs/DIVERGENCES.md)." + - id: up.config-file-fspath-without-id-label-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\",\"remoteUser\":\"root\"}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --include-configuration --include-merged-configuration" + cleanup_cmd: "IDS=$(docker ps -aq --filter label=devcontainer.local_folder=${WORKSPACE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: output-shape + current_status: match + notes: "Without --id-label, TS resolveWithLocalFolder passes configPath.fsPath to findContainerAndIdLabels (configContainer.ts), and vscode-uri's toJSON writes fsPath once something read it, so both configurations' configFilePath have fsPath. Go left it out of every up result." + + - id: up.config-file-fspath-id-label-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\",\"remoteUser\":\"root\"}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --include-configuration --include-merged-configuration --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + cleanup_cmd: "IDS=$(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: output-shape + current_status: match + notes: "With --id-label, a configuration found in the workspace (vscode-fileHost, read through CLIHostDocuments, which uses uriToFsPath) and no Features, nothing in TS's up reads configFilePath.fsPath, so its URI has no fsPath. Guards against writing it always." + + - id: up.compose-config-file-fspath-features-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s\\n' 'services:' ' app:' ' image: alpine:3' ' command: sleep infinity' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"workspaceFolder\":\"/\",\"remoteUser\":\"root\",\"features\":{\"./f\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --skip-post-create --include-configuration --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: output-shape + current_status: match + notes: "A compose configuration with Features: TS generateFeaturesConfig reads the lockfile next to the configuration (getLockfilePath reads configFilePath.fsPath), so even with --id-label the configuration's configFilePath has fsPath (vscode-uri's toJSON writes it once read). Go left it out." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/compose_up.go b/internal/cli/compose_up.go index ab50e9a..8cb4b24 100644 --- a/internal/cli/compose_up.go +++ b/internal/cli/compose_up.go @@ -297,8 +297,14 @@ type composeExtendResult struct { // returning nil when none are configured. The returned folder (TS dstFolder) // holds each Feature at its consecutiveId; it is kept because the compose build // override references it (TS keeps its features folder too). +// +// mutate-lane: TestParityMatrix - up.compose-config-file-fspath-features-success +// processes a compose configuration's Features through it. func (r *upRunner) composeFeatureSets(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult) ([]*features.Set, string, error) { opts := r.opts + if len(cfg.Features) > 0 { + r.lockfileRead = !opts.noLockfile + } // A lockfile that cannot be read fails the compose flow as it fails TS's // (readLockfile rethrows), reported by up and build as their error result. var lockfile *features.Lockfile diff --git a/internal/cli/read_configuration.go b/internal/cli/read_configuration.go index ae860c6..e24a048 100644 --- a/internal/cli/read_configuration.go +++ b/internal/cli/read_configuration.go @@ -317,14 +317,26 @@ func configurationOutput(result *config.LoadResult, opts *readConfigOpts) map[st if opts.containerID != "" || opts.configPath != "" || opts.overrideConfig != "" { scheme = "file" } - cfp := result.Config.ConfigFilePath - cfgMap["configFilePath"] = map[string]any{ + // TS findContainerAndIdLabels reads the URI's fsPath (devContainersSpecCLI.ts + // readConfiguration), so read-configuration always writes it. + cfgMap["configFilePath"] = configFileURI(result.Config.ConfigFilePath, scheme, true) + return cfgMap +} + +// configFileURI is how JSON.stringify writes the vscode-uri URI TS keeps as a +// configuration's configFilePath: its toJSON writes fsPath only once +// something has read that getter on the instance, so withFSPath says whether +// the command's TS code path did. +func configFileURI(path, scheme string, withFSPath bool) map[string]any { + uri := map[string]any{ "$mid": 1, - "fsPath": cfp, - "path": cfp, + "path": path, "scheme": scheme, } - return cfgMap + if withFSPath { + uri["fsPath"] = path + } + return uri } // readConfigurationContainerID is the container read-configuration reads: the diff --git a/internal/cli/up.go b/internal/cli/up.go index 50b9edd..8662aa6 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -98,6 +98,9 @@ type upRunner struct { // configLabel is the configuration's entry in the devcontainer.metadata // label: its properties as written (TS pick(config.raw, ...)). configLabel imagemeta.Entry + // lockfileRead records that Features were processed with the lockfile on, + // which in TS reads the configuration URI's fsPath (getLockfilePath). + lockfileRead bool } func newUpCmd() *cobra.Command { @@ -460,7 +463,7 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { result["composeProjectName"] = opts.composeProjectName } - if err := addUpConfiguration(result, opts, cfg, inspectResp, run.metadataOfContainer, logger); err != nil { + if err := addUpConfiguration(result, opts, upConfigFileFSPathRead(opts, run.lockfileRead), cfg, inspectResp, run.metadataOfContainer, logger); err != nil { return writeErrorResult(out, err.Error()) } orderConfigurations(result, loadResult) @@ -513,6 +516,16 @@ func upRemoteUser(cfgUser string, ctr container.InspectResponse, imageLabels fun return cmp.Or(ctrUser, "root") } +// upConfigFileFSPathRead reports whether TS's up read the configuration URI's +// fsPath, which decides whether the URI it writes has one: readDocument reads +// a file URI's (editableFiles.ts), so a --config read without an override; +// resolveWithLocalFolder passes it to findContainerAndIdLabels without +// --id-label (configContainer.ts); and getLockfilePath reads it when Features +// are processed with the lockfile on (lockfile.ts). +func upConfigFileFSPathRead(opts *upOpts, lockfileRead bool) bool { + return opts.configPath != "" && opts.overrideConfig == "" || len(opts.idLabels) == 0 || lockfileRead +} + // addUpConfiguration adds the configuration (--include-configuration) and the // mergedConfiguration (--include-merged-configuration) to up's result, with // ${containerEnv:…} resolved from the container's environment as TS does. @@ -520,7 +533,7 @@ func upRemoteUser(cfgUser string, ctr container.InspectResponse, imageLabels fun // mergedConfiguration merges the container's metadata (metadata, TS // getImageMetadataFromContainer), which records the configuration's entry, as // TS's up does for an existing container. -func addUpConfiguration(result map[string]any, opts *upOpts, cfg *config.DevContainer, ctr container.InspectResponse, metadata func(ctr container.InspectResponse, cfg *config.DevContainer) ([]imagemeta.Entry, error), logger log.Logger) error { +func addUpConfiguration(result map[string]any, opts *upOpts, fsPathRead bool, cfg *config.DevContainer, ctr container.InspectResponse, metadata func(ctr container.InspectResponse, cfg *config.DevContainer) ([]imagemeta.Entry, error), logger log.Logger) error { if !opts.includeConfig && !opts.includeMergedConfig { return nil } @@ -548,11 +561,7 @@ func addUpConfiguration(result map[string]any, opts *upOpts, cfg *config.DevCont if opts.configPath != "" || opts.overrideConfig != "" { scheme = "file" } - subMap["configFilePath"] = map[string]any{ - "$mid": 1, - "path": cfp, - "scheme": scheme, - } + subMap["configFilePath"] = configFileURI(cfp, scheme, fsPathRead) } else { delete(subMap, "configFilePath") } @@ -673,6 +682,7 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, // Extend with features if any if len(cfg.Features) > 0 { + r.lockfileRead = !opts.noLockfile names, err := extendImageWithFeatures(ctx, logger, dockerClient, engine, imageName, cfg.Features, useBuildx, nil, r.featureBuildOptions(cfg, loadResult, "")) if err != nil { return "", fmt.Errorf("install features: %w", err) @@ -722,6 +732,7 @@ func (r *upRunner) fromImage(ctx context.Context, cfg *config.DevContainer, load // Extend with features if any imageName := cfg.Image if len(cfg.Features) > 0 { + r.lockfileRead = !opts.noLockfile names, err := extendImageWithFeatures(ctx, logger, dockerClient, engine, cfg.Image, cfg.Features, useBuildx, nil, r.featureBuildOptions(cfg, loadResult, platform)) if err != nil { return "", fmt.Errorf("install features: %w", err) diff --git a/internal/cli/up_held_test.go b/internal/cli/up_held_test.go index 2686137..1695a79 100644 --- a/internal/cli/up_held_test.go +++ b/internal/cli/up_held_test.go @@ -125,7 +125,7 @@ func containerMetadata(labels map[string]string) func(container.InspectResponse, func upConfigurationJSON(t *testing.T, opts *upOpts, cfg *config.DevContainer, ctr container.InspectResponse, containerLabels map[string]string) map[string]any { t.Helper() result := map[string]any{} - if err := addUpConfiguration(result, opts, cfg, ctr, containerMetadata(containerLabels), quietLogger()); err != nil { + if err := addUpConfiguration(result, opts, false, cfg, ctr, containerMetadata(containerLabels), quietLogger()); err != nil { t.Fatal(err) } b, err := json.Marshal(result) @@ -167,6 +167,52 @@ func TestAddUpConfigurationNamesTheConfigFileAsTSDoes(t *testing.T) { } } +// vscode-uri's toJSON writes fsPath once something read it, so up writes it +// in the configFilePath of both configurations only when TS's up read it. +func TestAddUpConfigurationWritesFSPathWhenTSReadIt(t *testing.T) { + const cfp = "/ws/.devcontainer/devcontainer.json" + for _, read := range []bool{false, true} { + result := map[string]any{} + opts := &upOpts{includeConfig: true, includeMergedConfig: true} + if err := addUpConfiguration(result, opts, read, &config.DevContainer{ConfigFilePath: cfp, Image: "alpine:3"}, container.InspectResponse{}, containerMetadata(nil), quietLogger()); err != nil { + t.Fatal(err) + } + want := map[string]any{"$mid": 1, "path": cfp, "scheme": "vscode-fileHost"} + if read { + want["fsPath"] = cfp + } + conf, _ := result["configuration"].(map[string]any) + if !reflect.DeepEqual(conf["configFilePath"], want) { + t.Errorf("read=%v: configuration.configFilePath = %v, want %v", read, conf["configFilePath"], want) + } + mc, _ := result["mergedConfiguration"].(map[string]any) + if !reflect.DeepEqual(mc["configFilePath"], want) { + t.Errorf("read=%v: mergedConfiguration.configFilePath = %v, want %v", read, mc["configFilePath"], want) + } + } +} + +func TestUpConfigFileFSPathReadIsWhereTSUpReadsIt(t *testing.T) { + labels := []string{"a=b"} + for _, tc := range []struct { + name string + opts upOpts + lockfileRead bool + want bool + }{ + {"no --id-label", upOpts{}, false, true}, + {"--id-label", upOpts{idLabels: labels}, false, false}, + {"--id-label and --config", upOpts{idLabels: labels, configPath: "c.json"}, false, true}, + {"--id-label, --config and --override-config", upOpts{idLabels: labels, configPath: "c.json", overrideConfig: "o.json"}, false, false}, + {"--id-label and --override-config", upOpts{idLabels: labels, overrideConfig: "o.json"}, false, false}, + {"--id-label and the lockfile read", upOpts{idLabels: labels}, true, true}, + } { + if got := upConfigFileFSPathRead(&tc.opts, tc.lockfileRead); got != tc.want { + t.Errorf("%s: upConfigFileFSPathRead = %v, want %v", tc.name, got, tc.want) + } + } +} + func TestAddUpConfigurationResolvesTheContainerEnv(t *testing.T) { cfg := &config.DevContainer{ Image: "alpine:3", @@ -218,11 +264,11 @@ func TestAddUpConfigurationAddsOnlyWhatWasAskedFor(t *testing.T) { func TestAddUpConfigurationReportsAContainerEnvReferenceWithoutAName(t *testing.T) { cfg := &config.DevContainer{RemoteEnv: map[string]*string{"X": strPtr("${containerEnv}")}} labels := containerMetadata(nil) - err := addUpConfiguration(map[string]any{}, &upOpts{includeConfig: true}, cfg, container.InspectResponse{}, labels, quietLogger()) + err := addUpConfiguration(map[string]any{}, &upOpts{includeConfig: true}, false, cfg, container.InspectResponse{}, labels, quietLogger()) if err == nil || !strings.HasPrefix(err.Error(), "resolve configuration container variables: ") { t.Errorf("configuration error = %v, want the substitution error", err) } - err = addUpConfiguration(map[string]any{}, &upOpts{includeMergedConfig: true}, cfg, container.InspectResponse{}, labels, quietLogger()) + err = addUpConfiguration(map[string]any{}, &upOpts{includeMergedConfig: true}, false, cfg, container.InspectResponse{}, labels, quietLogger()) if err == nil || !strings.HasPrefix(err.Error(), "resolve merged container variables: ") { t.Errorf("merged error = %v, want the substitution error", err) } From cab91a70ffb5e7e57cf5dd8ae08f627a64fc5c20 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 23:05:39 -0300 Subject: [PATCH 27/37] fix(cli): give configFilePath the file scheme only for --config The reference makes a file URI of --config, and of --override-config only when there is no workspace; a configuration found in the workspace keeps the vscode-fileHost scheme whatever else is given. Go wrote the file scheme whenever --override-config was given (up and read-configuration) or --container-id was (read-configuration). The unit tests that held the old schemes now hold the reference's, which both CLIs were run to confirm. Without a workspace the override is the document up reads, so its URI also has fsPath. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 26 ++++++++++++++++++++ internal/cli/read_configuration.go | 9 ++++--- internal/cli/read_configuration_held_test.go | 20 +++++++++------ internal/cli/up.go | 23 ++++++++++++----- internal/cli/up_held_test.go | 10 +++++--- 5 files changed, 68 insertions(+), 20 deletions(-) diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index a0a8ca1..6a71975 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -1216,6 +1216,32 @@ initial_cases: current_status: match notes: "A compose configuration with Features: TS generateFeaturesConfig reads the lockfile next to the configuration (getLockfilePath reads configFilePath.fsPath), so even with --id-label the configuration's configFilePath has fsPath (vscode-uri's toJSON writes it once read). Go left it out." + - id: read-configuration.override-config-scheme-success + lane: semantic + command: read-configuration + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\"}' > $WORKSPACE/.devcontainer/devcontainer.json; printf '%s' '{\"image\":\"alpine:3\",\"remoteUser\":\"root\"}' > $WORKSPACE/override.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --override-config ${WORKSPACE}/override.json" + cleanup_cmd: "rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: output-shape + current_status: match + notes: "TS makes a file URI of --config only: with --override-config, configFilePath is the configuration found in the workspace (getDevContainerConfigPathIn), with the vscode-fileHost scheme. Go wrote the file scheme whenever --override-config or --container-id was given." + + - id: up.override-config-scheme-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\"}' > $WORKSPACE/.devcontainer/devcontainer.json; printf '%s' '{\"image\":\"alpine:3\",\"remoteUser\":\"root\"}' > $WORKSPACE/override.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --override-config ${WORKSPACE}/override.json --buildkit=never --include-configuration --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + cleanup_cmd: "IDS=$(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: output-shape + current_status: match + notes: "up keeps the configuration found in the workspace as configFilePath, with the vscode-fileHost scheme, when --override-config is given; readDocument reads the override, not it, so with --id-label its URI has no fsPath. Go wrote the file scheme." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/read_configuration.go b/internal/cli/read_configuration.go index e24a048..a47cbda 100644 --- a/internal/cli/read_configuration.go +++ b/internal/cli/read_configuration.go @@ -293,8 +293,7 @@ func loadReadConfiguration(workspaceFolder, configPath, overridePath string, opt // typed config drops are kept. The legacy settings, extensions and devPort are // left out only where the loader migrated them into customizations: one it // left in place, as TS updateFromOldProperties does, is printed. configFilePath is a -// VS Code URI, with the file scheme when the config was named on the command -// line or a container was. +// VS Code URI, with the file scheme when the config was named with --config. func configurationOutput(result *config.LoadResult, opts *readConfigOpts) map[string]any { cfgMap := map[string]any{} if result == nil { @@ -313,8 +312,12 @@ func configurationOutput(result *config.LoadResult, opts *readConfigOpts) map[st } cfgMap[k] = v } + // TS makes a file URI of --config; a configuration found in the workspace, + // or its default path when only --override-config is given, has the + // vscode-fileHost scheme, with or without --container-id + // (devContainersSpecCLI.ts readConfiguration). scheme := "vscode-fileHost" - if opts.containerID != "" || opts.configPath != "" || opts.overrideConfig != "" { + if opts.configPath != "" { scheme = "file" } // TS findContainerAndIdLabels reads the URI's fsPath (devContainersSpecCLI.ts diff --git a/internal/cli/read_configuration_held_test.go b/internal/cli/read_configuration_held_test.go index d9efcbd..9fa2b38 100644 --- a/internal/cli/read_configuration_held_test.go +++ b/internal/cli/read_configuration_held_test.go @@ -125,14 +125,20 @@ func TestConfigurationOutput(t *testing.T) { t.Errorf("configFilePath = %v, want %v", got["configFilePath"], want) } - for name, opts := range map[string]readConfigOpts{ - "--config": {configPath: cfp}, - "--override-config": {overrideConfig: cfp}, - "--container-id": {containerID: "abc"}, + // TS makes a file URI of --config only; the configuration found in the + // workspace keeps the vscode-fileHost scheme with --override-config or + // --container-id. + for name, tc := range map[string]struct { + opts readConfigOpts + scheme string + }{ + "--config": {readConfigOpts{configPath: cfp}, "file"}, + "--override-config": {readConfigOpts{overrideConfig: cfp}, "vscode-fileHost"}, + "--container-id": {readConfigOpts{containerID: "abc"}, "vscode-fileHost"}, } { - got := configurationOutput(result, &opts) - if scheme := got["configFilePath"].(map[string]any)["scheme"]; scheme != "file" { - t.Errorf("with %s: scheme = %v, want file", name, scheme) + got := configurationOutput(result, &tc.opts) + if scheme := got["configFilePath"].(map[string]any)["scheme"]; scheme != tc.scheme { + t.Errorf("with %s: scheme = %v, want %s", name, scheme, tc.scheme) } } } diff --git a/internal/cli/up.go b/internal/cli/up.go index 8662aa6..e70d25b 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -516,14 +516,25 @@ func upRemoteUser(cfgUser string, ctr container.InspectResponse, imageLabels fun return cmp.Or(ctrUser, "root") } +// upConfigFileFromArgument reports whether the URI TS's up keeps as +// configFilePath is one URI.file made from an argument, with the file scheme: +// --config's, or without a workspace --override-config's +// (configContainer.ts resolveWithLocalFolder). One found in the workspace, or +// its default path, has the vscode-fileHost scheme. +func upConfigFileFromArgument(opts *upOpts) bool { + return opts.configPath != "" || opts.workspaceFolder == "" && opts.overrideConfig != "" +} + // upConfigFileFSPathRead reports whether TS's up read the configuration URI's // fsPath, which decides whether the URI it writes has one: readDocument reads -// a file URI's (editableFiles.ts), so a --config read without an override; -// resolveWithLocalFolder passes it to findContainerAndIdLabels without -// --id-label (configContainer.ts); and getLockfilePath reads it when Features -// are processed with the lockfile on (lockfile.ts). +// a file URI's (editableFiles.ts) when it is the document read +// (overrideConfigFile ?? configFile); resolveWithLocalFolder passes it to +// findContainerAndIdLabels without --id-label (configContainer.ts); and +// getLockfilePath reads it when Features are processed with the lockfile on +// (lockfile.ts). func upConfigFileFSPathRead(opts *upOpts, lockfileRead bool) bool { - return opts.configPath != "" && opts.overrideConfig == "" || len(opts.idLabels) == 0 || lockfileRead + readDocument := upConfigFileFromArgument(opts) && (opts.configPath == "" || opts.overrideConfig == "") + return readDocument || len(opts.idLabels) == 0 || lockfileRead } // addUpConfiguration adds the configuration (--include-configuration) and the @@ -558,7 +569,7 @@ func addUpConfiguration(result map[string]any, opts *upOpts, fsPathRead bool, cf } if cfp, ok := subMap["configFilePath"].(string); ok && cfp != "" { scheme := "vscode-fileHost" - if opts.configPath != "" || opts.overrideConfig != "" { + if upConfigFileFromArgument(opts) { scheme = "file" } subMap["configFilePath"] = configFileURI(cfp, scheme, fsPathRead) diff --git a/internal/cli/up_held_test.go b/internal/cli/up_held_test.go index 1695a79..af98648 100644 --- a/internal/cli/up_held_test.go +++ b/internal/cli/up_held_test.go @@ -146,9 +146,10 @@ func TestAddUpConfigurationNamesTheConfigFileAsTSDoes(t *testing.T) { opts upOpts scheme string }{ - {"found in the workspace", upOpts{}, "vscode-fileHost"}, - {"--config", upOpts{configPath: "c.json"}, "file"}, - {"--override-config", upOpts{overrideConfig: "o.json"}, "file"}, + {"found in the workspace", upOpts{workspaceFolder: "/ws"}, "vscode-fileHost"}, + {"--config", upOpts{workspaceFolder: "/ws", configPath: "c.json"}, "file"}, + {"--override-config", upOpts{workspaceFolder: "/ws", overrideConfig: "o.json"}, "vscode-fileHost"}, + {"--override-config without a workspace", upOpts{overrideConfig: "o.json"}, "file"}, } { t.Run(tc.name, func(t *testing.T) { tc.opts.includeConfig, tc.opts.includeMergedConfig = true, true @@ -204,7 +205,8 @@ func TestUpConfigFileFSPathReadIsWhereTSUpReadsIt(t *testing.T) { {"--id-label", upOpts{idLabels: labels}, false, false}, {"--id-label and --config", upOpts{idLabels: labels, configPath: "c.json"}, false, true}, {"--id-label, --config and --override-config", upOpts{idLabels: labels, configPath: "c.json", overrideConfig: "o.json"}, false, false}, - {"--id-label and --override-config", upOpts{idLabels: labels, overrideConfig: "o.json"}, false, false}, + {"--id-label and --override-config", upOpts{idLabels: labels, workspaceFolder: "/ws", overrideConfig: "o.json"}, false, false}, + {"--id-label and --override-config without a workspace", upOpts{idLabels: labels, overrideConfig: "o.json"}, false, true}, {"--id-label and the lockfile read", upOpts{idLabels: labels}, true, true}, } { if got := upConfigFileFSPathRead(&tc.opts, tc.lockfileRead); got != tc.want { From c8311074991d3840c5d5046aed9d22d75db2c119 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 23:08:36 -0300 Subject: [PATCH 28/37] fix(cli): report the git worktree common dir mount in read-configuration With --mount-git-worktree-common-dir and a worktree whose .git file names its gitdir by a relative path, the reference's workspace configuration carries additionalMountString, the common dir's bind mount, and read-configuration writes it after workspaceFolder and workspaceMount. Go mounted it in up but left it out of the result. The workspace object is now also written in the reference's key order. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 28 ++++++++++++++++++++ internal/cli/read_configuration.go | 7 ++++- internal/cli/read_configuration_held_test.go | 13 +++++++++ internal/cli/result_order.go | 2 ++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 6a71975..6e8588a 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -1242,6 +1242,34 @@ initial_cases: current_status: match notes: "up keeps the configuration found in the workspace as configFilePath, with the vscode-fileHost scheme, when --override-config is given; readDocument reads the override, not it, so with --id-label its URI has no fsPath. Go wrote the file scheme." + - id: read-configuration.git-worktree-common-dir-success + lane: semantic + command: read-configuration + priority: p1 + docker_required: true + setup_cmd: "BASE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $BASE; mkdir -p $BASE; git init -q $BASE/repo && git -C $BASE/repo -c user.email=p@p -c user.name=p commit -q --allow-empty -m init && git -C $BASE/repo worktree add -q ../wt && printf '%s\\n' 'gitdir: ../repo/.git/worktrees/wt' > $BASE/wt/.git; mkdir -p $BASE/wt/.devcontainer; printf '%s' '{\"image\":\"alpine:3\",\"remoteUser\":\"root\"}' > $BASE/wt/.devcontainer/devcontainer.json; echo WORKSPACE=$BASE/wt; echo BASE=$BASE" + ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --mount-git-worktree-common-dir" + cleanup_cmd: "rm -rf ${BASE}; true" + key_order: ["workspace"] + asserts: [exit_code, stdout_normalized] + class: output-shape + current_status: match + notes: "A Git worktree whose .git file names its gitdir by a relative path: with --mount-git-worktree-common-dir, TS getWorkspaceConfiguration (utils.ts) adds additionalMountString, the bind mount of the common dir, and read-configuration writes it in workspace after workspaceFolder and workspaceMount. Go kept the mount for up but left it out of the result." + + - id: up.git-worktree-common-dir-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "BASE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $BASE; mkdir -p $BASE; git init -q $BASE/repo && git -C $BASE/repo -c user.email=p@p -c user.name=p commit -q --allow-empty -m init && git -C $BASE/repo worktree add -q ../wt && printf '%s\\n' 'gitdir: ../repo/.git/worktrees/wt' > $BASE/wt/.git; mkdir -p $BASE/wt/.devcontainer; printf '%s' '{\"image\":\"alpine:3\",\"remoteUser\":\"root\"}' > $BASE/wt/.devcontainer/devcontainer.json; echo WORKSPACE=$BASE/wt; echo BASE=$BASE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --mount-git-worktree-common-dir --buildkit=never --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "docker inspect -f '{{range .Mounts}}{{.Type}}:{{.Source}}:{{.Destination}} {{end}}' ${CONTAINER_ID}" + cleanup_cmd: "IDS=$(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1; rm -rf ${BASE}; true" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + notes: "up mounts the additionalMountString read-configuration reports: TS spawnDevContainer passes it as a --mount after the workspace mount (singleContainer.ts), so the container has the worktree and its common dir." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/read_configuration.go b/internal/cli/read_configuration.go index a47cbda..5f4eca5 100644 --- a/internal/cli/read_configuration.go +++ b/internal/cli/read_configuration.go @@ -165,7 +165,7 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) } if ws, ok := workspaceOutput(result, workspaceFolder, opts.containerID); ok { - output["workspace"] = ws + output["workspace"] = orderedObject{ws, workspaceKeys} } // Include features configuration if requested (or needed for merged config without container) @@ -407,6 +407,11 @@ func workspaceOutput(result *config.LoadResult, workspaceFolder, containerIDFlag if result.WorkspaceConfig.WorkspaceMount != "" { ws["workspaceMount"] = result.WorkspaceConfig.WorkspaceMount } + // The loader's additional mounts are at most the Git worktree common dir's, + // TS's additionalMountString (getWorkspaceConfiguration). + if mounts := result.WorkspaceConfig.AdditionalMounts; len(mounts) > 0 { + ws["additionalMountString"] = mounts[0] + } return ws, true } diff --git a/internal/cli/read_configuration_held_test.go b/internal/cli/read_configuration_held_test.go index 9fa2b38..73653ea 100644 --- a/internal/cli/read_configuration_held_test.go +++ b/internal/cli/read_configuration_held_test.go @@ -224,6 +224,7 @@ func TestSubstituteContainerEnv(t *testing.T) { func TestWorkspaceOutput(t *testing.T) { withMount := &config.LoadResult{WorkspaceConfig: &config.WorkspaceConfig{WorkspaceFolder: "/workspaces/p", WorkspaceMount: "type=bind,source=/p,target=/workspaces/p"}} withoutMount := &config.LoadResult{WorkspaceConfig: &config.WorkspaceConfig{WorkspaceFolder: "/workspaces/p"}} + worktree := &config.LoadResult{WorkspaceConfig: &config.WorkspaceConfig{WorkspaceFolder: "/workspaces/wt", WorkspaceMount: "type=bind,source=/r/wt,target=/workspaces/wt", AdditionalMounts: []string{"type=bind,source=/r/repo/.git,target=/workspaces/repo/.git"}}} for _, tc := range []struct { name string result *config.LoadResult @@ -235,6 +236,7 @@ func TestWorkspaceOutput(t *testing.T) { {"no workspace config", &config.LoadResult{}, "/p", "", nil, false}, {"folder and mount", withMount, "/p", "", map[string]any{"workspaceFolder": "/workspaces/p", "workspaceMount": "type=bind,source=/p,target=/workspaces/p"}, true}, {"folder only", withoutMount, "/p", "", map[string]any{"workspaceFolder": "/workspaces/p"}, true}, + {"worktree common dir", worktree, "/r/wt", "", map[string]any{"workspaceFolder": "/workspaces/wt", "workspaceMount": "type=bind,source=/r/wt,target=/workspaces/wt", "additionalMountString": "type=bind,source=/r/repo/.git,target=/workspaces/repo/.git"}, true}, {"container id without workspace folder", withMount, "", "abc", map[string]any{}, true}, {"container id and workspace folder", withoutMount, "/p", "abc", map[string]any{"workspaceFolder": "/workspaces/p"}, true}, {"id label without workspace folder", withoutMount, "", "", map[string]any{"workspaceFolder": "/workspaces/p"}, true}, @@ -248,6 +250,17 @@ func TestWorkspaceOutput(t *testing.T) { } } +// TS writes the workspace as getWorkspaceConfiguration's literal lists it. +func TestWorkspaceOutputKeepsTSOrder(t *testing.T) { + got, err := stringifyJSON(orderedObject{map[string]any{"additionalMountString": "a", "workspaceMount": "m", "workspaceFolder": "f"}, workspaceKeys}, "") + if err != nil { + t.Fatal(err) + } + if want := `{"workspaceFolder":"f","workspaceMount":"m","additionalMountString":"a"}`; string(got) != want { + t.Errorf("workspace = %s, want %s", got, want) + } +} + func TestNeedsFeaturesConfiguration(t *testing.T) { withFeatures := &config.LoadResult{Config: &config.DevContainer{Features: map[string]any{"./f": map[string]any{}}}} noFeatures := &config.LoadResult{Config: &config.DevContainer{}} diff --git a/internal/cli/result_order.go b/internal/cli/result_order.go index 93fbf70..0142ed4 100644 --- a/internal/cli/result_order.go +++ b/internal/cli/result_order.go @@ -62,6 +62,8 @@ var ( runUserCommandsResultKeys = []string{"outcome", "result"} // readConfiguration. readConfigurationKeys = []string{"configuration", "workspace", "featuresConfiguration", "mergedConfiguration", "ociAuthDiagnostics"} + // getWorkspaceConfiguration's WorkspaceConfiguration (utils.ts). + workspaceKeys = []string{"workspaceFolder", "workspaceMount", "additionalMountString"} // generateFeaturesConfig's FeaturesConfig. featuresConfigurationKeys = []string{"featureSets", "dstFolder"} // The properties mergeConfiguration (imageMetadata.ts) sets after spreading From 294611c4f66ba3558270751a833854c0ee68b2ce Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 23:23:04 -0300 Subject: [PATCH 29/37] fix(cli)!: write the Feature lockfile by default as the reference does The reference CLI (v0.89.0) writes devcontainer-lock.json on every build and up that has Features unless --no-lockfile is given (containerFeaturesConfiguration.ts generateFeaturesConfig -> lockfile.ts writeLockfile, which creates a missing file). build and up, single-container and Compose, only wrote it with --experimental-lockfile or --frozen-lockfile. They now create or update it by default; --no-lockfile disables reading and writing it, and --frozen-lockfile is unchanged. --experimental-lockfile and --experimental-frozen-lockfile now print the reference's deprecation warnings on stderr. upgrade truncates the lockfile before writing it, as upgradeCommand.ts does, so a lockfile that differs only in formatting is rewritten in canonical form. BREAKING CHANGE: build and up create devcontainer-lock.json (or .devcontainer-lock.json next to .devcontainer.json) in projects that use Features. Commit it to pin the Features, or pass --no-lockfile to neither read nor write it. See docs/migration.md. Co-Authored-By: Claude Opus 5.5 --- docs/migration.md | 13 +++ docs/parity/parity-matrix.yaml | 106 ++++++++++++++++++++++ internal/cli/build.go | 18 +++- internal/cli/compose_up.go | 6 +- internal/cli/compose_up_held_test.go | 36 +++++--- internal/cli/feature_install.go | 18 ++-- internal/cli/feature_install_held_test.go | 63 ++++++++++--- internal/cli/lockfile_flags_test.go | 53 +++++++++++ internal/cli/outdated.go | 9 +- internal/cli/outdated_repo_held_test.go | 47 ++++++++++ internal/cli/up.go | 2 +- internal/features/lockfile.go | 19 ++-- internal/features/lockfile_frozen_test.go | 3 +- internal/features/lockfile_held_test.go | 5 +- internal/features/lockfile_test.go | 4 +- 15 files changed, 339 insertions(+), 63 deletions(-) create mode 100644 internal/cli/lockfile_flags_test.go diff --git a/docs/migration.md b/docs/migration.md index a5f4f37..2b32a9e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -97,6 +97,19 @@ runs the official CLI is unaffected: are colored and end in `\r\n`, and Stop lines appear only at `trace`. Earlier releases always wrote `[N ms] text` without color. A script that parses the log should read the timestamped form, or use `--log-format json`. +- `build` and `up` write the Feature lockfile by default whenever the configuration + uses Features, as the official CLI does: `devcontainer-lock.json` next to + `devcontainer.json` (`.devcontainer-lock.json` next to `.devcontainer.json`) is + created when missing and rewritten when the resolved Features change. Earlier + releases wrote it only with `--experimental-lockfile` or `--frozen-lockfile`, so + the file now appears in projects that never had one. Commit it to pin the + Features' versions and digests, or pass `--no-lockfile` to neither read nor write + it. `--experimental-lockfile` is accepted but does nothing more and prints + `Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by + default.` on stderr; `--experimental-frozen-lockfile` prints a warning to use + `--frozen-lockfile` instead. +- `upgrade` always rewrites the lockfile in its canonical form, even when the + existing one differs only in formatting. - Only a registry whose host is `localhost` is reached over plain HTTP. Earlier releases also used plain HTTP for `127.0.0.1`, which is now addressed over HTTPS like any other host. Refer to a local plain-HTTP registry as `localhost:`. diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 6e8588a..0c2e9f4 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -1270,6 +1270,112 @@ initial_cases: current_status: match notes: "up mounts the additionalMountString read-configuration reports: TS spawnDevContainer passes it as a --mount after the workspace mount (singleContainer.ts), so the container has the worktree and its common dir." + - id: build.lockfile-written-by-default-success + lane: runtime + command: build + priority: p1 + docker_required: true + network_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./f\":{},\"ghcr.io/devcontainers/feature-starter/hello:1\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "build --workspace-folder ${WORKSPACE} --buildkit=never --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "cat ${WORKSPACE}/.devcontainer/devcontainer-lock.json" + cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code] + class: lockfile + current_status: match + compare_hashes: true + notes: "TS writes devcontainer-lock.json on every build with Features unless --no-lockfile is given (containerFeaturesConfiguration.ts generateFeaturesConfig -> writeLockfile, which creates the file); it records the OCI Feature, pinned to its digest, and leaves the local one out. Go wrote it only with --experimental-lockfile or --frozen-lockfile." + + - id: build.no-lockfile-writes-none-success + lane: runtime + command: build + priority: p1 + docker_required: true + network_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./f\":{},\"ghcr.io/devcontainers/feature-starter/hello:1\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "build --workspace-folder ${WORKSPACE} --buildkit=never --no-lockfile --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "ls -A ${WORKSPACE}/.devcontainer" + cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code] + class: lockfile + current_status: match + notes: "--no-lockfile disables writing the lockfile (generateFeaturesConfig skips writeLockfile): the configuration folder holds no devcontainer-lock.json after the build." + + - id: build.dot-devcontainer-lockfile-path-success + lane: runtime + command: build + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./.devcontainer/f\":{}}}' > $WORKSPACE/.devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "build --workspace-folder ${WORKSPACE} --buildkit=never --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "cd ${WORKSPACE} && ls -A . .devcontainer && cat .devcontainer-lock.json" + cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code] + class: lockfile + current_status: match + notes: "A .devcontainer.json gets .devcontainer-lock.json next to it (lockfile.ts getLockfilePath); with only a local Feature (under .devcontainer/, where TS requires it) the lockfile's features map is empty, and TS still writes it." + + - id: up.lockfile-rewrites-stale-success + lane: runtime + command: up + priority: p1 + docker_required: true + network_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"features\":{\"stale\":{}}}' > $WORKSPACE/.devcontainer/devcontainer-lock.json; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./f\":{},\"ghcr.io/devcontainers/feature-starter/hello:1\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --skip-post-create --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "cat ${WORKSPACE}/.devcontainer/devcontainer-lock.json" + cleanup_cmd: "IDS=$(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code] + class: lockfile + current_status: match + compare_hashes: true + notes: "up rewrites a stale lockfile with the resolved Features by default (writeLockfile replaces content whose JSON.parse -> JSON.stringify differs). Go left an existing lockfile alone without a lockfile flag." + + - id: up.no-lockfile-leaves-lockfile-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"features\":{\"stale\":{}}}' > $WORKSPACE/.devcontainer/devcontainer-lock.json; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./f\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --skip-post-create --no-lockfile --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "cat ${WORKSPACE}/.devcontainer/devcontainer-lock.json" + cleanup_cmd: "IDS=$(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code] + class: lockfile + current_status: match + notes: "--no-lockfile neither reads nor writes the lockfile: a stale one is left as it was." + + - id: up.compose-lockfile-written-by-default-success + lane: runtime + command: up + priority: p1 + docker_required: true + network_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s\\n' 'services:' ' app:' ' image: alpine:3' ' command: sleep infinity' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"features\":{\"./f\":{},\"ghcr.io/devcontainers/feature-starter/hello:1\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --skip-post-create --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "cat ${WORKSPACE}/.devcontainer/devcontainer-lock.json" + cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code] + class: lockfile + current_status: match + compare_hashes: true + notes: "The Compose path resolves the Features through the same generateFeaturesConfig (dockerCompose.ts -> getExtendImageBuildInfo) and writes the lockfile by default. Go wrote it only with a lockfile flag." + + - id: build.compose-no-lockfile-writes-none-success + lane: runtime + command: build + priority: p1 + docker_required: true + network_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s\\n' 'services:' ' app:' ' image: alpine:3' ' command: sleep infinity' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"features\":{\"./f\":{},\"ghcr.io/devcontainers/feature-starter/hello:1\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "build --workspace-folder ${WORKSPACE} --buildkit=never --no-lockfile --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "ls -A ${WORKSPACE}/.devcontainer" + cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code] + class: lockfile + current_status: match + notes: "A Compose build with --no-lockfile writes no lockfile." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/build.go b/internal/cli/build.go index e927162..b0a6aa9 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "path/filepath" "slices" @@ -139,6 +140,8 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { } } + warnDeprecatedLockfileFlags(out.Stderr(), opts.experimentalLockfile, opts.experimentalFrozenLockfile) + // Non-blocking hint (interactive TTY only) if the host was never checked or a // previous `devcontainer check` found a failing configuration. warnUncheckedHost(out) @@ -236,6 +239,19 @@ func buildResultImageName(compose bool, argImageNames, built []string) any { return built } +// warnDeprecatedLockfileFlags writes the warnings TS up and build write +// straight to stderr for the deprecated lockfile flags +// (devContainersSpecCLI.ts warnDeprecatedLockfileFlags): the lockfile is +// written by default, so --experimental-lockfile does nothing more. +func warnDeprecatedLockfileFlags(w io.Writer, experimentalLockfile, experimentalFrozenLockfile bool) { + if experimentalLockfile { + fmt.Fprint(w, "Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by default.\n") + } + if experimentalFrozenLockfile { + fmt.Fprint(w, "Warning: --experimental-frozen-lockfile is deprecated. Use --frozen-lockfile instead.\n") + } +} + // foldFrozenLockfile folds the deprecated --experimental-frozen-lockfile into // --frozen-lockfile, as TS does (effectiveFrozenLockfile = frozenLockfile || // experimentalFrozenLockfile); the Feature build reads the folded value. @@ -836,7 +852,6 @@ func (r *buildRunner) buildCompose(ctx context.Context, cfg *config.DevContainer cacheFrom: opts.cacheFrom, buildNoCache: opts.noCache, skipFeatureAutoMapping: opts.skipFeatureAutoMapping, - experimentalLockfile: opts.experimentalLockfile, experimentalFrozenLockfile: opts.experimentalFrozenLockfile, noLockfile: opts.noLockfile, lockfileExcludeIDs: opts.lockfileExcludeIDs, @@ -930,7 +945,6 @@ func (r *buildRunner) featureBuildOptions(cfg *config.DevContainer, hostSub *con Output: opts.output, ContainerEnv: cfg.ContainerEnv, SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, - Lockfile: opts.experimentalLockfile, FrozenLockfile: opts.experimentalFrozenLockfile, NoLockfile: opts.noLockfile, ConfigPath: cfg.ConfigFilePath, diff --git a/internal/cli/compose_up.go b/internal/cli/compose_up.go index 8cb4b24..f7e1274 100644 --- a/internal/cli/compose_up.go +++ b/internal/cli/compose_up.go @@ -324,9 +324,11 @@ func (r *upRunner) composeFeatureSets(ctx context.Context, cfg *config.DevContai if err := substituteFeatureHostVars(res.FeatureSets, loadResult.HostSub); err != nil { return nil, "", err } - if (opts.experimentalLockfile || opts.experimentalFrozenLockfile) && cfg.ConfigFilePath != "" { + // Written by default, as TS generateFeaturesConfig writes it unless + // --no-lockfile is given. + if !opts.noLockfile && cfg.ConfigFilePath != "" { lf := features.GenerateLockfile(&features.Config{FeatureSets: res.FeatureSets}, opts.lockfileExcludeIDs) - if err := features.WriteLockfile(cfg.ConfigFilePath, lf, features.WriteOptions{Frozen: opts.experimentalFrozenLockfile, Force: opts.experimentalLockfile}); err != nil { + if err := features.WriteLockfile(cfg.ConfigFilePath, lf, features.WriteOptions{Frozen: opts.experimentalFrozenLockfile}); err != nil { return nil, "", fmt.Errorf("lockfile: %w", err) } } diff --git a/internal/cli/compose_up_held_test.go b/internal/cli/compose_up_held_test.go index 802fcc5..b282cad 100644 --- a/internal/cli/compose_up_held_test.go +++ b/internal/cli/compose_up_held_test.go @@ -622,8 +622,10 @@ func TestComposeFeatureSets(t *testing.T) { if st, err := os.Stat(dir); err != nil || !st.IsDir() { t.Errorf("features folder %q: %v", dir, err) } - if _, err := os.Stat(filepath.Join(h.dc, "devcontainer-lock.json")); !os.IsNotExist(err) { - t.Errorf("lockfile written without --experimental-lockfile: %v", err) + // The lockfile is written by default (TS generateFeaturesConfig); a + // local Feature leaves its features map empty. + if got := readFile(t, filepath.Join(h.dc, "devcontainer-lock.json")); got != "{\n \"features\": {}\n}\n" { + t.Errorf("lockfile = %q", got) } }) t.Run("missing feature", func(t *testing.T) { @@ -633,15 +635,15 @@ func TestComposeFeatureSets(t *testing.T) { t.Error("want an error for a Feature folder that does not exist") } }) - t.Run("experimental lockfile", func(t *testing.T) { + t.Run("no lockfile", func(t *testing.T) { h := newComposeHarness(t) - h.r.opts.experimentalLockfile = true + h.r.opts.noLockfile = true cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: h.localFeature()} if _, _, err := h.r.composeFeatureSets(ctx, cfg, lr); err != nil { t.Fatal(err) } - if _, err := os.Stat(filepath.Join(h.dc, "devcontainer-lock.json")); err != nil { - t.Errorf("lockfile not written: %v", err) + if _, err := os.Stat(filepath.Join(h.dc, "devcontainer-lock.json")); !os.IsNotExist(err) { + t.Errorf("lockfile written with --no-lockfile: %v", err) } }) t.Run("frozen lockfile out of date", func(t *testing.T) { @@ -653,15 +655,19 @@ func TestComposeFeatureSets(t *testing.T) { t.Errorf("err = %v, want the lockfile error", err) } }) - t.Run("existing lockfile left alone", func(t *testing.T) { - h := newComposeHarness(t) - lock := h.write("devcontainer-lock.json", `{"features":{"stale":{}}}`) - cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: h.localFeature()} - if _, _, err := h.r.composeFeatureSets(ctx, cfg, lr); err != nil { - t.Fatal(err) - } - if got := readFile(t, lock); got != `{"features":{"stale":{}}}` { - t.Errorf("lockfile rewritten without a lockfile flag: %s", got) + t.Run("existing lockfile", func(t *testing.T) { + // Rewritten by default; --no-lockfile leaves it alone. + for noLockfile, want := range map[bool]string{false: "{\n \"features\": {}\n}\n", true: `{"features":{"stale":{}}}`} { + h := newComposeHarness(t) + h.r.opts.noLockfile = noLockfile + lock := h.write("devcontainer-lock.json", `{"features":{"stale":{}}}`) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: h.localFeature()} + if _, _, err := h.r.composeFeatureSets(ctx, cfg, lr); err != nil { + t.Fatal(err) + } + if got := readFile(t, lock); got != want { + t.Errorf("noLockfile=%t: lockfile = %q, want %q", noLockfile, got, want) + } } }) t.Run("malformed lockfile", func(t *testing.T) { diff --git a/internal/cli/feature_install.go b/internal/cli/feature_install.go index b0b1a6e..f7646d3 100644 --- a/internal/cli/feature_install.go +++ b/internal/cli/feature_install.go @@ -61,11 +61,11 @@ type FeatureBuildOptions struct { // devcontainer.metadata label, as written (TS getDevcontainerMetadata's raw). AdditionalMetadata []imagemeta.Entry LabelMetadata []imagemeta.Entry - // Lockfile writes devcontainer-lock.json with the resolved feature digests; + // devcontainer-lock.json is written with the resolved feature digests + // unless NoLockfile is set, as TS generateFeaturesConfig writes it; // FrozenLockfile validates the resolved features against the committed - // lockfile and aborts on drift. ConfigPath is the devcontainer.json path - // used to locate the lockfile. - Lockfile bool + // lockfile instead and aborts on drift. ConfigPath is the devcontainer.json + // path used to locate the lockfile. FrozenLockfile bool // NoLockfile disables lockfile reading (pinning-on-read) and writing, matching // TS --no-lockfile ("Disable lockfile generation and verification."). @@ -678,12 +678,12 @@ func extendImageWithFeatures( } } - // Write/validate the features lockfile when requested (opt-in via the - // --experimental-lockfile / --experimental-frozen-lockfile flags). Writing - // records the resolved digests for reproducibility; frozen aborts on drift. - if (fbOpts.Lockfile || fbOpts.FrozenLockfile) && fbOpts.ConfigPath != "" { + // Write (or, frozen, validate) the features lockfile unless --no-lockfile + // is given, as TS generateFeaturesConfig does (containerFeaturesConfiguration.ts + // `if (!params.noLockfile) await writeLockfile(...)`). + if !fbOpts.NoLockfile && fbOpts.ConfigPath != "" { lf := features.GenerateLockfile(&features.Config{FeatureSets: featureSets}, fbOpts.LockfileExcludeIDs) - if err := features.WriteLockfile(fbOpts.ConfigPath, lf, features.WriteOptions{Frozen: fbOpts.FrozenLockfile, Force: fbOpts.Lockfile}); err != nil { + if err := features.WriteLockfile(fbOpts.ConfigPath, lf, features.WriteOptions{Frozen: fbOpts.FrozenLockfile}); err != nil { return nil, fmt.Errorf("lockfile: %w", err) } } diff --git a/internal/cli/feature_install_held_test.go b/internal/cli/feature_install_held_test.go index 0befab8..a0e953d 100644 --- a/internal/cli/feature_install_held_test.go +++ b/internal/cli/feature_install_held_test.go @@ -719,34 +719,69 @@ func TestExtendImageWithFeaturesSwitchesToADockerDriverBuilder(t *testing.T) { } } -// --experimental-lockfile writes the lockfile; --experimental-frozen-lockfile -// fails when the resolved Features differ from it. An existing lockfile pins -// the Features it records. +// The lockfile is written by default, as TS generateFeaturesConfig writes it +// unless --no-lockfile is given; --frozen-lockfile fails when the resolved +// Features differ from it. An existing lockfile pins the Features it records. func TestExtendImageWithFeaturesLockfile(t *testing.T) { - t.Run("written", func(t *testing.T) { + tgz := featureTgzBytes(t, map[string]string{"devcontainer-feature.json": `{"id":"tb","version":"1.2.3"}`, "install.sh": "#!/bin/sh\n"}) + sum := sha256.Sum256(tgz) + digest := "sha256:" + hex.EncodeToString(sum[:]) + uri := serveBytes(t, tgz, http.StatusOK) + "/devcontainer-feature-tb.tgz" + want := "{\n \"features\": {\n \"" + uri + "\": {\n \"version\": \"1.2.3\",\n \"resolved\": \"" + uri + "\",\n \"integrity\": \"" + digest + "\"\n }\n }\n}\n" + t.Run("written by default", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "devcontainer.json") - res := extendRun{fbOpts: FeatureBuildOptions{Lockfile: true, ConfigPath: configPath}}.run(t) + res := extendRun{features: map[string]any{uri: true}, fbOpts: FeatureBuildOptions{ConfigPath: configPath}}.run(t) if res.err != nil { t.Fatal(res.err) } - if _, err := os.Stat(features.LockfilePath(configPath)); err != nil { - t.Errorf("lockfile not written: %v", err) + if data, err := os.ReadFile(features.LockfilePath(configPath)); err != nil || string(data) != want { + t.Errorf("lockfile = %q, %v; want\n%s", data, err, want) } }) - t.Run("left alone without the flags", func(t *testing.T) { - t.Chdir(t.TempDir()) - configPath := filepath.Join(t.TempDir(), "devcontainer.json") + t.Run("stale lockfile rewritten", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), ".devcontainer.json") if err := os.WriteFile(features.LockfilePath(configPath), []byte("{}"), 0o644); err != nil { t.Fatal(err) } + if res := (extendRun{features: map[string]any{uri: true}, fbOpts: FeatureBuildOptions{ConfigPath: configPath}}).run(t); res.err != nil { + t.Fatal(res.err) + } + if data, err := os.ReadFile(filepath.Join(filepath.Dir(configPath), ".devcontainer-lock.json")); err != nil || string(data) != want { + t.Errorf("lockfile = %q, %v; want\n%s", data, err, want) + } + }) + t.Run("local Features only", func(t *testing.T) { + // generateLockfile records OCI and tarball Features only: an empty map. + configPath := filepath.Join(t.TempDir(), "devcontainer.json") if res := (extendRun{fbOpts: FeatureBuildOptions{ConfigPath: configPath}}).run(t); res.err != nil { t.Fatal(res.err) } - if data, err := os.ReadFile(features.LockfilePath(configPath)); err != nil || string(data) != "{}" { - t.Errorf("lockfile = %q, %v; want it untouched", data, err) + if data, err := os.ReadFile(features.LockfilePath(configPath)); err != nil || string(data) != "{\n \"features\": {}\n}\n" { + t.Errorf("lockfile = %q, %v", data, err) + } + }) + t.Run("--no-lockfile writes none", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "devcontainer.json") + if res := (extendRun{features: map[string]any{uri: true}, fbOpts: FeatureBuildOptions{ConfigPath: configPath, NoLockfile: true}}).run(t); res.err != nil { + t.Fatal(res.err) + } + if _, err := os.Stat(features.LockfilePath(configPath)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("--no-lockfile wrote a lockfile: %v", err) + } + stale := filepath.Join(t.TempDir(), "devcontainer.json") + if err := os.WriteFile(features.LockfilePath(stale), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if res := (extendRun{features: map[string]any{uri: true}, fbOpts: FeatureBuildOptions{ConfigPath: stale, NoLockfile: true}}).run(t); res.err != nil { + t.Fatal(res.err) + } + if data, err := os.ReadFile(features.LockfilePath(stale)); err != nil || string(data) != "{}" { + t.Errorf("lockfile = %q, %v; want it untouched under --no-lockfile", data, err) } - // Without a configuration there is no lockfile to write. - if res := (extendRun{fbOpts: FeatureBuildOptions{Lockfile: true}}).run(t); res.err != nil { + }) + t.Run("without a configuration", func(t *testing.T) { + t.Chdir(t.TempDir()) + if res := (extendRun{}).run(t); res.err != nil { t.Fatal(res.err) } if entries, _ := os.ReadDir("."); len(entries) != 0 { diff --git a/internal/cli/lockfile_flags_test.go b/internal/cli/lockfile_flags_test.go new file mode 100644 index 0000000..89f6993 --- /dev/null +++ b/internal/cli/lockfile_flags_test.go @@ -0,0 +1,53 @@ +package cli + +import ( + "strings" + "testing" +) + +// up and build warn on stderr, before anything else, about the deprecated +// lockfile flags, as TS warnDeprecatedLockfileFlags (devContainersSpecCLI.ts) +// does at the start of both handlers; the lockfile is written by default, so +// --experimental-lockfile has nothing left to turn on. +func TestDeprecatedLockfileFlagsWarn(t *testing.T) { + const ( + write = "Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by default.\n" + frozen = "Warning: --experimental-frozen-lockfile is deprecated. Use --frozen-lockfile instead.\n" + ) + for _, tc := range []struct { + name string + experimental, frozen bool + want string + }{ + {"neither", false, false, ""}, + {"experimental-lockfile", true, false, write}, + {"experimental-frozen-lockfile", false, true, frozen}, + {"both", true, true, write + frozen}, + } { + run := map[string]func(*captureOutput) error{ + "build": func(out *captureOutput) error { + return runBuild(t.Context(), out, &buildOpts{ + workspaceFolder: t.TempDir(), logLevel: "info", logFormat: "text", buildkit: "auto", + experimentalLockfile: tc.experimental, experimentalFrozenLockfile: tc.frozen, + }) + }, + "up": func(out *captureOutput) error { + return runUp(t.Context(), out, &upOpts{ + workspaceFolder: t.TempDir(), logLevel: "info", logFormat: "text", buildkit: "auto", gpuAvailability: "detect", workspaceMountConsistency: "cached", defaultUserEnvProbe: "loginInteractiveShell", updateRemoteUserUIDDefault: "on", + experimentalLockfile: tc.experimental, experimentalFrozenLockfile: tc.frozen, + }) + }, + } + for cmd, fn := range run { + t.Run(cmd+"/"+tc.name, func(t *testing.T) { + out := &captureOutput{} + // An empty workspace fails loading its configuration, before Docker. + _ = fn(out) + stderr := out.err.String() + if !strings.HasPrefix(stderr, tc.want) || strings.Count(stderr, "is deprecated") != strings.Count(tc.want, "is deprecated") { + t.Errorf("stderr = %q, want it to start with %q and warn nothing more", stderr, tc.want) + } + }) + } + } +} diff --git a/internal/cli/outdated.go b/internal/cli/outdated.go index 9d7a2e3..7470122 100644 --- a/internal/cli/outdated.go +++ b/internal/cli/outdated.go @@ -467,8 +467,13 @@ func newUpgradeCmd() *cobra.Command { return nil } - err = features.WriteLockfile(lockfilePath, lf, features.WriteOptions{Force: true}) - if err != nil { + // TS truncates any existing lockfile before writing it + // (upgradeCommand.ts), so the new one is always written in its + // canonical form, even over one that differs only cosmetically. + if err := os.WriteFile(features.LockfilePath(lockfilePath), nil, 0o644); err != nil { + return err + } + if err := features.WriteLockfile(lockfilePath, lf, features.WriteOptions{}); err != nil { return err } diff --git a/internal/cli/outdated_repo_held_test.go b/internal/cli/outdated_repo_held_test.go index 66355de..02768f9 100644 --- a/internal/cli/outdated_repo_held_test.go +++ b/internal/cli/outdated_repo_held_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "io" "os" "path/filepath" @@ -146,6 +147,52 @@ func TestUpgradeWritesLockfileNextToConfig(t *testing.T) { } } +// upgrade truncates the lockfile before writing it (TS upgradeCommand.ts), so a +// lockfile with the same content in another layout is rewritten canonically, +// where build and up would leave it alone. +func TestUpgradeRewritesACosmeticallyDifferentLockfile(t *testing.T) { + reg := newReadRegistry(t) + reg.manifests["ns/tool"] = featureManifest("tool") + cfg := filepath.Join(t.TempDir(), "devcontainer.json") + writeFile(t, cfg, `{"image":"alpine","features":{"`+reg.host+`/ns/tool:1":{}}}`, 0o644) + ws := t.TempDir() + if _, err := executeCmd(t, newUpgradeCmd(), "--workspace-folder", ws, "--config", cfg); err != nil { + t.Fatal(err) + } + lockPath := features.LockfilePath(cfg) + canonical, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, canonical); err != nil { + t.Fatal(err) + } + writeFile(t, lockPath, compact.String(), 0o644) + if _, err := executeCmd(t, newUpgradeCmd(), "--workspace-folder", ws, "--config", cfg); err != nil { + t.Fatal(err) + } + if got := readFile(t, lockPath); got != string(canonical) { + t.Errorf("lockfile = %q, want it rewritten as %q", got, canonical) + } +} + +// A lockfile upgrade can truncate but not read back fails the command, as TS +// writeLockfile rethrows readLocalFile's EACCES after the truncation. +func TestUpgradeFailsOnALockfileItCannotReadBack(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root reads a write-only file") + } + reg := newReadRegistry(t) + reg.manifests["ns/tool"] = featureManifest("tool") + cfg := filepath.Join(t.TempDir(), "devcontainer.json") + writeFile(t, cfg, `{"image":"alpine","features":{"`+reg.host+`/ns/tool:1":{}}}`, 0o644) + writeFile(t, features.LockfilePath(cfg), "{}", 0o200) + if _, err := executeCmd(t, newUpgradeCmd(), "--workspace-folder", t.TempDir(), "--config", cfg); !errors.Is(err, os.ErrPermission) { + t.Errorf("err = %v, want the lockfile's permission error", err) + } +} + // upgrade reads the workspace given by --workspace-folder, and the current // directory without it. func TestUpgradeWorkspaceFolderDefaultsToCwd(t *testing.T) { diff --git a/internal/cli/up.go b/internal/cli/up.go index e70d25b..501448f 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -262,6 +262,7 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { return writeValidationError(out, "--no-lockfile and --experimental-lockfile are mutually exclusive.") } } + warnDeprecatedLockfileFlags(out.Stderr(), opts.experimentalLockfile, opts.experimentalFrozenLockfile) // TS folds --experimental-frozen-lockfile into --frozen-lockfile. opts.experimentalFrozenLockfile = opts.experimentalFrozenLockfile || opts.frozenLockfile // Non-blocking hint (interactive TTY only) if the host was never checked or a @@ -1377,7 +1378,6 @@ func (r *upRunner) featureBuildOptions(cfg *config.DevContainer, loadResult *con OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, - Lockfile: opts.experimentalLockfile, FrozenLockfile: opts.experimentalFrozenLockfile, NoLockfile: opts.noLockfile, ConfigPath: cfg.ConfigFilePath, diff --git a/internal/features/lockfile.go b/internal/features/lockfile.go index b7c2f15..1e9b5c2 100644 --- a/internal/features/lockfile.go +++ b/internal/features/lockfile.go @@ -334,12 +334,13 @@ func sequenceLen(b []byte) int { type WriteOptions struct { // Frozen fails (rather than writing) if the lockfile is missing or would change. Frozen bool - // Force writes even when no prior lockfile exists. - Force bool } -// WriteLockfile writes a lockfile to disk. With opts.Frozen set, it fails -// instead of writing when the lockfile is missing or would change. +// WriteLockfile writes a lockfile to disk as TS writeLockfile (lockfile.ts) +// does: it creates the file when there is none and rewrites it when its +// content would change. With opts.Frozen set, it fails instead of writing when +// the lockfile is missing or would change. A lockfile that exists but cannot be +// read is an error, as writeLockfile rethrows all but ENOENT. func WriteLockfile(configPath string, lockfile *Lockfile, opts WriteOptions) error { path := LockfilePath(configPath) @@ -349,19 +350,13 @@ func WriteLockfile(configPath string, lockfile *Lockfile, opts WriteOptions) err } oldData, readErr := os.ReadFile(path) - - if !opts.Force && readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + missing := errors.Is(readErr, os.ErrNotExist) + if readErr != nil && !missing { return readErr } - - missing := errors.Is(readErr, os.ErrNotExist) if opts.Frozen && missing { return ErrLockfileMissing } - // If no old lockfile and not explicitly enabled, skip - if !opts.Force && missing { - return nil - } // TS compares the old lockfile through JSON.parse -> JSON.stringify, so a // cosmetic difference is no change, and one that does not parse always is. diff --git a/internal/features/lockfile_frozen_test.go b/internal/features/lockfile_frozen_test.go index b67ec8e..80f3f09 100644 --- a/internal/features/lockfile_frozen_test.go +++ b/internal/features/lockfile_frozen_test.go @@ -8,8 +8,7 @@ import ( ) // A frozen lockfile fails with TS writeLockfile's own errors: "Lockfile does -// not exist." when there is none (also without --experimental-lockfile, and -// without writing one) and "Lockfile does not match." when it is stale. +// not exist." when there is none (without writing one) and "Lockfile does not match." when it is stale. func TestWriteLockfileFrozenErrors(t *testing.T) { configPath := filepath.Join(t.TempDir(), "devcontainer.json") err := WriteLockfile(configPath, heldLockfile(), WriteOptions{Frozen: true}) diff --git a/internal/features/lockfile_held_test.go b/internal/features/lockfile_held_test.go index 1588454..4cb5e70 100644 --- a/internal/features/lockfile_held_test.go +++ b/internal/features/lockfile_held_test.go @@ -76,10 +76,11 @@ func readHeld(t *testing.T, path string) (string, time.Time) { return string(data), info.ModTime() } -// A new lockfile is written as TS writeLockfile writes it. +// A new lockfile is written as TS writeLockfile writes it, which creates the +// file when there is none: the lockfile is on by default. func TestWriteLockfileWritesWhatTSWrites(t *testing.T) { configPath := filepath.Join(t.TempDir(), "devcontainer.json") - if err := WriteLockfile(configPath, heldLockfile(), WriteOptions{Force: true}); err != nil { + if err := WriteLockfile(configPath, heldLockfile(), WriteOptions{}); err != nil { t.Fatal(err) } if got, _ := readHeld(t, LockfilePath(configPath)); got != heldLockfileJSON { diff --git a/internal/features/lockfile_test.go b/internal/features/lockfile_test.go index 4ccc00e..51ffb5e 100644 --- a/internal/features/lockfile_test.go +++ b/internal/features/lockfile_test.go @@ -138,7 +138,7 @@ func TestReadWriteLockfile(t *testing.T) { }, } - err := WriteLockfile(configPath, lf, WriteOptions{Force: true}) + err := WriteLockfile(configPath, lf, WriteOptions{}) if err != nil { t.Fatal(err) } @@ -216,7 +216,7 @@ func TestWriteLockfile_Frozen_Changed(t *testing.T) { }, } - err := WriteLockfile(configPath, lf, WriteOptions{Frozen: true, Force: true}) + err := WriteLockfile(configPath, lf, WriteOptions{Frozen: true}) if err == nil { t.Error("frozen lockfile with changes should error") } From 86b95050c87741fd582967b3895dbf8a3b469093 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 23:34:53 -0300 Subject: [PATCH 30/37] fix(cli): resolve an absolute Dockerfile and its context as the reference does build and up joined build.dockerfile onto the configuration's folder, so an absolute path became '/' and the build failed to read it; --cache-key did the same. The reference resolves it against the configuration's folder, which keeps an absolute path (configuration.ts getDockerfilePath). Without build.context the context is the Dockerfile's folder (spec-node/utils.ts getDockerContextPath), where Go used the configuration's folder, and an absolute context was mangled the same way. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 28 ++++++++++++ internal/cli/build.go | 34 ++++++++++---- internal/cli/cache_key.go | 6 +-- internal/cli/dockerfile_path_test.go | 68 ++++++++++++++++++++++++++++ internal/cli/image_build_info.go | 5 +- internal/cli/up.go | 3 +- 6 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 internal/cli/dockerfile_path_test.go diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 0c2e9f4..fee4145 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -1376,6 +1376,34 @@ initial_cases: current_status: match notes: "A Compose build with --no-lockfile writes no lockfile." + - id: build.absolute-dockerfile-success + lane: runtime + command: build + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer $WORKSPACE/df; printf '%s\\n' 'FROM alpine:3' 'COPY marker /marker' > $WORKSPACE/df/Dockerfile; echo absolute > $WORKSPACE/df/marker; printf '{\"build\":{\"dockerfile\":\"%s/df/Dockerfile\"}}' $WORKSPACE > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "build --workspace-folder ${WORKSPACE} --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "docker run --rm parity-${PARITY_CASE_ID}-${PARITY_SIDE} cat /marker" + cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code] + class: build-runtime + current_status: match + notes: "An absolute build.dockerfile is used where it is (configuration.ts getDockerfilePath resolves it against the configuration's folder, which keeps an absolute path), and without build.context the context is its folder (utils.ts getDockerContextPath). Go joined it onto the configuration's folder and failed reading '/'." + + - id: build.subfolder-dockerfile-default-context-success + lane: runtime + command: build + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/sub; printf '%s\\n' 'FROM alpine:3' 'COPY marker /marker' > $WORKSPACE/.devcontainer/sub/Dockerfile; echo subfolder > $WORKSPACE/.devcontainer/sub/marker; printf '%s' '{\"build\":{\"dockerfile\":\"sub/Dockerfile\"}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "build --workspace-folder ${WORKSPACE} --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "docker run --rm parity-${PARITY_CASE_ID}-${PARITY_SIDE} cat /marker" + cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code] + class: build-runtime + current_status: match + notes: "Without build.context the context is the Dockerfile's folder (utils.ts getDockerContextPath: parentURI(getDockerfilePath)), not the configuration's: COPY finds the file next to sub/Dockerfile. Go used the configuration's folder and the COPY failed." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/internal/cli/build.go b/internal/cli/build.go index b0a6aa9..1710327 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -315,6 +315,30 @@ func copyDockerignore(origDockerfile, newDockerfile string) { _ = os.WriteFile(newDockerfile+".dockerignore", data, 0644) } +// configRelativePath is TS getConfigFilePath (configuration.ts): a path the +// configuration names, resolved against the configuration's folder, so an +// absolute one stays as it is. +func configRelativePath(cfg *config.DevContainer, p string) string { + if filepath.IsAbs(p) { + return filepath.Clean(p) + } + return filepath.Join(filepath.Dir(cfg.ConfigFilePath), p) +} + +// dockerfileConfigPath is TS getDockerfilePath: the configuration's Dockerfile. +func dockerfileConfigPath(cfg *config.DevContainer) string { + return configRelativePath(cfg, cfg.Dockerfile()) +} + +// dockerContextPath is TS getDockerContextPath (spec-node/utils.ts): the +// configured build context, else the Dockerfile's folder. +func dockerContextPath(cfg *config.DevContainer) string { + if c := cfg.BuildContext(); c != "" { + return configRelativePath(cfg, c) + } + return filepath.Dir(dockerfileConfigPath(cfg)) +} + // prepareDockerfileBuild resolves cfg's Dockerfile relative to the config dir, // reads it, ensures the final stage has a name (writing a temp Dockerfile when a // name must be injected), and parses it. The caller must defer prep.Cleanup(). @@ -325,8 +349,7 @@ func prepareDockerfileBuild(cfg *config.DevContainer) (*dockerfileBuildPrep, err if dockerfilePath == "" { return nil, fmt.Errorf("no Dockerfile specified") } - configDir := filepath.Dir(cfg.ConfigFilePath) - dockerfilePath = filepath.Join(configDir, dockerfilePath) + dockerfilePath = dockerfileConfigPath(cfg) content, err := os.ReadFile(dockerfilePath) if err != nil { @@ -384,7 +407,6 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai defer prep.Cleanup() dockerfilePath := prep.Path stageName := prep.StageName - configDir := filepath.Dir(cfg.ConfigFilePath) // The base image is the FROM of the config's target, else of the last stage // (TS getImageBuildInfoFromDockerfile with config.build.target), not of the @@ -405,11 +427,7 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai // Determine image names (match TS getFolderImageName: vsc-{basename}-{hash}) imageNames := imageNamesOr(opts.imageNames, folderImageName(resolvePath(opts.workspaceFolder))) - // Build context - contextPath := configDir - if cfg.BuildContext() != "" { - contextPath = filepath.Join(configDir, cfg.BuildContext()) - } + contextPath := dockerContextPath(cfg) // Build args from config buildArgs := buildArgsFromConfig(cfg) diff --git a/internal/cli/cache_key.go b/internal/cli/cache_key.go index ff38ec7..0d71e29 100644 --- a/internal/cli/cache_key.go +++ b/internal/cli/cache_key.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "github.com/devcontainers/cli/internal/config" "github.com/devcontainers/cli/internal/features" @@ -48,12 +47,11 @@ func computeCacheKey(result *config.LoadResult, env map[string]string) (string, } in := cacheKeyInput{Config: cfgJSON, Context: cfg.BuildContext()} - configDir := filepath.Dir(cfg.ConfigFilePath) - if df := cfg.Dockerfile(); df != "" { + if cfg.Dockerfile() != "" { // The Dockerfile build reads (prepareDockerfileBuild). One that cannot // be read fails the key, which would otherwise not track the image's // recipe. - content, err := os.ReadFile(filepath.Join(configDir, df)) + content, err := os.ReadFile(dockerfileConfigPath(cfg)) if err != nil { return "", fmt.Errorf("read Dockerfile: %w", err) } diff --git a/internal/cli/dockerfile_path_test.go b/internal/cli/dockerfile_path_test.go new file mode 100644 index 0000000..f51e811 --- /dev/null +++ b/internal/cli/dockerfile_path_test.go @@ -0,0 +1,68 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/devcontainers/cli/internal/config" +) + +// The Dockerfile and build context resolve as TS getDockerfilePath and +// getDockerContextPath do: against the configuration's folder, an absolute +// path as it is, and without a context the Dockerfile's own folder. +func TestDockerfileAndContextPaths(t *testing.T) { + cfgPath := "/ws/.devcontainer/devcontainer.json" + for _, tc := range []struct { + name string + build *config.Build + legacy, legacyCtx string + dockerfile, contextP string + }{ + {"relative", &config.Build{Dockerfile: "Dockerfile"}, "", "", "/ws/.devcontainer/Dockerfile", "/ws/.devcontainer"}, + {"in a subfolder", &config.Build{Dockerfile: "sub/Dockerfile"}, "", "", "/ws/.devcontainer/sub/Dockerfile", "/ws/.devcontainer/sub"}, + {"above the configuration", &config.Build{Dockerfile: "../Dockerfile", Context: ".."}, "", "", "/ws/Dockerfile", "/ws"}, + {"absolute", &config.Build{Dockerfile: "/elsewhere/x/Dockerfile"}, "", "", "/elsewhere/x/Dockerfile", "/elsewhere/x"}, + {"absolute context", &config.Build{Dockerfile: "Dockerfile", Context: "/ctx/"}, "", "", "/ws/.devcontainer/Dockerfile", "/ctx"}, + {"legacy dockerFile", nil, "/elsewhere/Dockerfile", "", "/elsewhere/Dockerfile", "/elsewhere"}, + {"legacy context", nil, "Dockerfile", "..", "/ws/.devcontainer/Dockerfile", "/ws"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.DevContainer{ConfigFilePath: cfgPath, Build: tc.build, DockerFile: tc.legacy, Context: tc.legacyCtx} + if got := dockerfileConfigPath(cfg); got != tc.dockerfile { + t.Errorf("Dockerfile = %q, want %q", got, tc.dockerfile) + } + if got := dockerContextPath(cfg); got != tc.contextP { + t.Errorf("context = %q, want %q", got, tc.contextP) + } + }) + } +} + +// An absolute build.dockerfile is read where it is by the build and by +// --cache-key, not joined onto the configuration's folder. +func TestAbsoluteDockerfileIsReadWhereItIs(t *testing.T) { + dockerfile := filepath.Join(t.TempDir(), "Dockerfile") + if err := os.WriteFile(dockerfile, []byte("FROM alpine:3 AS base\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(t.TempDir(), "devcontainer.json"), Build: &config.Build{Dockerfile: dockerfile}} + prep, err := prepareDockerfileBuild(cfg) + if err != nil { + t.Fatal(err) + } + defer prep.Cleanup() + if prep.Path != dockerfile || prep.StageName != "base" { + t.Errorf("prep = %q, stage %q; want %q, stage base", prep.Path, prep.StageName, dockerfile) + } + key, err := computeCacheKey(&config.LoadResult{Config: cfg}, nil) + if err != nil || key == "" { + t.Fatalf("cache key = %q, %v", key, err) + } + if err := os.WriteFile(dockerfile, []byte("FROM alpine:3.20 AS base\n"), 0o644); err != nil { + t.Fatal(err) + } + if changed, err := computeCacheKey(&config.LoadResult{Config: cfg}, nil); err != nil || changed == key { + t.Errorf("cache key after editing the Dockerfile = %q, %v; want it to change from %q", changed, err, key) + } +} diff --git a/internal/cli/image_build_info.go b/internal/cli/image_build_info.go index 08e3e09..6dd7db0 100644 --- a/internal/cli/image_build_info.go +++ b/internal/cli/image_build_info.go @@ -130,10 +130,7 @@ func getImageBuildInfo(ctx context.Context, cfg *config.DevContainer, insp *imag configDir := filepath.Dir(cfg.ConfigFilePath) switch { case cfg.IsDockerfileConfig(): - dockerfilePath := cfg.Dockerfile() - if !filepath.IsAbs(dockerfilePath) { - dockerfilePath = filepath.Join(configDir, dockerfilePath) - } + dockerfilePath := dockerfileConfigPath(cfg) if st, err := os.Stat(dockerfilePath); err != nil || st.IsDir() { return nil, fmt.Errorf("Dockerfile (%s) not found.", dockerfilePath) } diff --git a/internal/cli/up.go b/internal/cli/up.go index 501448f..7bbe661 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -637,7 +637,6 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, defer prep.Cleanup() dockerfilePath := prep.Path stageName := prep.StageName - configDir := filepath.Dir(cfg.ConfigFilePath) imageName := folderImageName(workspaceFolder) @@ -657,7 +656,7 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, baseMetadata := imagemeta.ReadMetadataFromLabels(baseLabels, logger) stageMetadata := imagemeta.ReadMetadataFromLabels(prep.Parsed.StageLabels(stageName), logger) - contextPath := filepath.Join(configDir, cfg.BuildContext()) + contextPath := dockerContextPath(cfg) upCacheFrom := cacheFromForDockerfileBuild(opts.cacheFrom, cfg) // Bridge the CLI credential chain to the build subprocess via a temporary From dbdf8e5afbb7833fd2dfee4bfed4b46cff32caaf Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 23:36:14 -0300 Subject: [PATCH 31/37] refactor(cli): report frozen-lockfile errors through the reference-error marker commandErrorOutput recognised the reference's own message two ways: a loop over the frozen-lockfile sentinels and the ReferenceError marker the lockfile read errors carry. The sentinels now carry the marker, so one check reports every such error as its message under ".", with the same output as before. Co-Authored-By: Claude Opus 5.5 --- internal/cli/build.go | 8 ++------ internal/features/lockfile.go | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/cli/build.go b/internal/cli/build.go index 1710327..358a1ca 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -802,12 +802,8 @@ func commandErrorOutput(err error, description string) coreerrors.ErrorOutput { return coreerrors.ToErrorOutput(ce) } // TS wraps a plain Error it throws as its originalError, so the message is - // that error's own text (a frozen lockfile's "Lockfile does not match."). - for _, plain := range []error{features.ErrLockfileMissing, features.ErrLockfileMismatch} { - if errors.Is(err, plain) { - return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: description + ".", OriginalError: plain}) - } - } + // that error's own text (a frozen lockfile's "Lockfile does not match.", a + // lockfile's JSON.parse error). if re, ok := errors.AsType[coreerrors.ReferenceError](err); ok { return coreerrors.ErrorOutput{Outcome: "error", Message: re.Error(), Description: description + "."} } diff --git a/internal/features/lockfile.go b/internal/features/lockfile.go index 1e9b5c2..e13673a 100644 --- a/internal/features/lockfile.go +++ b/internal/features/lockfile.go @@ -149,12 +149,22 @@ func ReadLockfile(configPath string) (*Lockfile, bool, error) { return &lf, false, nil } -// The errors a frozen WriteLockfile fails with, TS writeLockfile's own text. +// The errors a frozen WriteLockfile fails with, TS writeLockfile's own text: +// plain JavaScript errors, so they carry the reference's own message +// (internal/errors.ReferenceError). var ( - ErrLockfileMissing = errors.New("Lockfile does not exist.") - ErrLockfileMismatch = errors.New("Lockfile does not match.") + ErrLockfileMissing error = &frozenLockfileError{"Lockfile does not exist."} + ErrLockfileMismatch error = &frozenLockfileError{"Lockfile does not match."} ) +// frozenLockfileError is a message TS writeLockfile throws as a plain Error. +type frozenLockfileError struct{ msg string } + +func (e *frozenLockfileError) Error() string { return e.msg } + +// ReferenceError marks the message as the reference's own. +func (e *frozenLockfileError) ReferenceError() {} + // jsonTruthy is the truthiness of JSON.parse(data), valid JSON: TS reads the // parsed lockfile through `lockfile?.` and `if (lockfile)`, so null, false, 0 // and "" are no lockfile. From 83b9ad4d9bbc7e1e9f90258be08afc754d32bbb9 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 23:57:13 -0300 Subject: [PATCH 32/37] test(parity): lock the cases whose setup writes Features TestParityMatrixLocksTheFeatureContentImage read Features only from fixture configurations, so 13 cases whose setup_cmd writes a configuration with Features and builds it without BuildKit ran without the feature-content-image lock and could take each other's Features through dev_container_feature_content_temp. The check now also reads the configuration a setup_cmd writes, and those cases hold the lock. Co-Authored-By: Claude Opus 5.5 --- docs/parity/parity-matrix.yaml | 13 +++++++++++++ internal/cli/parity_lock_test.go | 10 +++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index fee4145..b242bad 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -780,6 +780,7 @@ initial_cases: notes: "TS builds a Dockerfile service through a build override even without Features (getImageBuildOptions adds the devcontainer.metadata label to the image) and then runs updateRemoteUserUID on the built image (dockerCompose.ts startContainer). Go built the service unchanged and never remapped the remote user's UID for compose." - id: up.compose-image-with-local-feature-image-name-success + lock: feature-content-image lane: runtime command: up priority: p1 @@ -907,6 +908,7 @@ initial_cases: notes: "BuildKit Features build (TS getFeaturesBuildOptions + getFeatureLayers, containerFeaturesConfiguration.ts): the Feature is staged at its consecutiveId (hello_0), bind-mounted from the dev_containers_feature_content_source build context, copied to /tmp/dev-container-features/hello_0 and installed through devcontainer-features-install.sh, which sources ../devcontainer-features.builtin.env (_CONTAINER_USER/_REMOTE_USER plus the _*_HOME lines the first RUN appends) and devcontainer-features.env (options in devcontainer-feature.json order); the folder is removed afterwards, the builtin env stays. The Feature's containerEnv and the config's containerEnv are ENV lines and the image keeps its own user (USER $_DEV_CONTAINERS_IMAGE_USER). Go used to copy _dev_container_feature_0 to /tmp/build-features and run install.sh with inline env prefixes, so ../devcontainer-features.builtin.env did not exist." - id: build.features-staging-layout-no-buildkit-success + lock: feature-content-image lane: runtime command: build priority: p1 @@ -1098,6 +1100,7 @@ initial_cases: notes: "A compose configuration without workspaceFolder works in `/` (TS getWorkspaceConfiguration returns getRemoteWorkspaceFolder, `config.workspaceFolder || '/'`, dockerCompose.ts): up's remoteWorkspaceFolder, ${containerWorkspaceFolder}, the lifecycle commands' cwd and exec's cwd. Go used /workspaces/, a folder the compose service never mounts." - id: build.frozen-lockfile-missing-failure + lock: feature-content-image lane: runtime command: build priority: p1 @@ -1111,6 +1114,7 @@ initial_cases: notes: "--frozen-lockfile without a lockfile fails with TS writeLockfile's 'Lockfile does not exist.' (lockfile.ts), wrapped as the build's ContainerError: message is that text, description 'An error occurred building the container.'. Go skipped the check when no lockfile existed and built the image." - id: up.frozen-lockfile-mismatch-failure + lock: feature-content-image lane: runtime command: up priority: p1 @@ -1124,6 +1128,7 @@ initial_cases: notes: "A stale lockfile with --frozen-lockfile fails with TS's 'Lockfile does not match.' as the message and 'An error occurred setting up the container.' as the description. Go reported 'An error occurred setting up the container: install features: lockfile: lockfile does not match' in both." - id: up.compose-frozen-lockfile-missing-failure + lock: feature-content-image lane: runtime command: up priority: p1 @@ -1151,6 +1156,7 @@ initial_cases: notes: "JSON.stringify writes properties in insertion order: the result as readConfiguration's literal lists them, the configuration in its document's order with configFilePath last, featuresConfiguration as FeaturesConfig's literal (featureSets, dstFolder), mergedConfiguration as mergeConfiguration builds it (the configuration without replaceProperties, then the properties it sets). Go wrote every object with sorted keys. Nested user objects keep sorted keys (docs/DIVERGENCES.md)." - id: up.include-configurations-key-order-success + lock: feature-content-image lane: runtime command: up priority: p1 @@ -1271,6 +1277,7 @@ initial_cases: notes: "up mounts the additionalMountString read-configuration reports: TS spawnDevContainer passes it as a --mount after the workspace mount (singleContainer.ts), so the container has the worktree and its common dir." - id: build.lockfile-written-by-default-success + lock: feature-content-image lane: runtime command: build priority: p1 @@ -1287,6 +1294,7 @@ initial_cases: notes: "TS writes devcontainer-lock.json on every build with Features unless --no-lockfile is given (containerFeaturesConfiguration.ts generateFeaturesConfig -> writeLockfile, which creates the file); it records the OCI Feature, pinned to its digest, and leaves the local one out. Go wrote it only with --experimental-lockfile or --frozen-lockfile." - id: build.no-lockfile-writes-none-success + lock: feature-content-image lane: runtime command: build priority: p1 @@ -1302,6 +1310,7 @@ initial_cases: notes: "--no-lockfile disables writing the lockfile (generateFeaturesConfig skips writeLockfile): the configuration folder holds no devcontainer-lock.json after the build." - id: build.dot-devcontainer-lockfile-path-success + lock: feature-content-image lane: runtime command: build priority: p1 @@ -1316,6 +1325,7 @@ initial_cases: notes: "A .devcontainer.json gets .devcontainer-lock.json next to it (lockfile.ts getLockfilePath); with only a local Feature (under .devcontainer/, where TS requires it) the lockfile's features map is empty, and TS still writes it." - id: up.lockfile-rewrites-stale-success + lock: feature-content-image lane: runtime command: up priority: p1 @@ -1332,6 +1342,7 @@ initial_cases: notes: "up rewrites a stale lockfile with the resolved Features by default (writeLockfile replaces content whose JSON.parse -> JSON.stringify differs). Go left an existing lockfile alone without a lockfile flag." - id: up.no-lockfile-leaves-lockfile-success + lock: feature-content-image lane: runtime command: up priority: p1 @@ -1346,6 +1357,7 @@ initial_cases: notes: "--no-lockfile neither reads nor writes the lockfile: a stale one is left as it was." - id: up.compose-lockfile-written-by-default-success + lock: feature-content-image lane: runtime command: up priority: p1 @@ -1362,6 +1374,7 @@ initial_cases: notes: "The Compose path resolves the Features through the same generateFeaturesConfig (dockerCompose.ts -> getExtendImageBuildInfo) and writes the lockfile by default. Go wrote it only with a lockfile flag." - id: build.compose-no-lockfile-writes-none-success + lock: feature-content-image lane: runtime command: build priority: p1 diff --git a/internal/cli/parity_lock_test.go b/internal/cli/parity_lock_test.go index 41359f0..2438af3 100644 --- a/internal/cli/parity_lock_test.go +++ b/internal/cli/parity_lock_test.go @@ -15,9 +15,13 @@ var ( parityWorkspaceFlag = regexp.MustCompile(`--workspace-folder\s+(src/test/configs/[^\s"';]+)`) parityConfigFlag = regexp.MustCompile(`--config\s+(src/test/configs/[^\s"';]+)`) nonEmptyFeaturesJSON = regexp.MustCompile(`"features"\s*:\s*\{\s*"`) + // setupWritesFeatures matches a setup_cmd that writes a configuration with + // Features itself, its quotes possibly escaped for the shell. + setupWritesFeatures = regexp.MustCompile(`\\?"features\\?"\s*:\s*\{\s*\\?"`) ) -// A case that builds Features without BuildKit goes through the shared +// A case that builds Features without BuildKit, from a fixture or from a +// configuration its setup_cmd writes, goes through the shared // dev_container_feature_content_temp image, so it must hold the // feature-content-image lock; a case that does not, must not, so the lock // serializes no more than it has to. @@ -36,13 +40,13 @@ func TestParityMatrixLocksTheFeatureContentImage(t *testing.T) { t.Errorf("case %q names unknown lock %q", tc.ID, tc.Lock) } cmds := tc.TSCmd + "\n" + tc.SetupCmd - need := strings.Contains(cmds, "--buildkit=never") && slices.ContainsFunc(parityCaseConfigs(t, cmds), func(config string) bool { + need := strings.Contains(cmds, "--buildkit=never") && (setupWritesFeatures.MatchString(tc.SetupCmd) || slices.ContainsFunc(parityCaseConfigs(t, cmds), func(config string) bool { data, err := os.ReadFile(filepath.Join("..", "..", config)) if err != nil { t.Fatalf("case %q: %v", tc.ID, err) } return nonEmptyFeaturesJSON.Match(data) - }) + })) switch has := tc.Lock == "feature-content-image"; { case need && !has: t.Errorf("case %q builds Features without BuildKit; add `lock: feature-content-image`", tc.ID) From baf5cb0cf86ef9f4d5c4c587e53dc3c510140826 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sun, 25 Oct 2026 23:59:41 -0300 Subject: [PATCH 33/37] test(cli): hold composeFeatureSets' lockfile read with a unit test composeFeatureSets carried a mutate-lane marker for the runtime parity case up.compose-config-file-fspath-features-success, which kept every line of it out of mutation testing although unit tests hold all of them but the lockfileRead flag. TestComposeFeatureSets now checks the flag (set only when Features are processed with the lockfile on), and the marker is gone. Co-Authored-By: Claude Opus 5.5 --- internal/cli/compose_up.go | 3 --- internal/cli/compose_up_held_test.go | 9 +++++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/cli/compose_up.go b/internal/cli/compose_up.go index f7e1274..d7bdd34 100644 --- a/internal/cli/compose_up.go +++ b/internal/cli/compose_up.go @@ -297,9 +297,6 @@ type composeExtendResult struct { // returning nil when none are configured. The returned folder (TS dstFolder) // holds each Feature at its consecutiveId; it is kept because the compose build // override references it (TS keeps its features folder too). -// -// mutate-lane: TestParityMatrix - up.compose-config-file-fspath-features-success -// processes a compose configuration's Features through it. func (r *upRunner) composeFeatureSets(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult) ([]*features.Set, string, error) { opts := r.opts if len(cfg.Features) > 0 { diff --git a/internal/cli/compose_up_held_test.go b/internal/cli/compose_up_held_test.go index b282cad..f05ebb5 100644 --- a/internal/cli/compose_up_held_test.go +++ b/internal/cli/compose_up_held_test.go @@ -605,6 +605,9 @@ func TestComposeFeatureSets(t *testing.T) { if sets != nil || dir != "" || err != nil { t.Errorf("got %v, %q, %v; want nothing", sets, dir, err) } + if h.r.lockfileRead { + t.Error("lockfileRead with no Features; TS reads the lockfile only to process Features") + } }) t.Run("local feature", func(t *testing.T) { h := newComposeHarness(t) @@ -619,6 +622,9 @@ func TestComposeFeatureSets(t *testing.T) { if got := sets[0].Features[0].ContainerEnv["HELLO"]; got != "from-host" { t.Errorf("containerEnv HELLO = %q, want the host variable substituted", got) } + if !h.r.lockfileRead { + t.Error("lockfileRead = false; processing Features reads the lockfile (TS getLockfilePath reads configFilePath.fsPath)") + } if st, err := os.Stat(dir); err != nil || !st.IsDir() { t.Errorf("features folder %q: %v", dir, err) } @@ -642,6 +648,9 @@ func TestComposeFeatureSets(t *testing.T) { if _, _, err := h.r.composeFeatureSets(ctx, cfg, lr); err != nil { t.Fatal(err) } + if h.r.lockfileRead { + t.Error("lockfileRead with --no-lockfile") + } if _, err := os.Stat(filepath.Join(h.dc, "devcontainer-lock.json")); !os.IsNotExist(err) { t.Errorf("lockfile written with --no-lockfile: %v", err) } From 37f7fcd1833073a1e41c234c0a6b032bd2d93273 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 26 Oct 2026 00:09:28 -0300 Subject: [PATCH 34/37] build: pin go-tools with mutate's isolated test processes go-tools 5fc0868 runs each mutate test process in a private copy of the module with a temporary directory of its own (spin-stack/go-tools#69). Here, mutants of internal/cli wrote Dockerfile-with-features and a timestamped cache folder into shared places, so `mutate -j 4` reported survivors that a serial run refused, a different set each run. The pin also brings testquality's check for tests that sleep a second or more; it finds none here. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 822d9e1..e829611 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/spin-stack/go-tools v0.0.0-20261025134518-0ee222410bc5 + github.com/spin-stack/go-tools v0.0.0-20261026011834-5fc0868cb561 github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f github.com/testcontainers/testcontainers-go v0.44.0 go.uber.org/mock v0.6.0 diff --git a/go.sum b/go.sum index 9e099ef..112ae85 100644 --- a/go.sum +++ b/go.sum @@ -309,8 +309,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/spin-stack/go-tools v0.0.0-20261025134518-0ee222410bc5 h1:/HuzuukMLQSVOvGKvgmbB2cv4Xx/nj9T048vykOwlOE= -github.com/spin-stack/go-tools v0.0.0-20261025134518-0ee222410bc5/go.mod h1:AjnMnKNXxvNt1n0zTkHpABmBd+NnNBqdRMSwmYu885Y= +github.com/spin-stack/go-tools v0.0.0-20261026011834-5fc0868cb561 h1:g4acS+rNKMfVXLtf6QeDyiqrwPOfc+EdwiFmO0InF3Y= +github.com/spin-stack/go-tools v0.0.0-20261026011834-5fc0868cb561/go.mod h1:AjnMnKNXxvNt1n0zTkHpABmBd+NnNBqdRMSwmYu885Y= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= From 400e1f67bb46462e235416c029468002d206b85b Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 26 Oct 2026 00:29:10 -0300 Subject: [PATCH 35/37] fix(cli): reject a terminal size flag value int cannot hold givenInt parsed --terminal-columns/--terminal-rows as int64 and converted to int, so on a 32-bit platform 2147483648 became -2147483648 and was taken as given (code scanning: incorrect conversion between integer types). It now parses at int's size and fails on such a value. The new case in TestGivenIntParsesLikeIntVar fails before the fix under GOARCH=386; on 64-bit platforms nothing changes. Co-Authored-By: Claude Opus 5.5 --- internal/cli/validate.go | 4 +++- internal/cli/validate_repo_held_test.go | 12 +++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/cli/validate.go b/internal/cli/validate.go index eaf058c..dc0c0a7 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -62,7 +62,9 @@ type givenInt struct { } func (g *givenInt) Set(s string) error { - v, err := strconv.ParseInt(s, 0, 64) + // Parsed at int's size, so a value int cannot hold is an error rather + // than truncated. + v, err := strconv.ParseInt(s, 0, strconv.IntSize) if err != nil { return err } diff --git a/internal/cli/validate_repo_held_test.go b/internal/cli/validate_repo_held_test.go index 94517a3..8f55674 100644 --- a/internal/cli/validate_repo_held_test.go +++ b/internal/cli/validate_repo_held_test.go @@ -1,6 +1,10 @@ package cli -import "testing" +import ( + "math" + "strconv" + "testing" +) // An --id-label is =: a name and a value on both sides of an '='. // TS rejects a label without '=', with an empty name or with an empty value. @@ -96,6 +100,12 @@ func TestGivenIntParsesLikeIntVar(t *testing.T) { if err := bad.Set("abc"); err == nil || bad.given { t.Errorf("Set(\"abc\") = %v, givenInt %+v; want an error and not given", err, bad) } + // A value int cannot hold is an error, never truncated. + tooBig := strconv.FormatUint(uint64(math.MaxInt)+1, 10) + var big givenInt + if err := big.Set(tooBig); err == nil || big.given { + t.Errorf("Set(%q) = %v, givenInt %+v; want an error and not given", tooBig, err, big) + } } // The first flag whose value is not one of its choices is the one reported. From d832273e0a29351d7c313c139222541ce2d2207c Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 26 Oct 2026 01:09:31 -0300 Subject: [PATCH 36/37] fix(oci): read an ASCII auth host as Node.js 24 does Node.js 24's URL parser only lower-cases a host that is all ASCII; it no longer checks its xn-- labels as Punycode, so xn--a, xn-- and xn--.example are hosts where Node.js 22 rejected them. The ts-oracle golden recorded Node.js 22's answers on one machine while CI ran Node.js 24, so the oracle gate failed there. domainToASCII now lower-cases any ASCII domain, and five more golden cases pin the rule. Co-Authored-By: Claude Opus 5.5 --- .../oci/testdata/ts-oracle/authpolicy.json | 88 ++++++++++++++++++- internal/oci/whatwghost.go | 23 +---- 2 files changed, 89 insertions(+), 22 deletions(-) diff --git a/internal/oci/testdata/ts-oracle/authpolicy.json b/internal/oci/testdata/ts-oracle/authpolicy.json index 30ded83..a58f2a7 100644 --- a/internal/oci/testdata/ts-oracle/authpolicy.json +++ b/internal/oci/testdata/ts-oracle/authpolicy.json @@ -1118,7 +1118,11 @@ "registry.example=xn--a" ] ], - "error": "Invalid authority 'xn--a'." + "result": { + "registry.example": [ + "xn--a" + ] + } }, { "fn": "parseCrossOriginAuthHosts", @@ -1310,7 +1314,11 @@ "registry.example=xn--.example" ] ], - "error": "Invalid authority 'xn--.example'." + "result": { + "registry.example": [ + "xn--.example" + ] + } }, { "fn": "parseCrossOriginAuthHosts", @@ -1319,7 +1327,11 @@ "registry.example=XN--.example" ] ], - "error": "Invalid authority 'XN--.example'." + "result": { + "registry.example": [ + "xn--.example" + ] + } }, { "fn": "parseCrossOriginAuthHosts", @@ -1954,6 +1966,76 @@ [] ], "result": false + }, + { + "name": "ascii xn-- label only lower-cased: xn--zz-", + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=xn--zz-" + ] + ], + "result": { + "registry.example": [ + "xn--zz-" + ] + } + }, + { + "name": "ascii xn-- label only lower-cased: xn--Ab", + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=xn--Ab" + ] + ], + "result": { + "registry.example": [ + "xn--ab" + ] + } + }, + { + "name": "ascii xn-- label only lower-cased: a.xn--", + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=a.xn--" + ] + ], + "result": { + "registry.example": [ + "a.xn--" + ] + } + }, + { + "name": "ascii xn-- label only lower-cased: xn---", + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=xn---" + ] + ], + "result": { + "registry.example": [ + "xn---" + ] + } + }, + { + "name": "ascii xn-- label only lower-cased: xn--a.xn--b", + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=xn--a.xn--b" + ] + ], + "result": { + "registry.example": [ + "xn--a.xn--b" + ] + } } ] } diff --git a/internal/oci/whatwghost.go b/internal/oci/whatwghost.go index b8d93bc..bb54061 100644 --- a/internal/oci/whatwghost.go +++ b/internal/oci/whatwghost.go @@ -138,19 +138,13 @@ var whatwgIDNA = idna.New( idna.VerifyDNSLength(false), ) -// domainToASCII is WHATWG domain to ASCII (beStrict false). A pure-ASCII domain -// with no "xn--" label only lower-cases, as the standard allows. +// domainToASCII is WHATWG domain to ASCII (beStrict false) as Node.js 26 (the +// version versions.yaml pins for the oracle) runs it: a pure-ASCII domain is +// only lower-cased, its "xn--" labels not checked as Punycode. func domainToASCII(domain string) (string, error) { - if isASCII(domain) && !hasPunycodeLabel(domain) { + if isASCII(domain) { return strings.ToLower(domain), nil } - // x/net/idna decodes a bare "xn--" label to an empty one; UTS #46, as - // Node's ICU implements it, fails it as an empty Punycode label. - for label := range strings.SplitSeq(domain, ".") { - if strings.EqualFold(label, "xn--") { - return "", errInvalidHost - } - } out, err := whatwgIDNA.ToASCII(domain) if err != nil || out == "" { return "", errInvalidHost @@ -167,15 +161,6 @@ func isASCII(s string) bool { return true } -func hasPunycodeLabel(domain string) bool { - for label := range strings.SplitSeq(domain, ".") { - if strings.HasPrefix(strings.ToLower(label), "xn--") { - return true - } - } - return false -} - // isForbiddenDomainCodePoint is the WHATWG forbidden domain code point set: // the forbidden host code points, C0 controls, '%' and DEL. func isForbiddenDomainCodePoint(r rune) bool { From 0365a013d88971ba94b5846b41018b7dec997a89 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 26 Oct 2026 01:09:31 -0300 Subject: [PATCH 37/37] test(parity): run the ts-oracle in the pinned node image The goldens recorded what the host's Node.js answered, so the same reference functions gave other answers on another machine (URL parsing, IDNA and Node.js error messages change between releases). The oracle now bundles and runs in node:24-bookworm-slim, pinned by digest in versions.yaml and handed in by the Taskfile, with no network: Node.js 24 is what CI installs and runs the reference on, and the newest release the reference's lockfile builds on (its nan 2.25.0 does not compile node-pty against Node.js 26). A bump that changes an answer fails the oracle gate until the goldens are refreshed. The readLockfile oracle writes its temporary folder as , so a message naming the file stays the same from run to run. TestTSOracleGoldensMatchTheReference skips without Docker or without TS_ORACLE_IMAGE, which task parity:contract sets. Co-Authored-By: Claude Opus 5.5 --- Taskfile.yml | 8 ++--- internal/devtools/command.go | 41 +++++++++++++++++++---- internal/devtools/command_test.go | 10 ++++++ internal/devtools/tsoracle_test.go | 17 +++++++--- internal/features/lockfile_oracle_test.go | 5 ++- tools/tsoracle/oracle.ts | 7 ++++ versions.yaml | 12 +++++++ 7 files changed, 84 insertions(+), 16 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 979fd69..37f7c24 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -249,16 +249,16 @@ tasks: - 'echo "Schema refreshed. Run ''task spec:compliance'' — any new spec property fails until modeled in internal/config/types.go."' testdata:ts-oracle: - desc: Rewrite every internal/**/testdata/ts-oracle/*.json with the results of the reference TypeScript functions (tools/tsoracle) + desc: Rewrite every internal/**/testdata/ts-oracle/*.json with the results of the reference TypeScript functions (tools/tsoracle), run in the node image versions.yaml pins (needs Docker) deps: [reference:compile] cmds: - - go run ./cmd/devtool ts-oracle + - go run ./cmd/devtool ts-oracle "$(go tool versions ref node)" testdata:ts-oracle:check: desc: Fail when a testdata/ts-oracle golden differs from what the reference TypeScript functions return deps: [reference:compile] cmds: - - go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1 -v + - TS_ORACLE_IMAGE="$(go tool versions ref node)" go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1 -v reference: desc: Install and compile the TypeScript parity oracle @@ -280,7 +280,7 @@ tasks: # must be in the inventory (needs reference/ checked out, so it lives here). - go test ./internal/cli -run TestOracleFlagCoverage -count=1 # The ts-oracle goldens must be what the reference's functions return. - - go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1 + - TS_ORACLE_IMAGE="$(go tool versions ref node)" go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1 - PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-contract.json PARITY_STRICT=true PARITY_LANE=contract PARITY_SKIP_DOCKER=true PARITY_SKIP_NETWORK=true go test ./internal/cli -run TestParityMatrix -count=1 -timeout 10m -v {{.CLI_ARGS}} parity:semantic: diff --git a/internal/devtools/command.go b/internal/devtools/command.go index c37d4c1..502cdd8 100644 --- a/internal/devtools/command.go +++ b/internal/devtools/command.go @@ -49,7 +49,11 @@ func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int { case "metrics": err = t.metrics(rest) case "ts-oracle": - err = t.tsOracle(".", false) + if len(rest) != 1 { + fmt.Fprintln(stderr, "usage: devtool ts-oracle ") + return 2 + } + err = t.tsOracle(".", rest[0], false) default: fmt.Fprintf(stderr, "devtool: unknown command %q\n", cmd) return 2 @@ -553,26 +557,49 @@ func (t tool) metrics(args []string) error { // tsOracle rewrites every internal/**/testdata/ts-oracle/*.json under root // with the answers the reference's own functions give to the golden's -// requests; with check it writes nothing and fails on a stale golden. +// requests; with check it writes nothing and fails on a stale golden. Node.js +// runs in image, the node image versions.yaml pins (the Taskfile hands it in), +// so the answers do not depend on the host's Node.js: URL parsing, IDNA and +// JSON.parse messages change between releases. // // mutate-lane: TestTSOracleGoldensMatchTheReference -func (t tool) tsOracle(root string, check bool) error { +func (t tool) tsOracle(root, image string, check bool) error { + root, err := filepath.Abs(root) + if err != nil { + return err + } + // The reference may be a symlink out of the repository; esbuild reads it + // through its real path, so that path is mounted too. + reference, err := filepath.EvalSymlinks(filepath.Join(root, "reference")) + if err != nil { + return err + } dir, err := os.MkdirTemp("", "ts-oracle-") if err != nil { return err } defer os.RemoveAll(dir) + node := func(mounts []string, args ...string) *exec.Cmd { + run := []string{"run", "--rm", "-i", "--network=none", "-u", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())} + for _, m := range mounts { + run = append(run, "-v", m) + } + return exec.Command("docker", append(append(run, image, "node"), args...)...) + } bundle := filepath.Join(dir, "oracle.js") - if err := t.run("node", filepath.Join(root, "tools/tsoracle/build.mjs"), filepath.Join(root, "reference"), bundle); err != nil { - return fmt.Errorf("bundle tools/tsoracle/oracle.ts: %w", err) + build := node([]string{root + ":" + root + ":ro", reference + ":" + reference + ":ro", dir + ":" + dir}, + filepath.Join(root, "tools/tsoracle/build.mjs"), reference, bundle) + build.Stdout, build.Stderr = t.out, t.errw + if err := build.Run(); err != nil { + return fmt.Errorf("bundle tools/tsoracle/oracle.ts in %s: %w", image, err) } return refreshTSOracleFiles(root, func(request []byte) ([]byte, error) { - cmd := exec.Command("node", bundle) + cmd := node([]string{dir + ":" + dir + ":ro"}, bundle) cmd.Stdin = bytes.NewReader(request) cmd.Stderr = t.errw out, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("run the oracle: %w", err) + return nil, fmt.Errorf("run the oracle in %s: %w", image, err) } return out, nil }, check, t.errw) diff --git a/internal/devtools/command_test.go b/internal/devtools/command_test.go index ef4e96a..3afcc5a 100644 --- a/internal/devtools/command_test.go +++ b/internal/devtools/command_test.go @@ -121,6 +121,16 @@ func TestMainUsageErrors(t *testing.T) { if code != 1 || out != "" || errOut != "devtool coverage-report: usage: coverage-report [title]\n" { t.Errorf("a command's error: code=%d stdout=%q stderr=%q", code, out, errOut) } + // ts-oracle takes the node image, the one argument the Taskfile hands in. + for _, args := range [][]string{{"ts-oracle"}, {"ts-oracle", "a", "b"}} { + if code, out, errOut := runMain(t, "", args...); code != 2 || out != "" || errOut != "usage: devtool ts-oracle \n" { + t.Errorf("%q: code=%d stdout=%q stderr=%q", args, code, out, errOut) + } + } + // With its image it runs, and here, with no reference/ to bundle, fails. + if code, _, errOut := runMain(t, "", "ts-oracle", "node:test"); code != 1 || !strings.Contains(errOut, "reference") { + t.Errorf("ts-oracle with an image: code=%d stderr=%q, want the missing reference/", code, errOut) + } } func TestParityAffectedReadsFilesFromStdin(t *testing.T) { diff --git a/internal/devtools/tsoracle_test.go b/internal/devtools/tsoracle_test.go index f157b1e..50c1f6b 100644 --- a/internal/devtools/tsoracle_test.go +++ b/internal/devtools/tsoracle_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "os" + "os/exec" "path/filepath" "slices" "strings" @@ -217,16 +218,24 @@ func TestRefreshTSOracleFiles(t *testing.T) { } } -// The recorded goldens are what the reference's functions return today. It -// runs where `task reference` has installed the reference (the parity CI job, -// through task parity:contract). +// The recorded goldens are what the reference's functions return today, run in +// the node image versions.yaml pins, which task parity:contract hands in as +// TS_ORACLE_IMAGE. It runs where `task reference` has installed the reference +// and Docker is reachable (the parity CI job). func TestTSOracleGoldensMatchTheReference(t *testing.T) { const root = "../.." + image := os.Getenv("TS_ORACLE_IMAGE") + if image == "" { + t.Skip("TS_ORACLE_IMAGE not set; run it through task parity:contract or task testdata:ts-oracle:check") + } if _, err := os.Stat(filepath.Join(root, "reference/node_modules/esbuild")); err != nil { t.Skipf("reference not installed (%v); run `task reference`", err) } + if err := exec.Command("docker", "info").Run(); err != nil { + t.Skipf("docker not reachable (%v); the oracle runs in the pinned node image", err) + } var out strings.Builder - if err := (tool{out: &out, errw: &out}).tsOracle(root, true); err != nil { + if err := (tool{out: &out, errw: &out}).tsOracle(root, image, true); err != nil { t.Fatalf("%v\n%s", err, out.String()) } } diff --git a/internal/features/lockfile_oracle_test.go b/internal/features/lockfile_oracle_test.go index 9d47a35..d59d135 100644 --- a/internal/features/lockfile_oracle_test.go +++ b/internal/features/lockfile_oracle_test.go @@ -3,8 +3,10 @@ package features import ( "encoding/base64" "encoding/json" + "errors" "os" "path/filepath" + "strings" "testing" "github.com/devcontainers/cli/internal/tsoracle/oracletest" @@ -44,7 +46,8 @@ func TestTSOracleReadLockfile(t *testing.T) { lockfile, initLockfile, err := ReadLockfile(filepath.Join(dir, "devcontainer.json")) switch { case err != nil: - return nil, err + // The oracle writes its temporary folder as . + return nil, errors.New(strings.ReplaceAll(err.Error(), dir, "")) case initLockfile: return "init", nil case lockfile != nil: diff --git a/tools/tsoracle/oracle.ts b/tools/tsoracle/oracle.ts index 4a4a5c1..a6c151e 100644 --- a/tools/tsoracle/oracle.ts +++ b/tools/tsoracle/oracle.ts @@ -117,6 +117,13 @@ const fns: Record any> = { } const { lockfile, initLockfile } = await readLockfile({ configFilePath: URI.file(path.join(dir, 'devcontainer.json')) } as any); return initLockfile ? 'init' : lockfile ? 'lockfile' : 'none'; + } catch (err: any) { + // The temporary folder is in the golden, so a message that names + // the file stays the same from run to run. + if (typeof err?.message === 'string') { + err.message = err.message.split(dir).join(''); + } + throw err; } finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/versions.yaml b/versions.yaml index 9a4683c..5ad12f4 100644 --- a/versions.yaml +++ b/versions.yaml @@ -13,6 +13,18 @@ task lint, with this repository's .golangci.yml. go-tools' version, so the spin-stack repositories are measured by the same linter; the image's Go must be at least go.mod's. +- name: node + kind: image + source: node + version: 24-bookworm-slim + pin: sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 + track: digest + note: >- + The Node.js the ts-oracle runs the reference's functions in (task testdata:ts-oracle, + TestTSOracleGoldensMatchTheReference), so a golden does not depend on the host's Node: URL + parsing, IDNA and JSON.parse messages change between Node releases. A bump that changes an + answer fails the oracle gate until the goldens are refreshed and the Go ports follow. + - name: distroless kind: image source: gcr.io/distroless/static