diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c8dfd..b0e5167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/up_compose_reattach.go b/up_compose_reattach.go index 56e8f82..19eb7e0 100644 --- a/up_compose_reattach.go +++ b/up_compose_reattach.go @@ -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 @@ -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) diff --git a/up_compose_reattach_test.go b/up_compose_reattach_test.go index 67d240c..b055a6e 100644 --- a/up_compose_reattach_test.go +++ b/up_compose_reattach_test.go @@ -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 {