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/docs/DIVERGENCES.md b/docs/DIVERGENCES.md index a2235e6..6df3000 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 @@ -72,6 +80,19 @@ 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`). +- **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 @@ -81,6 +102,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 @@ -135,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/go-only-features.md b/docs/go-only-features.md index 1c7c190..c76dfda 100644 --- a/docs/go-only-features.md +++ b/docs/go-only-features.md @@ -166,6 +166,12 @@ 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. 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/docs/migration.md b/docs/migration.md index 806594c..2b32a9e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -86,6 +86,33 @@ 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` + 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`. +- `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:`. ## Rollback diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index d55d6da..b242bad 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 @@ -297,6 +310,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 @@ -467,6 +493,58 @@ 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: 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 @@ -702,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 @@ -829,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 @@ -880,6 +960,463 @@ 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: 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: 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: 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: 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" + key_order: [""] + 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: build.frozen-lockfile-missing-failure + lock: feature-content-image + 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 + lock: feature-content-image + 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 + lock: feature-content-image + 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: 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 + lock: feature-content-image + 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, 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. 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 + 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: 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: 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: 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: build.lockfile-written-by-default-success + lock: feature-content-image + 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 + lock: feature-content-image + 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 + lock: feature-content-image + 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 + lock: feature-content-image + 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 + lock: feature-content-image + 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 + lock: feature-content-image + 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 + lock: feature-content-image + 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: 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 @@ -1762,6 +2299,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 @@ -1786,6 +2324,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 @@ -2478,6 +3017,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/go.mod b/go.mod index 0e5d44b..e829611 100644 --- a/go.mod +++ b/go.mod @@ -15,12 +15,14 @@ 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 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 ) @@ -172,8 +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/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/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= diff --git a/internal/cli/build.go b/internal/cli/build.go index 32953d0..358a1ca 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -2,9 +2,9 @@ package cli import ( "context" - "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "slices" @@ -140,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) @@ -222,9 +224,32 @@ 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), - }) + }, buildResultKeys...) +} + +// 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 +} + +// 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 @@ -290,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(). @@ -300,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 { @@ -359,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 @@ -380,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) @@ -714,8 +757,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 := stringifyJSON(orderedObject{data, order}, "") + if err != nil { + return fmt.Errorf("marshal result: %w", err) + } fmt.Fprintln(out.Stdout(), string(b)) return nil } @@ -739,18 +787,26 @@ 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)} } // 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) } + // 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.", 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 + "."} + } return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: fmt.Sprintf("%s: %v", description, err)}) } @@ -810,7 +866,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, @@ -904,7 +959,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/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/cache_key.go b/internal/cli/cache_key.go index cae8bec..0d71e29 100644 --- a/internal/cli/cache_key.go +++ b/internal/cli/cache_key.go @@ -4,8 +4,8 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "os" - "path/filepath" "github.com/devcontainers/cli/internal/config" "github.com/devcontainers/cli/internal/features" @@ -47,19 +47,32 @@ 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 content, rerr := os.ReadFile(filepath.Join(configDir, df)); rerr == nil { - in.Dockerfile = string(content) + 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(dockerfileConfigPath(cfg)) + 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 - // 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..983946c 100644 --- a/internal/cli/cache_key_repo_held_test.go +++ b/internal/cli/cache_key_repo_held_test.go @@ -3,7 +3,10 @@ package cli import ( "os" "path/filepath" + "strings" "testing" + + "github.com/devcontainers/cli/internal/config" ) // A committed features lockfile pins resolved digests, so changing it changes @@ -60,3 +63,50 @@ 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 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) + } +} + +// 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) + } +} + +// 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/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/compose_up.go b/internal/cli/compose_up.go index f6c41f7..d7bdd34 100644 --- a/internal/cli/compose_up.go +++ b/internal/cli/compose_up.go @@ -299,9 +299,17 @@ 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 + 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 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 { @@ -313,9 +321,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 6c7a40b..f05ebb5 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" @@ -604,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) @@ -618,11 +622,16 @@ 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) } - 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) { @@ -632,15 +641,18 @@ 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 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) } }) t.Run("frozen lockfile out of date", func(t *testing.T) { @@ -652,15 +664,33 @@ 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) + 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) + } } - if got := readFile(t, lock); got != `{"features":{"stale":{}}}` { - 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) { 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/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() 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/feature_install.go b/internal/cli/feature_install.go index 50fd843..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."). @@ -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 @@ -647,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 { @@ -670,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 c50dbcc..a0e953d 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}, @@ -718,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) } - // Without a configuration there is no lockfile to write. - if res := (extendRun{fbOpts: FeatureBuildOptions{Lockfile: true}}).run(t); res.err != nil { + }) + 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) + } + }) + 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 { @@ -762,6 +798,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/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_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/cli/features_resolve_deps.go b/internal/cli/features_resolve_deps.go index f1f7dbd..9e55eb4 100644 --- a/internal/cli/features_resolve_deps.go +++ b/internal/cli/features_resolve_deps.go @@ -1,7 +1,7 @@ package cli import ( - "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -51,9 +51,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) @@ -96,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 }, @@ -107,3 +113,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) + } + }) + } +} 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/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/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/outdated.go b/internal/cli/outdated.go index 903965c..7470122 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" @@ -48,6 +50,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 +68,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 +84,8 @@ type outdatedOpts struct { logFormat string logFile string terminalLogFile string + terminalColumns givenInt + terminalRows givenInt } type outdatedEntry struct { @@ -181,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) @@ -222,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 @@ -449,13 +462,18 @@ 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 } - 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 } @@ -538,3 +556,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) + } + } +} 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/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) diff --git a/internal/cli/parity_matrix_test.go b/internal/cli/parity_matrix_test.go index f1912fc..07b65c3 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 { @@ -441,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) @@ -449,6 +464,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) @@ -669,7 +700,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 +876,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 @@ -1022,6 +1053,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/pure_helpers_test.go b/internal/cli/pure_helpers_test.go index f0aaf8c..a857bfb 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,10 +277,27 @@ 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) } + // 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/cli/read_configuration.go b/internal/cli/read_configuration.go index b792049..5f4eca5 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") @@ -159,13 +159,13 @@ 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 } 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) @@ -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 := stringifyJSON(orderedObject{output, readConfigurationKeys}, "") if err != nil { return fmt.Errorf("marshal output: %w", err) } @@ -292,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 { @@ -312,18 +312,34 @@ 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" } - 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 @@ -391,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 82738ed..73653ea 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) } } } @@ -218,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 @@ -229,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}, @@ -242,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{}} @@ -399,21 +418,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) @@ -444,6 +471,61 @@ 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" || + 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) + } +} + +// --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 { diff --git a/internal/cli/result_order.go b/internal/cli/result_order.go new file mode 100644 index 0000000..0142ed4 --- /dev/null +++ b/internal/cli/result_order.go @@ -0,0 +1,117 @@ +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"} + // 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 + // 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 5b0e5ef..b5f12ba 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 @@ -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/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 bb7a1ea..7e64d24 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" @@ -35,8 +36,8 @@ type setUpOpts struct { remoteEnvs []string defaultUserEnvProbe string containerSessionDataFolder string - terminalColumns int - terminalRows int + terminalColumns givenInt + terminalRows givenInt } func newSetUpCmd() *cobra.Command { @@ -69,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 @@ -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,76 @@ 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) + orderConfigurations(result, loaded) + return writeSuccessJSON(out, result, setUpResultKeys...) } -// 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/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 }, diff --git a/internal/cli/up.go b/internal/cli/up.go index 5c65da7..7bbe661 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 @@ -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 { @@ -172,8 +175,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 @@ -259,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 @@ -460,11 +464,12 @@ 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) - return writeSuccessJSON(out, result) + return writeSuccessJSON(out, result, upResultKeys...) } // imageLabels are the labels of the image ref names, nil when there is no ref @@ -512,6 +517,27 @@ 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) 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 { + readDocument := upConfigFileFromArgument(opts) && (opts.configPath == "" || opts.overrideConfig == "") + return readDocument || 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. @@ -519,7 +545,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 } @@ -544,14 +570,10 @@ func addUpConfiguration(result map[string]any, opts *upOpts, cfg *config.DevCont } if cfp, ok := subMap["configFilePath"].(string); ok && cfp != "" { scheme := "vscode-fileHost" - if opts.configPath != "" || opts.overrideConfig != "" { + if upConfigFileFromArgument(opts) { scheme = "file" } - subMap["configFilePath"] = map[string]any{ - "$mid": 1, - "path": cfp, - "scheme": scheme, - } + subMap["configFilePath"] = configFileURI(cfp, scheme, fsPathRead) } else { delete(subMap, "configFilePath") } @@ -615,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) @@ -635,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 @@ -672,6 +693,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) @@ -721,6 +743,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) @@ -1354,7 +1377,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/cli/up_held_test.go b/internal/cli/up_held_test.go index 2686137..af98648 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) @@ -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 @@ -167,6 +168,53 @@ 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, 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 { + 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 +266,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) } diff --git a/internal/cli/validate.go b/internal/cli/validate.go index 4ef6ca0..dc0c0a7 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "slices" + "strconv" "strings" ) @@ -39,18 +40,42 @@ 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 { + // 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 + } + 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..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. @@ -52,19 +56,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 +81,33 @@ 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) + } + // 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. func TestValidateEnums(t *testing.T) { if err := validateEnums(enumFlag{"log-level", "debug", logLevels}, enumFlag{"log-format", "json", logFormats}); err != nil { 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/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 3b53c76..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 } @@ -353,10 +361,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 +398,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 +424,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 } diff --git a/internal/config/loader_object_held_test.go b/internal/config/loader_object_held_test.go index ddeb22a..3f9d0d4 100644 --- a/internal/config/loader_object_held_test.go +++ b/internal/config/loader_object_held_test.go @@ -4,6 +4,8 @@ import ( "errors" "os" "path/filepath" + "reflect" + "strings" "testing" ) @@ -40,3 +42,68 @@ 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) + } + } +} + +// 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) + } + } +} 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/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/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/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 diff --git a/internal/features/lockfile.go b/internal/features/lockfile.go index 45a016c..e13673a 100644 --- a/internal/features/lockfile.go +++ b/internal/features/lockfile.go @@ -6,11 +6,18 @@ 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" ) // Lockfile represents a devcontainer-lock.json. @@ -18,6 +25,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,17 +105,21 @@ 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 } -// 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) @@ -82,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) @@ -97,16 +149,208 @@ func ReadLockfile(configPath string) (*Lockfile, bool, error) { return &lf, false, nil } +// 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 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. +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. 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) @@ -116,14 +360,12 @@ 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 } - - // If no old lockfile and not explicitly enabled, skip - if !opts.Force && errors.Is(readErr, os.ErrNotExist) { - return nil + if opts.Frozen && missing { + return ErrLockfileMissing } // TS compares the old lockfile through JSON.parse -> JSON.stringify, so a @@ -132,11 +374,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..80f3f09 --- /dev/null +++ b/internal/features/lockfile_frozen_test.go @@ -0,0 +1,27 @@ +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 (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) + } +} 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_oracle_test.go b/internal/features/lockfile_oracle_test.go new file mode 100644 index 0000000..d59d135 --- /dev/null +++ b/internal/features/lockfile_oracle_test.go @@ -0,0 +1,59 @@ +package features + +import ( + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "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: + // The oracle writes its temporary folder as . + return nil, errors.New(strings.ReplaceAll(err.Error(), dir, "")) + 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/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") } 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/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/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/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/internal/imagemeta/merge.go b/internal/imagemeta/merge.go index 3d5a6d1..a38ccd3 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,91 @@ 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) } - 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 // 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() + if neg && f != 0 { + f = -f + } + return f, true } func allDigits(s string) bool { 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 4a58286..2083d3c 100644 --- a/internal/imagemeta/testdata/ts-oracle/imagemeta.json +++ b/internal/imagemeta/testdata/ts-oracle/imagemeta.json @@ -765,6 +765,311 @@ ] } }, + { + "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": "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", 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) + } + } +} 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/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/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 { diff --git a/internal/oci/testdata/ts-oracle/authpolicy.json b/internal/oci/testdata/ts-oracle/authpolicy.json new file mode 100644 index 0000000..a58f2a7 --- /dev/null +++ b/internal/oci/testdata/ts-oracle/authpolicy.json @@ -0,0 +1,2041 @@ +{ + "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" + ] + ], + "result": { + "registry.example": [ + "xn--.example" + ] + } + }, + { + "fn": "parseCrossOriginAuthHosts", + "args": [ + [ + "registry.example=XN--.example" + ] + ], + "result": { + "registry.example": [ + "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 + }, + { + "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 new file mode 100644 index 0000000..bb54061 --- /dev/null +++ b/internal/oci/whatwghost.go @@ -0,0 +1,374 @@ +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) 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) { + return strings.ToLower(domain), nil + } + 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 +} + +// 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/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) 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/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 + } + } +} 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 + } +} 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..a6c151e 100644 --- a/tools/tsoracle/oracle.ts +++ b/tools/tsoracle/oracle.ts @@ -14,12 +14,17 @@ 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'; 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 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() { } }; @@ -63,9 +68,13 @@ 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), + // 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') }; @@ -76,6 +85,49 @@ 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; + }, + // 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), + // 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'; + } 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 }); + } + }, }; async function main() { 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