Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/devcontainer/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 53 additions & 15 deletions pkg/devcontainer/config/substitute.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
}
93 changes: 67 additions & 26 deletions pkg/devcontainer/config/substitute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
Loading