From f7b8bd77d1d43ea420f04a6c3c077ff9a2ad2335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Wed, 28 Oct 2026 18:15:03 +0000 Subject: [PATCH] fix(compose): hash symlinked local-feature content for reattach hashDirContent only walked regular files, but GenerateBuildContext copies a symlink in a local feature as the content it names. Editing the target of a symlinked install.sh outside the feature directory left the compose inputs hash unchanged, so Up reattached to the image built from the old content. Hash symlinks as their resolved content, and fail the hash (no label, full path) on a symlink to a directory, which the build context copy cannot handle either. Cut 0.5.3 in the changelog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UMTBosSzdEvXqCZ9c7VyLk --- CHANGELOG.md | 17 ++++++++++++++++- up_compose_reattach.go | 16 ++++++++++++---- up_compose_reattach_test.go | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) 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 {