Skip to content

Commit 6c697e4

Browse files
aledbfclaude
andcommitted
fix(oci,mounts,config): identity-token build auth, mount target subst, comma-path mount
Three more bugs from the adversarial edge-case review, each verified against the TS reference with a test that fails on the old code: - oci/buildauth: a token-only credential (identitytoken / OAuth refresh token, e.g. ACR) has an empty base64 auth, so the `base64Encoded == ""` guard dropped it and the build subprocess authenticated anonymously — the base-image pull then failed even though the CLI's own OCI auth worked. Skip only when there is no usable credential at all; the identitytoken branch below is now reachable. (auth entry tags gain omitempty so a token-only entry omits the empty auth.) - cli/mounts: ${devcontainerId} was substituted in an object mount's source but not its target, so `"target":"/cache/${devcontainerId}"` reached the container literal — diverging from the string form which substitutes the whole spec. - config/loader: the workspace bind mount did not quote a source/target path containing a comma, which Docker reads as a mount-option boundary (broken mount). Quote the affected segment, matching the TS srcQuote/tgtQuote guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fffa6b3 commit 6c697e4

7 files changed

Lines changed: 100 additions & 4 deletions

File tree

‎internal/cli/mounts.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ func mountFromMetadata(entry interface{}, devcontainerID string) (dockermount.Mo
108108
return docker.ParseMountSpec(spec)
109109
case map[string]interface{}:
110110
target, _ := raw["target"].(string)
111+
// ${devcontainerId} must resolve in target too, not only source — the
112+
// string form substitutes the whole spec, so the object form has to match
113+
// (e.g. target "/cache/${devcontainerId}").
114+
target = strings.ReplaceAll(target, "${devcontainerId}", devcontainerID)
111115
if target == "" {
112116
return dockermount.Mount{}, fmt.Errorf("mount requires a target/destination")
113117
}

‎internal/cli/upstream_fixes_test.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,23 @@ func TestBuildPositionalPath(t *testing.T) {
119119
t.Errorf("#8 build must accept a single positional arg: %v", err)
120120
}
121121
}
122+
123+
// TestMountFromMetadata_DevcontainerIdInTarget covers the fix: ${devcontainerId}
124+
// must resolve in an object mount's target, not only its source (the string form
125+
// already substitutes the whole spec).
126+
func TestMountFromMetadata_DevcontainerIdInTarget(t *testing.T) {
127+
m, err := mountFromMetadata(map[string]interface{}{
128+
"type": "volume",
129+
"source": "cache-${devcontainerId}",
130+
"target": "/cache/${devcontainerId}",
131+
}, "abc123")
132+
if err != nil {
133+
t.Fatal(err)
134+
}
135+
if m.Source != "cache-abc123" {
136+
t.Errorf("source = %q, want cache-abc123", m.Source)
137+
}
138+
if m.Target != "/cache/abc123" {
139+
t.Errorf("target = %q, want /cache/abc123 (was left literal)", m.Target)
140+
}
141+
}

‎internal/config/loader.go‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,19 @@ func computeWorkspaceConfig(workspace *Workspace, config *DevContainer, mountWor
324324
if runtime.GOOS != "linux" {
325325
consistency = ",consistency=consistent"
326326
}
327-
wc.WorkspaceMount = fmt.Sprintf("type=bind,source=%s,target=/workspaces/%s%s", sourceFolder, filepath.Base(sourceFolder), consistency)
327+
// A comma in the source/target path would otherwise be read by Docker as a
328+
// mount-option boundary and break the mount — quote the affected segment,
329+
// matching the TS srcQuote/tgtQuote guard.
330+
containerMountFolder := "/workspaces/" + filepath.Base(sourceFolder)
331+
srcQuote, tgtQuote := "", ""
332+
if strings.Contains(sourceFolder, ",") {
333+
srcQuote = `"`
334+
}
335+
if strings.Contains(containerMountFolder, ",") {
336+
tgtQuote = `"`
337+
}
338+
wc.WorkspaceMount = fmt.Sprintf("type=bind,%ssource=%s%s,%starget=%s%s%s",
339+
srcQuote, sourceFolder, srcQuote, tgtQuote, containerMountFolder, tgtQuote, consistency)
328340
}
329341

