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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.3] - 2026-10-28

### Fixed

- **compose** — the compose inputs hash (`LabelComposeInputsHash`) skipped
symlinks in a local feature's directory, while the feature build context
copies each symlink as the content it names. Editing the target of a
symlinked file (say `install.sh` linked to a shared script outside the
feature) left the hash unchanged, so `Up` reattached to the image built
from the old content. Symlinks are now hashed as their resolved content;
a symlink to a directory fails the hash, so that workspace gets no label
and always takes the full path. Found by the DAP review bot on the
v0.5.2 bump.

## [0.5.2] - 2026-10-28

### Fixed
Expand Down Expand Up @@ -667,7 +681,8 @@ shelling out to `@devcontainers/cli`.
- `events` is doc-tagged **experimental** until v1.0.0 — type shapes may evolve
without a SemVer-major bump.

[Unreleased]: https://github.com/crunchloop/devcontainer/compare/v0.5.2...HEAD
[Unreleased]: https://github.com/crunchloop/devcontainer/compare/v0.5.3...HEAD
[0.5.3]: https://github.com/crunchloop/devcontainer/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/crunchloop/devcontainer/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/crunchloop/devcontainer/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/crunchloop/devcontainer/compare/v0.4.3...v0.5.0
Expand Down
16 changes: 12 additions & 4 deletions up_compose_reattach.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,20 @@ func (e *Engine) composeInputsHash(cfg *config.ResolvedConfig, opts UpOptions, p
return hex.EncodeToString(sum[:]), nil
}

// hashDirContent digests every regular file under root (relative path,
// mode and bytes, in lexical order), reading at most limit bytes.
// hashDirContent digests every regular file and symlink under root
// (relative path, mode and bytes, in lexical order), reading at most
// limit bytes. A symlink is hashed as the content it resolves to, the
// same way feature.GenerateBuildContext copies it into the build
// context, so editing a linked file outside the feature directory
// changes the digest. A symlink to a directory, which the build
// context copy cannot handle either, fails the hash.
func hashDirContent(root string, limit int64) (string, error) {
var paths []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsRegular() {
if d.Type().IsRegular() || d.Type()&fs.ModeSymlink != 0 {
paths = append(paths, p)
}
return nil
Expand All @@ -200,10 +205,13 @@ func hashDirContent(root string, limit int64) (string, error) {
if err != nil {
return "", err
}
fi, err := os.Stat(p)
fi, err := os.Stat(p) // follows a symlink to the content it names
if err != nil {
return "", err
}
if !fi.Mode().IsRegular() {
return "", fmt.Errorf("%s: not a regular file", rel)
}
total += fi.Size()
if total > limit {
return "", fmt.Errorf("content exceeds %d bytes", limit)
Expand Down
38 changes: 38 additions & 0 deletions up_compose_reattach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,44 @@ func TestComposeInputsHash_LocalFeatureContent(t *testing.T) {
}
}

// A local feature file that is a symlink is copied into the build
// context as the content it names, so that content is what the hash
// covers: editing the link's target outside the feature directory must
// not let a reattach keep the image built from the old content.
func TestComposeInputsHash_LocalFeatureSymlinkTarget(t *testing.T) {
dir := t.TempDir()
json := replaceOnce(t, hashBaseJSON, `"ghcr.io/devcontainers/features/docker-in-docker:2": { "moby": false }`, `"./local-feature": {}`)
featDir := filepath.Join(dir, ".devcontainer", "local-feature")
if err := os.MkdirAll(featDir, 0o755); err != nil {
t.Fatal(err)
}
shared := filepath.Join(dir, "shared-install.sh")
if err := os.WriteFile(shared, []byte("#!/bin/sh\necho one\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../../shared-install.sh", filepath.Join(featDir, "install.sh")); err != nil {
t.Fatal(err)
}
f := newHashFixture(t, dir, json, hashBaseCompose)
h1 := f.hash(t, "sha256:base")
if err := os.WriteFile(shared, []byte("#!/bin/sh\necho two\n"), 0o755); err != nil {
t.Fatal(err)
}
if h2 := f.hash(t, "sha256:base"); h1 == h2 {
t.Fatal("hash unchanged after editing the target of a symlinked install.sh")
}

// A symlink to a directory cannot be copied into the build context
// either; the hash fails (no label, so no reattach) instead of
// silently skipping it.
if err := os.Symlink(dir, filepath.Join(featDir, "loop")); err != nil {
t.Fatal(err)
}
if _, err := f.eng.composeInputsHash(f.cfg, f.opts, f.project, "sha256:base"); err == nil {
t.Fatal("hash computed over a symlink to a directory")
}
}

func TestComposeInputsHash_RequiresBaseImageID(t *testing.T) {
f := newHashFixture(t, t.TempDir(), hashBaseJSON, hashBaseCompose)
if _, err := f.eng.composeInputsHash(f.cfg, f.opts, f.project, ""); err == nil {
Expand Down
Loading