blob: 69aea07b28e8d8a8e6c3eef43911df924e21aefd [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package hashfs_test
import (
"bytes"
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"go.chromium.org/build/hashigo/digest"
"go.chromium.org/build/siso/hashfs"
"go.chromium.org/build/siso/path"
)
// TestState_RemoveAllFailureKeepsEntry verifies a failed disk removal keeps
// the generated file's hashfs entry. The file then stays in saved state and
// in PreviouslyGeneratedFiles, so the next build's clean-dead retries the
// removal instead of leaving the dead output behind forever.
func TestState_RemoveAllFailureKeepsEntry(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("directory permission bits do not block removal on windows")
}
if os.Getuid() == 0 {
t.Skip("running as root; permission bits are not enforced")
}
ctx := t.Context()
dir := t.TempDir()
dir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
hfs, err := hashfs.New(ctx, hashfs.Option{})
if err != nil {
t.Fatal(err)
}
defer hfs.Close(ctx)
if err := hfs.WaitReady(ctx); err != nil {
t.Fatal(err)
}
cmdhash := []byte("dummy-cmdhash")
if err := hfs.WriteFile(ctx, dir, "gen/dead", []byte("dead"), false, time.Now(), cmdhash, nil); err != nil {
t.Fatalf("WriteFile=%v", err)
}
if err := hfs.Flush(ctx, dir, []path.Path{"gen/dead"}); err != nil {
t.Fatalf("Flush=%v", err)
}
// Strip write permission from the parent so unlinking gen/dead fails,
// like a sharing violation or permission error during clean-dead.
genDir := filepath.Join(dir, "gen")
if err := os.Chmod(genDir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chmod(genDir, 0o755); err != nil {
t.Error(err)
}
})
if err := hfs.RemoveAll(ctx, dir, "gen/dead"); err == nil {
t.Fatal("RemoveAll=nil; want error (test setup failed to block the removal)")
}
if _, err := os.Lstat(filepath.Join(dir, "gen/dead")); err != nil {
t.Fatalf("Lstat(gen/dead)=%v; want file left on disk", err)
}
m := hashfs.StateMap(digest.SHA256, hfs.State(ctx))
ent, ok := m[filepath.ToSlash(filepath.Join(dir, "gen/dead"))]
if !ok {
t.Fatal("gen/dead missing from state after failed RemoveAll; clean-dead will never retry removing it")
}
if !bytes.Equal(ent.CmdHash, cmdhash) {
t.Errorf("gen/dead cmdhash = %q; want %q so it stays in PreviouslyGeneratedFiles", ent.CmdHash, cmdhash)
}
}
// TestState_RemoveAllPartialFailureDropsRemovedChildren covers a RemoveAll
// that deletes some members of a directory before failing on another (e.g. a
// sibling locked by a scanner on Windows). The root entry must stay recorded
// so clean-dead retries the removal; entries for members proven gone from
// disk must be dropped, or a stale entry satisfies Stat for a missing path.
func TestState_RemoveAllPartialFailureDropsRemovedChildren(t *testing.T) {
if runtime.GOOS != "windows" && os.Getuid() == 0 {
t.Skip("running as root; permission bits are not enforced")
}
ctx := t.Context()
dir := t.TempDir()
dir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
hfs, err := hashfs.New(ctx, hashfs.Option{})
if err != nil {
t.Fatal(err)
}
defer hfs.Close(ctx)
if err := hfs.WaitReady(ctx); err != nil {
t.Fatal(err)
}
cmdhash := []byte("dummy-cmdhash")
if err := hfs.Mkdir(ctx, dir, "gen/dead", cmdhash, nil); err != nil {
t.Fatalf("Mkdir=%v", err)
}
for _, f := range []path.Path{"gen/dead/a", "gen/dead/sub/f"} {
if err := hfs.WriteFile(ctx, dir, f, []byte("dead"), false, time.Now(), cmdhash, nil); err != nil {
t.Fatalf("WriteFile(%q)=%v", f, err)
}
}
if err := hfs.Flush(ctx, dir, []path.Path{"gen/dead/a", "gen/dead/sub/f"}); err != nil {
t.Fatalf("Flush=%v", err)
}
// Block removal of gen/dead/sub/f while gen/dead/a stays deletable.
if runtime.GOOS == "windows" {
// An open handle without FILE_SHARE_DELETE makes unlinking fail with a
// sharing violation, the common cause in the wild.
fh, err := os.Open(filepath.Join(dir, "gen/dead/sub/f"))
if err != nil {
t.Fatal(err)
}
defer fh.Close()
} else {
subDir := filepath.Join(dir, "gen/dead/sub")
if err := os.Chmod(subDir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chmod(subDir, 0o755); err != nil {
t.Error(err)
}
})
}
if err := hfs.RemoveAll(ctx, dir, "gen/dead"); err == nil {
t.Fatal("RemoveAll=nil; want error (test setup failed to block the removal)")
}
// os.RemoveAll continues past the member it cannot delete, so gen/dead/a
// must be gone from disk while gen/dead/sub/f survived.
if _, err := os.Lstat(filepath.Join(dir, "gen/dead/a")); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("Lstat(gen/dead/a)=%v; want ErrNotExist (test setup: RemoveAll did not delete it)", err)
}
if _, err := os.Lstat(filepath.Join(dir, "gen/dead/sub/f")); err != nil {
t.Fatalf("Lstat(gen/dead/sub/f)=%v; want blocked member left on disk", err)
}
// The deleted member must not be statable from a stale entry.
if _, err := hfs.Stat(ctx, dir, "gen/dead/a"); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Stat(gen/dead/a)=%v; want ErrNotExist (deleted from disk by RemoveAll)", err)
}
// The root and the surviving member stay recorded so the next build's
// clean-dead retries the removal.
m := hashfs.StateMap(digest.SHA256, hfs.State(ctx))
if _, ok := m[filepath.ToSlash(filepath.Join(dir, "gen/dead"))]; !ok {
t.Error("gen/dead missing from state after failed RemoveAll; clean-dead will never retry removing it")
}
if _, ok := m[filepath.ToSlash(filepath.Join(dir, "gen/dead/sub/f"))]; !ok {
t.Error("gen/dead/sub/f missing from state; it is still on disk and must stay recorded")
}
if _, ok := m[filepath.ToSlash(filepath.Join(dir, "gen/dead/a"))]; ok {
t.Error("gen/dead/a still in state; its stale entry must be dropped after RemoveAll deleted it from disk")
}
}