330342
if config.WorkspaceFolder != "" {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package config
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// TestWorkspaceMount_QuotesCommaPath covers the fix: a workspace path containing
9+
// a comma must be quoted in the bind spec, or Docker parses the comma as a mount-
10+
// option boundary and the mount breaks (matches the TS srcQuote/tgtQuote guard).
11+
func TestWorkspaceMount_QuotesCommaPath(t *testing.T) {
12+
ws := &Workspace{RootFolderPath: "/home/user/project,v2"}
13+
wc := computeWorkspaceConfig(ws, &DevContainer{}, false)
14+
if !strings.Contains(wc.WorkspaceMount, `"source=/home/user/project,v2"`) {
15+
t.Errorf("comma source not quoted: %q", wc.WorkspaceMount)
16+
}
17+
if !strings.Contains(wc.WorkspaceMount, `"target=/workspaces/project,v2"`) {
18+
t.Errorf("comma target not quoted: %q", wc.WorkspaceMount)
19+
}
20+
21+
// A normal path is NOT quoted.
22+
wc2 := computeWorkspaceConfig(&Workspace{RootFolderPath: "/home/user/project"}, &DevContainer{}, false)
23+
if strings.Contains(wc2.WorkspaceMount, `"`) {
24+
t.Errorf("plain path should not be quoted: %q", wc2.WorkspaceMount)
25+
}
26+
}

‎internal/oci/auth.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ type dockerConfigFile struct {
6464
}
6565

6666
type dockerConfigAuth struct {
67-
Auth string `json:"auth"`
68-
IdentityToken string `json:"identitytoken"`
67+
Auth string `json:"auth,omitempty"`
68+
IdentityToken string `json:"identitytoken,omitempty"`
6969
}
7070

7171
func getCredentialFromDockerConfig(env map[string]string, registry string, logger log.Logger) *credential {

‎internal/oci/buildauth.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ func ResolveBuildAuth(env map[string]string, registries []string, logger log.Log
3434
}
3535
seen[reg] = true
3636
cred := getCredential(env, reg, logger)
37-
if cred == nil || cred.base64Encoded == "" {
37+
// Skip only when there is no usable credential at all. A token-only
38+
// credential (identitytoken / OAuth refresh token, e.g. ACR) has an empty
39+
// base64Encoded but a non-empty refreshToken — the old `base64Encoded == ""`
40+
// guard dropped it, so the build subprocess authenticated anonymously and
41+
// the base-image pull failed even though the CLI's own auth worked.
42+
if cred == nil || (cred.base64Encoded == "" && cred.refreshToken == "") {
3843
continue
3944
}
4045
entry := dockerConfigAuth{Auth: cred.base64Encoded}

‎internal/oci/buildauth_test.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,32 @@ func TestResolveBuildAuthDedupesRegistries(t *testing.T) {
8989
t.Fatalf("want 1 deduped auth, got %d", len(auths))
9090
}
9191
}
92+
93+
// TestResolveBuildAuth_IdentityToken covers the fix for token-only credentials
94+
// (ACR / OAuth refresh token): they carry an identitytoken but no base64 auth, and
95+
// were dropped by the old guard, leaving the build subprocess unauthenticated.
96+
func TestResolveBuildAuth_IdentityToken(t *testing.T) {
97+
cfgDir := t.TempDir()
98+
cfg := `{"auths":{"myacr.azurecr.io":{"identitytoken":"eyJTOKEN"}}}`
99+
if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(cfg), 0o600); err != nil {
100+
t.Fatal(err)
101+
}
102+
103+
env := map[string]string{"DOCKER_CONFIG": cfgDir}
104+
dir, cleanup, ok, err := ResolveBuildAuth(env, []string{"myacr.azurecr.io"}, log.Null)
105+
if err != nil || !ok {
106+
t.Fatalf("ok=%v err=%v — a token-only credential must still produce an auth entry", ok, err)
107+
}
108+
defer cleanup()
109+
110+
entry, present := readConfigAuths(t, dir)["myacr.azurecr.io"]
111+
if !present {
112+
t.Fatal("identity-token registry missing from the build auth config")
113+
}
114+
if entry.IdentityToken != "eyJTOKEN" {
115+
t.Errorf("IdentityToken = %q, want eyJTOKEN", entry.IdentityToken)
116+
}
117+
if entry.Auth != "" {
118+
t.Errorf("Auth should be empty for a token-only credential, got %q", entry.Auth)
119+
}
120+
}

0 commit comments

Comments
 (0)