M CHANGELOG.md => CHANGELOG.md +15 -0
@@ 6,6 6,20 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+## [0.2.2] — 2026-08-07
+
+### Fixed
+- `dir download` could not restore a tree containing read-only
+ directories — which the Go module cache is throughout (0555 dirs,
+ 0444 files). Extraction applied each archived mode as the entry
+ landed, so the first file written into a restored 0555 directory
+ failed with `permission denied`. Directories are now created
+ writable and their recorded mode applied once extraction finishes,
+ files are written writable and chmod'ed afterwards, and a read-only
+ file left by an earlier restore is replaced instead of erroring.
+ Caught by this repo's own CI on the first cache HIT of the new
+ `gomod/*.tar.zst` key (build #295).
+
## [0.2.1] — 2026-08-07
### Fixed
@@ 112,6 126,7 @@ with a single static Go binary.
must look in the same place.
[Unreleased]: https://git.srht.bigb.es/~bigbes/ci-cacher/log/master
+[0.2.2]: https://git.srht.bigb.es/~bigbes/ci-cacher/refs/v0.2.2
[0.2.1]: https://git.srht.bigb.es/~bigbes/ci-cacher/refs/v0.2.1
[0.2.0]: https://git.srht.bigb.es/~bigbes/ci-cacher/refs/v0.2.0
[0.1.2]: https://git.srht.bigb.es/~bigbes/ci-cacher/refs/v0.1.2
M VERSION => VERSION +1 -1
@@ 1,1 1,1 @@
-0.2.1
+0.2.2
M internal/archive/archive.go => internal/archive/archive.go +65 -3
@@ 108,6 108,13 @@ func writeOne(tw *tar.Writer, root, p string) error {
// DecodeDir extracts a tar.zst stream from r into dest. dest must exist.
// Any entry whose normalized path escapes dest is rejected.
+//
+// Read-only entries are the normal case, not an exotic one: the Go module
+// cache is 0555 directories and 0444 files throughout. So directories are
+// created writable and their recorded mode is applied at the very end,
+// and files are written writable and chmod'ed after the copy. Restoring
+// the archived mode as we go would lock the extraction out of the tree it
+// is still filling in.
func DecodeDir(r io.Reader, dest string) error {
absDest, err := filepath.Abs(dest)
if err != nil {
@@ 120,10 127,14 @@ func DecodeDir(r io.Reader, dest string) error {
defer zr.Close()
tr := tar.NewReader(zr)
+ // Directory modes, in archive order, applied once everything is in
+ // place. Deepest-first on the way back out.
+ var dirs []dirMode
+
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
- return nil
+ return restoreDirModes(dirs)
}
if err != nil {
return fmt.Errorf("tar next: %w", err)
@@ 132,16 143,18 @@ func DecodeDir(r io.Reader, dest string) error {
if err != nil {
return err
}
+ mode := fs.FileMode(hdr.Mode) & 0o7777
switch hdr.Typeflag {
case tar.TypeDir:
- if err := os.MkdirAll(target, fs.FileMode(hdr.Mode)&0o7777); err != nil {
+ if err := os.MkdirAll(target, mode|0o700); err != nil {
return fmt.Errorf("mkdir %s: %w", target, err)
}
+ dirs = append(dirs, dirMode{path: target, mode: mode})
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("mkdir parent of %s: %w", target, err)
}
- f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fs.FileMode(hdr.Mode)&0o7777)
+ f, err := createFile(target, mode)
if err != nil {
return fmt.Errorf("create %s: %w", target, err)
}
@@ 152,6 165,9 @@ func DecodeDir(r io.Reader, dest string) error {
if err := f.Close(); err != nil {
return err
}
+ if err := os.Chmod(target, mode); err != nil {
+ return fmt.Errorf("chmod %s: %w", target, err)
+ }
case tar.TypeSymlink:
_ = os.Remove(target)
if err := os.Symlink(hdr.Linkname, target); err != nil {
@@ 163,6 179,52 @@ func DecodeDir(r io.Reader, dest string) error {
}
}
+// dirMode is a directory whose archived mode is applied after extraction.
+type dirMode struct {
+ path string
+ mode fs.FileMode
+}
+
+// restoreDirModes applies the archived modes deepest-first: entries arrive
+// parent-before-child, so walking back tightens a directory only after
+// everything under it is already done.
+func restoreDirModes(dirs []dirMode) error {
+ for i := len(dirs) - 1; i >= 0; i-- {
+ if err := os.Chmod(dirs[i].path, dirs[i].mode); err != nil {
+ return fmt.Errorf("chmod %s: %w", dirs[i].path, err)
+ }
+ }
+ return nil
+}
+
+// createFile opens target for writing, forcing the owner-write bit on so a
+// read-only archived mode doesn't stop us writing the content. The caller
+// chmods to the archived mode afterwards. A read-only file left behind by
+// an earlier extraction is replaced rather than treated as an error —
+// re-restoring over a populated module cache is a normal CI situation.
+func createFile(target string, mode fs.FileMode) (*os.File, error) {
+ f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode|0o200)
+ if err == nil || !errors.Is(err, fs.ErrPermission) {
+ return f, err
+ }
+ if rmErr := os.Remove(target); rmErr != nil {
+ // The parent directory may itself be read-only; widen it and
+ // retry once before giving up with the original error.
+ parent := filepath.Dir(target)
+ st, stErr := os.Stat(parent)
+ if stErr != nil {
+ return nil, err
+ }
+ if chErr := os.Chmod(parent, st.Mode().Perm()|0o700); chErr != nil {
+ return nil, err
+ }
+ if rmErr := os.Remove(target); rmErr != nil {
+ return nil, err
+ }
+ }
+ return os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode|0o200)
+}
+
// safeJoin returns base/sub, rejecting any sub that is absolute or contains
// ".." segments that would resolve outside base. We reject rather than
// silently re-root so malicious tarballs surface as an error.
M internal/archive/archive_test.go => internal/archive/archive_test.go +110 -0
@@ 2,6 2,7 @@ package archive
import (
"bytes"
+ "io/fs"
"os"
"path/filepath"
"testing"
@@ 43,6 44,115 @@ func TestRoundTripDir(t *testing.T) {
}
}
+// The Go module cache is 0555 directories holding 0444 files, and it is
+// the single most common thing this package is pointed at. Restoring the
+// archived modes as each entry lands locks the extraction out of the tree
+// it is still filling in — CI build #295 died on exactly that.
+func TestRoundTripReadOnlyTree(t *testing.T) {
+ src := t.TempDir()
+ pkg := filepath.Join(src, "mod", "example.com", "pkg@v1.0.0")
+ mustWrite(t, filepath.Join(pkg, "go.mod"), "module example.com/pkg")
+ mustWrite(t, filepath.Join(pkg, "doc.go"), "package pkg")
+ // Tighten from the leaves up, or the chmods can't reach the children.
+ for _, p := range []string{
+ filepath.Join(pkg, "go.mod"), filepath.Join(pkg, "doc.go"),
+ } {
+ if err := os.Chmod(p, 0o444); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, d := range []string{pkg, filepath.Dir(pkg), filepath.Join(src, "mod")} {
+ if err := os.Chmod(d, 0o555); err != nil {
+ t.Fatal(err)
+ }
+ }
+ unlockAfter(t, src)
+
+ var buf bytes.Buffer
+ if err := EncodeDir(&buf, src); err != nil {
+ t.Fatalf("EncodeDir: %v", err)
+ }
+
+ dest := t.TempDir()
+ unlockAfter(t, dest)
+ if err := DecodeDir(&buf, dest); err != nil {
+ t.Fatalf("DecodeDir into a fresh dir: %v", err)
+ }
+
+ rel := "mod/example.com/pkg@v1.0.0"
+ got, err := os.ReadFile(filepath.Join(dest, rel, "doc.go"))
+ if err != nil {
+ t.Fatalf("read restored file: %v", err)
+ }
+ if string(got) != "package pkg" {
+ t.Errorf("doc.go = %q", got)
+ }
+ // The archived modes must survive, not just the bytes: `go` refuses to
+ // use a module cache it can write to.
+ assertMode(t, filepath.Join(dest, rel), 0o555)
+ assertMode(t, filepath.Join(dest, rel, "doc.go"), 0o444)
+}
+
+// Restoring over a populated, read-only cache is normal in CI: the second
+// extraction must replace the read-only files rather than fail on them.
+func TestDecodeOverExistingReadOnlyTree(t *testing.T) {
+ src := t.TempDir()
+ mustWrite(t, filepath.Join(src, "sub/f.txt"), "content")
+ if err := os.Chmod(filepath.Join(src, "sub/f.txt"), 0o444); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(filepath.Join(src, "sub"), 0o555); err != nil {
+ t.Fatal(err)
+ }
+ unlockAfter(t, src)
+
+ var first, second bytes.Buffer
+ if err := EncodeDir(&first, src); err != nil {
+ t.Fatalf("EncodeDir: %v", err)
+ }
+ second.Write(first.Bytes())
+
+ dest := t.TempDir()
+ unlockAfter(t, dest)
+ if err := DecodeDir(&first, dest); err != nil {
+ t.Fatalf("first DecodeDir: %v", err)
+ }
+ if err := DecodeDir(&second, dest); err != nil {
+ t.Fatalf("second DecodeDir over the restored tree: %v", err)
+ }
+ got, err := os.ReadFile(filepath.Join(dest, "sub/f.txt"))
+ if err != nil || string(got) != "content" {
+ t.Errorf("f.txt = %q, err=%v", got, err)
+ }
+ assertMode(t, filepath.Join(dest, "sub/f.txt"), 0o444)
+}
+
+func assertMode(t *testing.T, path string, want os.FileMode) {
+ t.Helper()
+ st, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat %s: %v", path, err)
+ }
+ if got := st.Mode().Perm(); got != want {
+ t.Errorf("%s mode = %#o, want %#o", path, got, want)
+ }
+}
+
+// unlockAfter makes a read-only tree removable again, so t.TempDir's
+// cleanup doesn't fail on the very permissions the test is about.
+func unlockAfter(t *testing.T, root string) {
+ t.Helper()
+ t.Cleanup(func() {
+ _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ _ = os.Chmod(p, 0o700)
+ return nil
+ })
+ })
+}
+
func TestDecodeRejectsPathEscape(t *testing.T) {
// Hand-craft a tarball with a "../evil" entry, then zstd it via EncodeDir
// indirection isn't feasible (EncodeDir won't emit ..). Instead, write