From 105320a94c2017d67b235daa7d8e6baaf76ea168 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 6 May 2026 15:55:33 -0500 Subject: [PATCH] feat(config): implement spec-compliant devcontainerId derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old SHA-256 hex-prefix derivation with the algorithm from the official devcontainer CLI: SHA-256 of JSON-serialized sorted labels → BigInt → base-32 (0-9a-v alphabet) → left-pad to 52 characters. The new ComputeDevContainerID accepts a labels map, enabling --id-label override support. DeriveDevContainerID now takes both workspace folder and config file path to construct the default labels per spec. LegacyDeriveDevContainerID is retained for backward compatibility. --- pkg/devcontainer/config.go | 2 +- pkg/devcontainer/config/substitute.go | 68 ++++++++++++---- pkg/devcontainer/config/substitute_test.go | 93 ++++++++++++++++------ 3 files changed, 121 insertions(+), 42 deletions(-) diff --git a/pkg/devcontainer/config.go b/pkg/devcontainer/config.go index 2a14c3826..d7358c5f9 100644 --- a/pkg/devcontainer/config.go +++ b/pkg/devcontainer/config.go @@ -168,7 +168,7 @@ func (r *runner) substitute( } substitutionContext := &config.SubstitutionContext{ - DevContainerID: config.DeriveDevContainerID(r.LocalWorkspaceFolder), + DevContainerID: config.DeriveDevContainerID(r.LocalWorkspaceFolder, configFile), LocalWorkspaceFolder: r.LocalWorkspaceFolder, ContainerWorkspaceFolder: containerWorkspaceFolder, Env: env, diff --git a/pkg/devcontainer/config/substitute.go b/pkg/devcontainer/config/substitute.go index 59a38670a..6d8e8c2a6 100644 --- a/pkg/devcontainer/config/substitute.go +++ b/pkg/devcontainer/config/substitute.go @@ -9,14 +9,17 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "strings" - - "github.com/devsy-org/devsy/pkg/hash" ) const ( - devContainerIDLength = 20 - containerEnvField = "containerEnv" + devContainerIDLength = 20 + specDevContainerIDWidth = 52 + containerEnvField = "containerEnv" + + LabelLocalFolder = "devcontainer.local_folder" + LabelConfigFile = "devcontainer.config_file" ) type ReplaceFunction func(match, variable string, args []string) string @@ -269,19 +272,54 @@ func ListToObject(list []string) map[string]string { return ret } -func DeriveDevContainerID(localWorkspaceFolder string) string { - h := sha256.Sum256([]byte(localWorkspaceFolder)) - return hex.EncodeToString(h[:])[:devContainerIDLength] +// ComputeDevContainerID implements the official devcontainer CLI algorithm: +// SHA-256(JSON.stringify(labels, sorted keys)) → BigInt → base-32 (0-9a-v) → left-pad to 52 chars. +func ComputeDevContainerID(labels map[string]string) string { + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + slices.Sort(keys) + + var buf strings.Builder + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyJSON, _ := json.Marshal(k) + valJSON, _ := json.Marshal(labels[k]) + buf.Write(keyJSON) + buf.WriteByte(':') + buf.Write(valJSON) + } + buf.WriteByte('}') + + h := sha256.Sum256([]byte(buf.String())) + bigInt := new(big.Int).SetBytes(h[:]) + encoded := bigInt.Text(32) + + if len(encoded) >= specDevContainerIDWidth { + return encoded[:specDevContainerIDWidth] + } + return strings.Repeat("0", specDevContainerIDWidth-len(encoded)) + encoded +} + +// DefaultIDLabels returns the default labels used for devcontainerId derivation. +func DefaultIDLabels(localWorkspaceFolder, configFilePath string) map[string]string { + return map[string]string{ + LabelLocalFolder: localWorkspaceFolder, + LabelConfigFile: configFilePath, + } } -func GetLegacyDevContainerID(labels map[string]string) string { - labelsBytes, _ := json.Marshal(labels) - hashedLabels := hash.String(string(labelsBytes)) - bigInt := big.Int{} - bigInt.SetString(hashedLabels, 16) - return bigInt.Text(32) +// DeriveDevContainerID computes the spec-compliant devcontainerId from workspace and config paths. +func DeriveDevContainerID(localWorkspaceFolder, configFilePath string) string { + return ComputeDevContainerID(DefaultIDLabels(localWorkspaceFolder, configFilePath)) } -func ResolveDevContainerID(localWorkspaceFolder string, legacyLabels map[string]string) string { - return DeriveDevContainerID(localWorkspaceFolder) +// LegacyDeriveDevContainerID is the old derivation (SHA-256 hex prefix of folder path). +func LegacyDeriveDevContainerID(localWorkspaceFolder string) string { + h := sha256.Sum256([]byte(localWorkspaceFolder)) + return hex.EncodeToString(h[:])[:devContainerIDLength] } diff --git a/pkg/devcontainer/config/substitute_test.go b/pkg/devcontainer/config/substitute_test.go index 1341d8b61..da3814f88 100644 --- a/pkg/devcontainer/config/substitute_test.go +++ b/pkg/devcontainer/config/substitute_test.go @@ -186,51 +186,92 @@ const ( testPATHKey = "PATH" ) -func TestDeriveDevContainerID(t *testing.T) { - h := sha256.Sum256([]byte(testWorkspaceFolder)) - want := hex.EncodeToString(h[:])[:devContainerIDLength] +func TestComputeDevContainerID(t *testing.T) { + labels := map[string]string{ + LabelLocalFolder: "/home/user/project", + LabelConfigFile: "/home/user/project/.devcontainer/devcontainer.json", + } - got := DeriveDevContainerID(testWorkspaceFolder) - if got != want { - t.Errorf("DeriveDevContainerID(%q) = %q, want %q", testWorkspaceFolder, got, want) + got := ComputeDevContainerID(labels) + if len(got) != specDevContainerIDWidth { + t.Errorf("ComputeDevContainerID() length = %d, want %d", len(got), specDevContainerIDWidth) } - if len(got) != devContainerIDLength { - t.Errorf("expected length %d, got %d", devContainerIDLength, len(got)) + + for _, c := range got { + valid := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'v') + if !valid { + t.Errorf("ComputeDevContainerID() contains invalid char %q in %q", string(c), got) + break + } } } -func TestDeriveDevContainerIDDeterministic(t *testing.T) { - first := DeriveDevContainerID(testWorkspaceFolder) - second := DeriveDevContainerID(testWorkspaceFolder) +func TestComputeDevContainerIDDeterministic(t *testing.T) { + labels := map[string]string{ + LabelLocalFolder: testWorkspaceFolder, + LabelConfigFile: testWorkspaceFolder + "/.devcontainer/devcontainer.json", + } + first := ComputeDevContainerID(labels) + second := ComputeDevContainerID(labels) if first != second { - t.Errorf("DeriveDevContainerID is not deterministic: %q != %q", first, second) + t.Errorf("ComputeDevContainerID is not deterministic: %q != %q", first, second) } } -func TestGetLegacyDevContainerID(t *testing.T) { +func TestComputeDevContainerIDSortedKeys(t *testing.T) { + // Keys must be sorted — order of insertion should not matter. + labels1 := map[string]string{ + "a": "1", + "b": "2", + } + labels2 := map[string]string{ + "b": "2", + "a": "1", + } + if ComputeDevContainerID(labels1) != ComputeDevContainerID(labels2) { + t.Error("ComputeDevContainerID should produce same result regardless of insertion order") + } +} + +func TestComputeDevContainerIDKnownValue(t *testing.T) { labels := map[string]string{ - "dev.containers.id": "test-workspace", + LabelLocalFolder: "/home/user/project", + LabelConfigFile: "/home/user/project/.devcontainer/devcontainer.json", } - got := GetLegacyDevContainerID(labels) - if got == "" { - t.Error("GetLegacyDevContainerID returned empty string") + got := ComputeDevContainerID(labels) + want := "0ns9efvs2cg80a2avksvk7nqv06jrab7n2918j79h49700ucligl" + if got != want { + t.Errorf("ComputeDevContainerID() = %q, want %q", got, want) } +} - again := GetLegacyDevContainerID(labels) - if got != again { - t.Errorf("GetLegacyDevContainerID is not deterministic: %q != %q", got, again) +func TestDeriveDevContainerID(t *testing.T) { + configPath := testWorkspaceFolder + "/.devcontainer/devcontainer.json" + got := DeriveDevContainerID(testWorkspaceFolder, configPath) + if len(got) != specDevContainerIDWidth { + t.Errorf("DeriveDevContainerID() length = %d, want %d", len(got), specDevContainerIDWidth) } } -func TestResolveDevContainerID(t *testing.T) { - labels := map[string]string{ - "dev.containers.id": "test-workspace", +func TestDeriveDevContainerIDDeterministic(t *testing.T) { + configPath := testWorkspaceFolder + "/.devcontainer/devcontainer.json" + first := DeriveDevContainerID(testWorkspaceFolder, configPath) + second := DeriveDevContainerID(testWorkspaceFolder, configPath) + if first != second { + t.Errorf("DeriveDevContainerID is not deterministic: %q != %q", first, second) } +} + +func TestLegacyDeriveDevContainerID(t *testing.T) { + h := sha256.Sum256([]byte(testWorkspaceFolder)) + want := hex.EncodeToString(h[:])[:devContainerIDLength] - got := ResolveDevContainerID(testWorkspaceFolder, labels) - want := DeriveDevContainerID(testWorkspaceFolder) + got := LegacyDeriveDevContainerID(testWorkspaceFolder) if got != want { - t.Errorf("ResolveDevContainerID() = %q, want %q (spec-based ID)", got, want) + t.Errorf("LegacyDeriveDevContainerID(%q) = %q, want %q", testWorkspaceFolder, got, want) + } + if len(got) != devContainerIDLength { + t.Errorf("expected length %d, got %d", devContainerIDLength, len(got)) } }