diff options
Diffstat (limited to 'pkg')
| -rw-r--r-- | pkg/archive.go | 411 | ||||
| -rw-r--r-- | pkg/archive_test.go | 240 | ||||
| -rw-r--r-- | pkg/clean.go | 161 | ||||
| -rw-r--r-- | pkg/clean_test.go | 293 | ||||
| -rw-r--r-- | pkg/compress.go | 151 | ||||
| -rw-r--r-- | pkg/compress_test.go | 72 | ||||
| -rw-r--r-- | pkg/exec.go | 808 | ||||
| -rw-r--r-- | pkg/exec_test.go | 670 | ||||
| -rw-r--r-- | pkg/file.go | 100 | ||||
| -rw-r--r-- | pkg/file_test.go | 56 | ||||
| -rw-r--r-- | pkg/internal/testtool/expected/expected.go | 9 | ||||
| -rw-r--r-- | pkg/internal/testtool/expected/sum_amd64.go | 11 | ||||
| -rw-r--r-- | pkg/internal/testtool/expected/sum_arm64.go | 11 | ||||
| -rw-r--r-- | pkg/internal/testtool/expected/sum_riscv64.go | 11 | ||||
| -rw-r--r-- | pkg/internal/testtool/main.go | 277 | ||||
| -rw-r--r-- | pkg/ir.go | 865 | ||||
| -rw-r--r-- | pkg/ir_test.go | 170 | ||||
| -rw-r--r-- | pkg/net.go | 109 | ||||
| -rw-r--r-- | pkg/net_test.go | 168 | ||||
| -rw-r--r-- | pkg/pkg.go | 2952 | ||||
| -rw-r--r-- | pkg/pkg_test.go | 2375 | ||||
| -rw-r--r-- | pkg/tar.go | 226 | ||||
| -rw-r--r-- | pkg/tar_test.go | 225 |
23 files changed, 10371 insertions, 0 deletions
diff --git a/pkg/archive.go b/pkg/archive.go new file mode 100644 index 00000000..ea4017d9 --- /dev/null +++ b/pkg/archive.go @@ -0,0 +1,411 @@ +package pkg + +import ( + "crypto/sha512" + "encoding/binary" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "unsafe" + + "hakurei.app/check" +) + +/* +| mode uint32 | path_sz uint32 | +| data_sz uint64 | +| path string | +| data []byte | +*/ + +// An ArchiveHeader represents a single header in an archive. +type ArchiveHeader struct { + Mode fs.FileMode // file mode bits + Path string // pathname of the file + Size uint64 // size of data segment +} + +// Writer implements sequential writing of an archive. [Writer.WriteHeader] +// begins a new file with the provided [ArchiveHeader], and then Writer can be +// treated as an [io.Writer] to supply that file's data. +// +// It is the caller's responsibility to write entries in lexical order. +type Writer struct { + // Underlying writer. + w io.Writer + // Current header. + h ArchiveHeader + // Fixed-size header segment. + buf [wordSize * 2]byte + // Current position in data segment. + n uint64 +} + +// NewWriter returns the address of a new [Writer] writing to w. +func NewWriter(w io.Writer) *Writer { return &Writer{w: w} } + +var zero [wordSize]byte + +// padSize returns the padding size for aligning sz. +func padSize[T int | uint64](sz T) T { + return (wordSize - (sz)%wordSize) % wordSize +} + +// flush concludes writing to the current file and writes padding. +func (aw *Writer) flush() error { + if aw.h.Size > aw.n { + return fmt.Errorf("missed writing %d bytes", aw.h.Size-aw.n) + } else if aw.h.Size < aw.n { + return fmt.Errorf("wrote %d bytes beyond end of file", aw.n-aw.h.Size) + } + + if psz := padSize(aw.h.Size); psz != 0 { + if _, err := aw.w.Write(zero[:psz]); err != nil { + return err + } + } + + aw.n = 0 + return nil +} + +// WriteHeader writes h and begins accepting its corresponding file. +func (aw *Writer) WriteHeader(h *ArchiveHeader) error { + if err := aw.flush(); err != nil { + return err + } + + aw.h = *h + binary.LittleEndian.PutUint32(aw.buf[:], uint32(aw.h.Mode)) + binary.LittleEndian.PutUint32(aw.buf[wordSize/2:], uint32(len(aw.h.Path))) + binary.LittleEndian.PutUint64(aw.buf[wordSize:], aw.h.Size) + if _, err := aw.w.Write(aw.buf[:]); err != nil { + return err + } else if _, err = aw.w.Write( + unsafe.Slice(unsafe.StringData(aw.h.Path), len(aw.h.Path)), + ); err != nil { + return err + } else if psz := padSize(len(aw.h.Path)); psz != 0 { + if _, err = aw.w.Write(zero[:psz]); err != nil { + return err + } + } + return nil +} + +// Write writes p to the underlying writer and records the new position. Invalid +// positions are reported by WriteHeader and Close. +func (aw *Writer) Write(p []byte) (n int, err error) { + n, err = aw.w.Write(p) + aw.n += uint64(n) + return +} + +// Close concludes writing to the archive stream. +func (aw *Writer) Close() (err error) { + err = aw.flush() + aw.w = nil + return +} + +// ErrInsecurePath is returned by [FlatEntry.Decode] if validation is requested +// and a nonlocal path is encountered in the stream. +var ErrInsecurePath = errors.New("insecure file path") + +// Reader implements sequential reading of an archive. [Reader.Next] advances to +// the next file in the archive (including the first), and then Reader can be +// treated as an [io.Reader] to access the file's data. +type Reader struct { + // Underlying reader. + r io.Reader + // Fixed-size header segment. + buf [wordSize * 2]byte + // Remaining bytes in current data segment. + n, pad uint64 +} + +// NewReader returns the address of a new [Reader] reading from r. +func NewReader(r io.Reader) *Reader { return &Reader{r: r} } + +// Next advances ar to the next entry. Remaining bytes of the current data +// segment are discarded. Advancing beyond the final entry returns [io.EOF]. +func (ar *Reader) Next() (*ArchiveHeader, error) { + if dsz := int64(ar.n + ar.pad); dsz > 0 { + if n, err := io.CopyN(io.Discard, ar.r, dsz); err != nil { + if errors.Is(err, io.EOF) && n != dsz { + err = io.ErrUnexpectedEOF + } + return nil, err + } + } + + if _, err := io.ReadFull(ar.r, ar.buf[:]); err != nil { + return nil, err + } + + h := ArchiveHeader{ + Mode: fs.FileMode(binary.LittleEndian.Uint32(ar.buf[:])), + Size: binary.LittleEndian.Uint64(ar.buf[wordSize:]), + } + pathSize := int(binary.LittleEndian.Uint32(ar.buf[wordSize/2:])) + pPathSize := alignSize(pathSize) + + buf := make([]byte, pPathSize) + if _, err := io.ReadFull(ar.r, buf); err != nil { + if errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return nil, err + } + + h.Path = unsafe.String(unsafe.SliceData(buf), pathSize) + if !filepath.IsLocal(h.Path) { + return &h, ErrInsecurePath + } + + ar.n = h.Size + ar.pad = padSize(h.Size) + return &h, nil +} + +// Read implements [io.Reader] for the data segment of the current entry. +func (ar *Reader) Read(p []byte) (n int, err error) { + if uint64(len(p)) > ar.n { + p = p[:ar.n] + } + + if len(p) > 0 { + n, err = ar.r.Read(p) + ar.n -= uint64(n) + } + + switch err { + case io.EOF: + if ar.n > 0 { + return n, io.ErrUnexpectedEOF + } + + case nil: + if ar.n == 0 { + return n, io.EOF + } + } + return +} + +// Write writes a deterministic representation of the contents of fsys to w. +// The resulting data can be hashed to produce a deterministic checksum for the +// directory. +func Write(fsys fs.FS, root string, w io.Writer) error { + aw := NewWriter(w) + if err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + var fi fs.FileInfo + fi, err = d.Info() + if err != nil { + return err + } + + h := ArchiveHeader{ + Path: path, + Mode: fi.Mode(), + } + if h.Mode.IsRegular() { + h.Size = uint64(fi.Size()) + if err = aw.WriteHeader(&h); err != nil { + return err + } + + var r fs.File + r, err = fsys.Open(path) + if err != nil { + return err + } + _, err = io.Copy(aw, r) + if _err := r.Close(); err == nil { + err = _err + } + return err + } else if h.Mode&fs.ModeSymlink != 0 { + var newpath string + if newpath, err = fs.ReadLink(fsys, path); err != nil { + return err + } + + h.Size = uint64(len(newpath)) + if err = aw.WriteHeader(&h); err != nil { + return err + } + + _, err = aw.Write(unsafe.Slice(unsafe.StringData(newpath), len(newpath))) + return err + } else if !h.Mode.IsDir() { + return InvalidFileModeError(h.Mode) + } + return aw.WriteHeader(&h) + }); err != nil { + return err + } + return aw.Close() +} + +// SumFS saves checksum of the archive of fsys to the value pointed to by buf. +func SumFS(buf *Checksum, fsys fs.FS, root string) error { + h := sha512.New384() + if err := Write(fsys, root, h); err != nil { + return err + } + h.Sum(buf[:0]) + return nil +} + +// SumDir saves checksum of the archive of directory at pathname to the value +// pointed to by buf. +func SumDir(buf *Checksum, pathname *check.Absolute) error { + return SumFS(buf, os.DirFS(pathname.String()), ".") +} + +// archiveArtifact is an [Artifact] unpacking an archive supported by [Reader] +// backed by a [FileArtifact]. +type archiveArtifact struct { + // Caller-supplied backing archive. + f Artifact +} + +var _ CuresExempt = archiveArtifact{} + +// NewArchive returns a new [Artifact] backed by the supplied [Artifact]. The +// source [Artifact] must be a [FileArtifact] and produce a stream compatible +// with [Reader]. +func NewArchive(a Artifact) Artifact { + return archiveArtifact{a} +} + +// Kind returns the hardcoded [Kind] constant. +func (archiveArtifact) Kind() Kind { return KindArchive } + +// Params is a noop. +func (archiveArtifact) Params(*IContext) {} + +func init() { + register(KindArchive, func(r *IRReader) Artifact { + a := NewArchive(r.Next()) + if _, ok := r.Finalise(); ok { + panic(ErrUnexpectedChecksum) + } + return a + }) +} + +// Inputs returns a slice containing the backing file. +func (a archiveArtifact) Inputs() []Artifact { + return []Artifact{a.f} +} + +// IsExclusive returns false: [Reader] is fully sequential. +func (archiveArtifact) IsExclusive() bool { return false } + +// Cure cures the [Artifact], producing a directory located at work. +func (a archiveArtifact) Cure(t *TContext) (err error) { + var r io.ReadCloser + if r, err = t.Open(a.f); err != nil { + return + } + + defer func() { + closeErr := r.Close() + if err == nil { + err = closeErr + } + }() + + type dirTargetPerm struct { + path string + mode fs.FileMode + } + var madeDirectories []dirTargetPerm + + if err = os.MkdirAll(t.GetWorkDir().String(), 0700); err != nil { + return + } + var root *os.Root + if root, err = os.OpenRoot(t.GetWorkDir().String()); err != nil { + return + } + defer func() { + closeErr := root.Close() + if err == nil { + err = closeErr + } + }() + + var header *ArchiveHeader + ar := NewReader(r) + for header, err = ar.Next(); err == nil; header, err = ar.Next() { + if header.Mode.IsRegular() { + var f *os.File + if f, err = root.OpenFile( + header.Path, + os.O_CREATE|os.O_EXCL|os.O_WRONLY, + header.Mode.Perm(), + ); err != nil { + return + } + if _, err = io.Copy(f, ar); err != nil { + _ = f.Close() + return + } else if err = f.Close(); err != nil { + return + } + } else if header.Mode&fs.ModeSymlink != 0 { + var p []byte + if p, err = io.ReadAll(ar); err != nil { + return + } + + if err = root.Symlink( + unsafe.String(unsafe.SliceData(p), len(p)), + header.Path, + ); err != nil { + return + } + } else if header.Mode.IsDir() { + if header.Path == "." { + continue + } + + madeDirectories = append(madeDirectories, dirTargetPerm{ + path: header.Path, + mode: header.Mode, + }) + if err = root.Mkdir(header.Path, 0700); err != nil { + return + } + } else { + return InvalidFileModeError(header.Mode) + } + } + if errors.Is(err, io.EOF) { + err = nil + } + if err == nil { + for _, e := range madeDirectories { + if err = root.Chmod(e.path, e.mode.Perm()); err != nil { + return + } + } + } else { + return + } + return +} + +// CuresExempt exempts the cheap [KindArchive] implementation often found at +// the end of a [FileArtifact] pipeline. +func (archiveArtifact) CuresExempt() {} diff --git a/pkg/archive_test.go b/pkg/archive_test.go new file mode 100644 index 00000000..44ea56a7 --- /dev/null +++ b/pkg/archive_test.go @@ -0,0 +1,240 @@ +package pkg_test + +import ( + "bytes" + "io" + "io/fs" + "maps" + "reflect" + "testing" + "testing/fstest" + "unsafe" + + "hakurei.app/check" + "hakurei.app/pkg" +) + +func TestArchive(t *testing.T) { + t.Parallel() + + type entry struct { + path string + mode fs.FileMode + data string + } + testCases := []struct { + name string + fsys fs.FS + entries []entry + sum pkg.Checksum + err error + }{ + {"bad type", fstest.MapFS{ + ".": {Mode: fs.ModeDir | 0700}, + "invalid": {Mode: fs.ModeCharDevice | 0400}, + }, nil, pkg.Checksum{}, pkg.InvalidFileModeError( + fs.ModeCharDevice | 0400, + )}, + + {"coldboot", fstest.MapFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "devices": {Mode: fs.ModeDir | 0700}, + "devices/uevent": {Mode: 0600, Data: []byte("add")}, + "devices/empty": {Mode: fs.ModeDir | 0700}, + + "devices/sub": {Mode: fs.ModeDir | 0700}, + "devices/sub/uevent": {Mode: 0600, Data: []byte("add")}, + + "block": {Mode: fs.ModeDir | 0700}, + "block/uevent": {Mode: 0600}, + }, []entry{ + {".", fs.ModeDir | 0700, ""}, + + {"block", fs.ModeDir | 0700, ""}, + {"block/uevent", 0600, ""}, + + {"devices", fs.ModeDir | 0700, ""}, + {"devices/empty", fs.ModeDir | 0700, ""}, + {"devices/sub", fs.ModeDir | 0700, ""}, + {"devices/sub/uevent", 0600, "add"}, + {"devices/uevent", 0600, "add"}, + }, pkg.MustDecode("mEy_Lf5KotThm7OwMx7yTKZh5HCCyaB41pVAvI9uDMgVQFM91iosBLYsRm8bDsX8"), nil}, + + {"empty", fstest.MapFS{ + ".": {Mode: fs.ModeDir | 0700}, + "checksum": {Mode: fs.ModeDir | 0700}, + "identifier": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }, []entry{ + {".", fs.ModeDir | 0700, ""}, + {"checksum", fs.ModeDir | 0700, ""}, + {"identifier", fs.ModeDir | 0700, ""}, + {"work", fs.ModeDir | 0700, ""}, + }, pkg.MustDecode("E4vEZKhCcL2gPZ2Tt59FS3lDng-d_2SKa2i5G_RbDfwGn6EemptFaGLPUDiOa94C"), nil}, + + {"sample directory step garbage", fstest.MapFS{ + ".": {Mode: fs.ModeDir | 0500}, + + "lib": {Mode: fs.ModeDir | 0500}, + "lib/check": {Mode: 0400}, + + "lib/pkgconfig": {Mode: fs.ModeDir | 0500}, + }, []entry{ + {".", fs.ModeDir | 0500, ""}, + + {"lib", fs.ModeDir | 0500, ""}, + {"lib/check", 0400, ""}, + + {"lib/pkgconfig", fs.ModeDir | 0500, ""}, + }, pkg.MustDecode("CUx-3hSbTWPsbMfDhgalG4Ni_GmR9TnVX8F99tY_P5GtkYvczg9RrF5zO0jX9XYT"), nil}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + t.Run("roundtrip", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := pkg.Write( + tc.fsys, + ".", + &buf, + ); !reflect.DeepEqual(err, tc.err) { + t.Fatalf("Flatten: error = %v, want %v", err, tc.err) + } else if tc.err != nil { + return + } + + r := pkg.NewReader(bytes.NewReader(buf.Bytes())) + var got []entry + for { + h, err := r.Next() + if err != nil { + if err == io.EOF { + break + } + t.Fatalf("Next: error = %v", err) + } + + var data []byte + if data, err = io.ReadAll(r); err != nil { + t.Fatalf("Read: error = %v", err) + } + + got = append(got, entry{ + path: h.Path, + mode: h.Mode, + data: unsafe.String(unsafe.SliceData(data), len(data)), + }) + } + + if !reflect.DeepEqual(got, tc.entries) { + t.Fatalf("Reader: %#v, want %#v", got, tc.entries) + } + }) + + if tc.err != nil { + return + } + + t.Run("hash", func(t *testing.T) { + t.Parallel() + + var got pkg.Checksum + if err := pkg.SumFS(&got, tc.fsys, "."); err != nil { + t.Fatalf("SumFS: error = %v", err) + } else if got != tc.sum { + t.Fatalf("SumFS: %v", &pkg.ChecksumMismatchError{ + Got: got, + Want: tc.sum, + }) + } + }) + }) + } +} + +var archiveTestdata = fstest.MapFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "devices": {Mode: fs.ModeDir | 0700}, + "devices/uevent": {Mode: 0600, Data: []byte("add")}, + "devices/empty": {Mode: fs.ModeDir | 0700}, + + "devices/sub": {Mode: fs.ModeDir | 0700}, + "devices/sub/uevent": {Mode: 0600, Data: []byte("add")}, + + "block": {Mode: fs.ModeDir | 0700}, + "block/uevent": {Mode: 0600}, +} + +func TestArchiveArtifact(t *testing.T) { + t.Parallel() + + want := maps.Clone(archiveTestdata) + want["."].Mode = fs.ModeDir | 0500 + + checkWithCache(t, []cacheTestCase{ + {"unpack", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + var buf bytes.Buffer + if err := pkg.Write(archiveTestdata, ".", &buf); err != nil { + t.Fatal(err) + } + + cureMany(t, c, []cureStep{ + {"sample", pkg.NewArchive( + pkg.NewFile("", buf.Bytes()), + ), ignorePathname, expectsFS(want), pkg.WNew, nil}, + }) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F": {Mode: fs.ModeDir | 0500}, + "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/block": {Mode: fs.ModeDir | 0700}, + "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/block/uevent": {Mode: 0600}, + "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices": {Mode: fs.ModeDir | 0700}, + "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices/empty": {Mode: fs.ModeDir | 0700}, + "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices/sub": {Mode: fs.ModeDir | 0700}, + "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices/sub/uevent": {Mode: 0600, Data: []byte("add")}, + "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices/uevent": {Mode: 0600, Data: []byte("add")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/-zXKfphyWM2Ko7VMVURaEMSuixNokFSe0xFbnwR2RtRtvMrcPGWalV0LIn45PXTY": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + }) +} + +func BenchmarkArchiveRead(b *testing.B) { + var buf bytes.Buffer + if err := pkg.Write(archiveTestdata, ".", &buf); err != nil { + b.Fatal(err) + } + testdata := buf.Bytes() + + for b.Loop() { + r := pkg.NewReader(bytes.NewReader(testdata)) + for { + _, err := r.Next() + if err != nil { + if err == io.EOF { + break + } + b.Fatal(err) + } + } + } +} + +func BenchmarkArchiveWrite(b *testing.B) { + for b.Loop() { + if err := pkg.Write(archiveTestdata, ".", io.Discard); err != nil { + b.Fatal(err) + } + } +} diff --git a/pkg/clean.go b/pkg/clean.go new file mode 100644 index 00000000..f07706cf --- /dev/null +++ b/pkg/clean.go @@ -0,0 +1,161 @@ +package pkg + +import ( + "errors" + "os" + "unique" +) + +// Clean destroys checksum backing entries without any identifier or substitute +// entry referring to it. If at least one keep [Artifact] is specified, +// identifier and substitute entries not kept alive by them are destroyed first. +func (c *Cache) Clean(dry, inputs bool, keep ...Artifact) ( + []unique.Handle[ID], + []unique.Handle[Checksum], + error, +) { + c.identMu.Lock() + defer c.identMu.Unlock() + + c.checksumMu.Lock() + defer c.checksumMu.Unlock() + + dents, err := os.ReadDir(c.base.Append(dirChecksum).String()) + if err != nil { + return nil, nil, err + } + checksums := make(map[unique.Handle[Checksum]]string, len(dents)) + var buf Checksum + for _, dent := range dents { + name := dent.Name() + if err = Decode(&buf, name); err != nil { + return nil, nil, err + } + checksums[unique.Make(buf)] = name + } + + type identPair struct { + id unique.Handle[ID] + name string + } + dents, err = os.ReadDir(c.base.Append(dirIdentifier).String()) + if err != nil { + return nil, nil, err + } + + keepIdents := make(map[unique.Handle[ID]]struct{}) + if inputs { + for _, id := range Inputs((*Collect)(&keep)) { + keepIdents[id] = struct{}{} + } + } else { + for _, a := range keep { + keepIdents[c.Ident(a)] = struct{}{} + } + } + + idents := make([]identPair, 0, len(dents)) + for _, dent := range dents { + name := dent.Name() + if err = Decode(&buf, name); err != nil { + return nil, nil, err + } + id := unique.Make(ID(buf)) + + if _, ok := keepIdents[id]; len(keep) == 0 || ok { + if err = readlinkChecksum(c.base.Append( + dirIdentifier, + name, + ), &buf); err != nil { + return nil, nil, err + } + delete(checksums, unique.Make(buf)) + continue + } + + c.msg.Verbosef( + "arranging for destruction of %s%s%s...", + c.sgrIdent, name, c.sgrRes, + ) + idents = append(idents, identPair{id, name}) + } + + destroyedIdents := make([]unique.Handle[ID], 0, len(idents)) + for _, pair := range idents { + if !dry { + if err = os.Remove(c.base.Append( + dirStatus, + pair.name, + ).String()); err != nil && !errors.Is(err, os.ErrNotExist) { + return destroyedIdents, nil, err + } + + if err = os.Remove(c.base.Append( + dirIdentifier, + pair.name, + ).String()); err != nil { + return destroyedIdents, nil, err + } + } + destroyedIdents = append(destroyedIdents, pair.id) + } + + destroyedChecksums := make([]unique.Handle[Checksum], 0, len(checksums)) + for checksum, name := range checksums { + if err = c.parent.Err(); err != nil { + return destroyedIdents, destroyedChecksums, err + } + c.msg.Verbosef( + "destroying checksum %s%s%s...", + c.sgrIdent, name, c.sgrRes, + ) + if !dry { + if err = errors.Join(removeAll(c.base.Append( + dirChecksum, + name, + ))); err != nil { + return destroyedIdents, destroyedChecksums, err + } + } + destroyedChecksums = append(destroyedChecksums, checksum) + } + + dents, err = os.ReadDir(c.base.Append(dirSubstitute).String()) + if err != nil { + return destroyedIdents, destroyedChecksums, err + } + for _, dent := range dents { + name := dent.Name() + if err = readlinkChecksum(c.base.Append( + dirSubstitute, + name, + ), &buf); err != nil { + return destroyedIdents, destroyedChecksums, err + } + if _, ok := checksums[unique.Make(buf)]; !ok { + continue + } + + c.msg.Verbosef( + "destroying substitute %s%s%s...", + c.sgrIdent, name, c.sgrRes, + ) + if !dry { + if err = os.Remove(c.base.Append( + dirStatus, + name, + ).String()); err != nil && !errors.Is(err, os.ErrNotExist) { + return destroyedIdents, nil, err + } + + if err = os.Remove(c.base.Append( + dirSubstitute, + name, + ).String()); err != nil { + return destroyedIdents, destroyedChecksums, err + } + } + } + + return destroyedIdents, destroyedChecksums, nil +} diff --git a/pkg/clean_test.go b/pkg/clean_test.go new file mode 100644 index 00000000..e31d52aa --- /dev/null +++ b/pkg/clean_test.go @@ -0,0 +1,293 @@ +package pkg_test + +import ( + "bytes" + "crypto/sha512" + "io/fs" + "log" + "os" + "slices" + "strings" + "testing" + "unique" + + "hakurei.app/message" + "hakurei.app/pkg" +) + +// formatHandles returns a user-facing string representing h. +func formatHandles[T pkg.ID | pkg.Checksum](handles ...unique.Handle[T]) string { + var buf strings.Builder + for _, h := range handles { + buf.WriteString(pkg.Encode(pkg.Checksum(h.Value()))) + buf.WriteString(", ") + } + return strings.TrimSuffix(buf.String(), ", ") +} + +func TestClean(t *testing.T) { + t.Parallel() + ic := pkg.NewIR() + + testCases := []struct { + name string + a []pkg.Artifact + keep []pkg.Artifact + inputs bool + want expectsFS + + wantIdents []unique.Handle[pkg.ID] + wantChecksums []unique.Handle[pkg.Checksum] + }{ + {"simple", []pkg.Artifact{ + pkg.NewFile("file", nil), + }, nil, false, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb": {Mode: 0400}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/h6qx7m0vsyhO6oLEJgY2rKa1c6RZuIZ4QXxZSh__NZJ9VU0MIj5bFB-KuaPcsfpE": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb")}, + + "lock": {Mode: 0644}, + "variant": {Mode: 0400}, + "status": {Mode: fs.ModeDir | 0700}, + "substitute": {Mode: fs.ModeDir | 0700}, + "fault": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }, nil, nil}, + + {"keep", []pkg.Artifact{ + pkg.NewFile("removed-file", []byte("removed file")), + }, []pkg.Artifact{ + pkg.NewFile("file", []byte("\xfd")), + }, false, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/KgZ-FjbGuU-XP2QEHInpgv-2Zn0cTH5NqFMgTU0XrSdKmSwyC-3baVs1BMCP5spk": {Mode: 0400, Data: []byte("\xfd")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/tkrOYhQfTRaArk4nNYNjTmHQUmuJMVIoDeAKIQBm3I4uuMimBKXKMIY2gGZqwQg4": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/KgZ-FjbGuU-XP2QEHInpgv-2Zn0cTH5NqFMgTU0XrSdKmSwyC-3baVs1BMCP5spk")}, + + "lock": {Mode: 0644}, + "variant": {Mode: 0400}, + "status": {Mode: fs.ModeDir | 0700}, + "substitute": {Mode: fs.ModeDir | 0700}, + "fault": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }, []unique.Handle[pkg.ID]{ + ic.Ident(pkg.NewFile("removed-file", []byte("removed file"))), + }, []unique.Handle[pkg.Checksum]{ + unique.Make(sha512.Sum384([]byte("removed file"))), + }}, + + {"inputs anchored substitute", []pkg.Artifact{ + &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("destroyed"), + deps: []pkg.Artifact{ + pkg.NewFile("destroyed-input", []byte("destroyed")), + }, + cure: func(f *pkg.FContext) error { + p := f.GetWorkDir() + if err := os.MkdirAll(p.String(), 0755); err != nil { + return err + } + return os.WriteFile(p.Append("result").String(), nil, 0444) + }, + }, + }, []pkg.Artifact{ + &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("kept"), + deps: []pkg.Artifact{ + pkg.NewFile("kept-input", []byte("kept")), + }, + cure: func(f *pkg.FContext) error { + p := f.GetWorkDir() + if err := os.MkdirAll(p.String(), 0755); err != nil { + return err + } + return os.WriteFile(p.Append("result").String(), nil, 0444) + }, + }, + }, true, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/H-eSiCo227-xdqyNl2R-5G3eqXPtbb8XegAB70I5OQb2majeZXJoCxTq9wJy5qqv": {Mode: 0400, Data: []byte("kept")}, + "checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE": {Mode: fs.ModeDir | 0500}, + "checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE/result": {Mode: 0444}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/KpxTUsUfGbsX6C6HU2j9cd9He3_tt2o2m2MgH4m0zial8lmoqSWY-Gc3KrS0k-UK": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/H-eSiCo227-xdqyNl2R-5G3eqXPtbb8XegAB70I5OQb2majeZXJoCxTq9wJy5qqv")}, + "identifier/xxQ43VMIS1WHMSEFnUdBNd6pulbtgiTorP_mtJpEsZENYhGBiDEL6Y2D8yqn0Sra": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE")}, + + "lock": {Mode: 0644}, + "variant": {Mode: 0400}, + "status": {Mode: fs.ModeDir | 0700}, + + "substitute": {Mode: fs.ModeDir | 0700}, + "substitute/dP-6_wIDRRouOaOF-nkBy-IaUbLdYHbOUTrptu7j_qfW01mnHSjYJ0oykUmvUd2x": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE")}, + "substitute/td1tf1kb3z8iUFg2k1xlHeTTEz3xR4e77WSWdv2JhjmZyY6iuvodhdT8BblDOi0E": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE")}, + + "fault": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }, []unique.Handle[pkg.ID]{ + ic.Ident(pkg.NewFile("destroyed-input", []byte("destroyed"))), + ic.Ident(&stubArtifactF{ + kind: pkg.KindExec, + params: []byte("destroyed"), + deps: []pkg.Artifact{ + pkg.NewFile("destroyed-input", []byte("destroyed")), + }, + }), + }, []unique.Handle[pkg.Checksum]{ + unique.Make(sha512.Sum384([]byte("destroyed"))), + }}, + + {"inputs", []pkg.Artifact{ + &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("destroyed"), + deps: []pkg.Artifact{ + pkg.NewFile("destroyed-input", []byte("destroyed")), + }, + cure: func(f *pkg.FContext) error { + if w, err := f.GetStatusWriter(); err != nil { + return err + } else if _, err = w.Write([]byte("destroyed")); err != nil { + return err + } + + p := f.GetWorkDir() + if err := os.MkdirAll(p.String(), 0755); err != nil { + return err + } + return os.WriteFile(p.Append("result").String(), nil, 0444) + }, + }, + }, []pkg.Artifact{ + &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("kept"), + deps: []pkg.Artifact{ + pkg.NewFile("kept-input", []byte("kept")), + }, + cure: func(f *pkg.FContext) error { + if w, err := f.GetStatusWriter(); err != nil { + return err + } else if _, err = w.Write([]byte("kept")); err != nil { + return err + } + + p := f.GetWorkDir() + if err := os.MkdirAll(p.String(), 0755); err != nil { + return err + } + return os.WriteFile(p.Append("result").String(), []byte{0}, 0444) + }, + }, + }, true, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/CyDnDvF-LaeGPcSW70tPosNCoclByWkTjznUUF1DcgzlIwkN9yzz1ZFME1TlPj6W": {Mode: fs.ModeDir | 0500}, + "checksum/CyDnDvF-LaeGPcSW70tPosNCoclByWkTjznUUF1DcgzlIwkN9yzz1ZFME1TlPj6W/result": {Mode: 0444, Data: []byte("\x00")}, + "checksum/H-eSiCo227-xdqyNl2R-5G3eqXPtbb8XegAB70I5OQb2majeZXJoCxTq9wJy5qqv": {Mode: 0400, Data: []byte("kept")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/KpxTUsUfGbsX6C6HU2j9cd9He3_tt2o2m2MgH4m0zial8lmoqSWY-Gc3KrS0k-UK": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/H-eSiCo227-xdqyNl2R-5G3eqXPtbb8XegAB70I5OQb2majeZXJoCxTq9wJy5qqv")}, + "identifier/xxQ43VMIS1WHMSEFnUdBNd6pulbtgiTorP_mtJpEsZENYhGBiDEL6Y2D8yqn0Sra": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/CyDnDvF-LaeGPcSW70tPosNCoclByWkTjznUUF1DcgzlIwkN9yzz1ZFME1TlPj6W")}, + + "lock": {Mode: 0644}, + "variant": {Mode: 0400}, + + "status": {Mode: fs.ModeDir | 0700}, + "status/xxQ43VMIS1WHMSEFnUdBNd6pulbtgiTorP_mtJpEsZENYhGBiDEL6Y2D8yqn0Sra": {Mode: 0400, Data: []byte(statusHeader + "kept")}, + "status/td1tf1kb3z8iUFg2k1xlHeTTEz3xR4e77WSWdv2JhjmZyY6iuvodhdT8BblDOi0E": {Mode: 0400, Data: []byte(statusHeader + "kept")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + "substitute/td1tf1kb3z8iUFg2k1xlHeTTEz3xR4e77WSWdv2JhjmZyY6iuvodhdT8BblDOi0E": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/CyDnDvF-LaeGPcSW70tPosNCoclByWkTjznUUF1DcgzlIwkN9yzz1ZFME1TlPj6W")}, + + "fault": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }, []unique.Handle[pkg.ID]{ + ic.Ident(pkg.NewFile("destroyed-input", []byte("destroyed"))), + ic.Ident(&stubArtifactF{ + kind: pkg.KindExec, + params: []byte("destroyed"), + deps: []pkg.Artifact{ + pkg.NewFile("destroyed-input", []byte("destroyed")), + }, + }), + }, []unique.Handle[pkg.Checksum]{ + unique.Make(expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + "result": {Mode: 0444}, + }.hash()), + unique.Make(sha512.Sum384([]byte("destroyed"))), + }}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + base := makeBase(t) + msg := message.New(log.New(os.Stderr, "clean: ", 0)) + msg.SwapVerbose(testing.Verbose()) + c, err := pkg.Open(t.Context(), msg, base, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(c.Close) + + all := pkg.Collect(slices.Concat(tc.a, tc.keep)) + if _, _, err = c.Cure(&all); !pkg.IsCollected(err) { + t.Fatal(err) + } + + var ( + idents []unique.Handle[pkg.ID] + checksums []unique.Handle[pkg.Checksum] + ) + idents, checksums, err = c.Clean(false, tc.inputs, tc.keep...) + if err != nil { + t.Fatalf("Clean: error = %v", err) + } + var buf [2]pkg.Checksum + + slices.SortFunc(idents, func(a, b unique.Handle[pkg.ID]) int { + buf[0], buf[1] = a.Value(), b.Value() + return bytes.Compare(buf[0][:], buf[1][:]) + }) + slices.SortFunc(checksums, func(a, b unique.Handle[pkg.Checksum]) int { + buf[0], buf[1] = a.Value(), b.Value() + return bytes.Compare(buf[0][:], buf[1][:]) + }) + + if !slices.Equal(idents, tc.wantIdents) { + t.Errorf( + "Clean: idents = %s, want %s", + formatHandles(idents...), formatHandles(tc.wantIdents...), + ) + } + if !slices.Equal(checksums, tc.wantChecksums) { + t.Errorf( + "Clean: checksums = %s, want %s", + formatHandles(checksums...), formatHandles(tc.wantChecksums...), + ) + } + + want := tc.want.hash() + var checksum pkg.Checksum + if err = pkg.SumDir(&checksum, base); err != nil { + t.Fatalf("SumDir: error = %v", err) + } else if checksum != want { + t.Error(expectsFrom(base.String())) + } + }) + } +} diff --git a/pkg/compress.go b/pkg/compress.go new file mode 100644 index 00000000..22f990f1 --- /dev/null +++ b/pkg/compress.go @@ -0,0 +1,151 @@ +package pkg + +import ( + "compress/bzip2" + "compress/gzip" + "fmt" + "io" + "os" + + "hakurei.app/internal/xz" + "hakurei.app/internal/zstd" +) + +const ( + // Gzip denotes a stream compressed via [gzip]. + Gzip = iota + // Bzip2 denotes a stream compressed via [bzip2]. + Bzip2 + // Zstd denotes a stream compressed via [zstd]. + Zstd + // XZ denotes a stream compressed via [xz]. + XZ +) + +// A decompressArtifact is a [FileArtifact] decompressing a backing +// [FileArtifact] stream. +type decompressArtifact struct { + // Caller-supplied backing stream. + f Artifact + // Compression on top of the stream. + compress uint32 +} + +var _ FileArtifact = new(decompressArtifact) +var _ CuresExempt = new(decompressArtifact) + +// decompressArtifactNamed embeds decompressArtifact for a [fmt.Stringer] stream. +type decompressArtifactNamed struct { + decompressArtifact + // Copied from decompressArtifact.f. + name string +} + +var _ fmt.Stringer = new(decompressArtifactNamed) + +// NewDecompress returns a [FileArtifact] decompressing the supplied [Artifact]. +func NewDecompress(a Artifact, compress uint32) FileArtifact { + da := decompressArtifact{a, compress} + if s, ok := a.(fmt.Stringer); ok { + if name := s.String(); name != "" { + return &decompressArtifactNamed{da, name} + } + } + return &da +} + +// String returns the name of the underlying [Artifact] prefixed with decompress. +func (a *decompressArtifactNamed) String() string { return "decompress-" + a.name } + +// Kind returns the hardcoded [Kind] constant. +func (a *decompressArtifact) Kind() Kind { return KindDecompress } + +// Params writes value of compression enum. +func (a *decompressArtifact) Params(ctx *IContext) { ctx.WriteUint32(a.compress) } + +func init() { + register(KindDecompress, func(r *IRReader) Artifact { + a := NewDecompress(r.Next(), r.ReadUint32()) + if _, ok := r.Finalise(); ok { + panic(ErrUnexpectedChecksum) + } + return a + }) +} + +// Inputs returns a slice containing the backing file. +func (a *decompressArtifact) Inputs() []Artifact { + return []Artifact{a.f} +} + +// IsExclusive returns false: decompressor is fully sequential. +func (a *decompressArtifact) IsExclusive() bool { return false } + +// compoundCloser is an [io.ReadCloser] with an additional [io.Closer] attached. +type compoundCloser struct { + io.ReadCloser + c io.Closer +} + +// Close closes [io.ReadCloser] and the additional [io.Closer]. It returns the +// non-nil error returned by the underlying [io.ReadCloser], otherwise it +// returns the error returned by the additional [io.Closer]. +func (c compoundCloser) Close() error { + err := c.ReadCloser.Close() + if _err := c.c.Close(); err == nil { + err = _err + } + return err +} + +// IsExecutable returns false. +func (*decompressArtifact) IsExecutable() bool { return false } + +// Cure returns a decompressor [io.ReadCloser]. +func (a *decompressArtifact) Cure(r *RContext) (io.ReadCloser, error) { + sr, err := r.Open(a.f) + if err != nil { + return nil, err + } + br := r.cache.getReaderRC(sr) + + var dr io.ReadCloser + switch a.compress { + case Gzip: + if dr, err = gzip.NewReader(br); err != nil { + _ = br.Close() + return nil, err + } + return compoundCloser{dr, br}, nil + + case Bzip2: + return struct { + io.Reader + io.Closer + }{bzip2.NewReader(br), br}, nil + + case Zstd: + return struct { + io.Reader + io.Closer + }{zstd.NewReader(br), br}, nil + + case XZ: + var _dr io.Reader + if _dr, err = xz.NewReader(br, 0); err != nil { + _ = br.Close() + return nil, err + } + return struct { + io.Reader + io.Closer + }{_dr, br}, nil + + default: + return nil, os.ErrInvalid + } +} + +// CuresExempt exempts the cheap [KindDecompress] implementation often part of +// a [FileArtifact] pipeline. +func (*decompressArtifact) CuresExempt() {} diff --git a/pkg/compress_test.go b/pkg/compress_test.go new file mode 100644 index 00000000..d737cdd7 --- /dev/null +++ b/pkg/compress_test.go @@ -0,0 +1,72 @@ +package pkg_test + +import ( + "bytes" + "compress/gzip" + "crypto/sha512" + "io/fs" + "net/http" + "testing" + "testing/fstest" + + "hakurei.app/check" + "hakurei.app/pkg" +) + +func TestDecompress(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + if _, err := gw.Write([]byte{0}); err != nil { + t.Fatal(err) + } else if err = gw.Close(); err != nil { + t.Fatal(err) + } + testdata := buf.String() + + var transport http.Transport + client := http.Client{Transport: &transport} + transport.RegisterProtocol("file", http.NewFileTransportFS(fstest.MapFS{ + "testdata": {Data: []byte(testdata), Mode: 0400}, + })) + testdataChecksum := func() pkg.Checksum { + h := sha512.New384() + h.Write([]byte(testdata)) + return (pkg.Checksum)(h.Sum(nil)) + }() + + gh := pkg.NewDecompress(pkg.NewHTTPGet( + &client, + "file:///testdata", + testdataChecksum, + ), pkg.Gzip) + + checkWithCache(t, []cacheTestCase{ + {"decompress", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + cureMany(t, c, []cureStep{ + {"close", pkg.NewDecompress(pkg.NewHTTPGet( + &client, + "file:///testdata", + pkg.Checksum{0xfd}, + ), pkg.Gzip), nil, nil, pkg.WNew, &pkg.ChecksumMismatchError{ + Got: testdataChecksum, + Want: pkg.Checksum{0xfd}, + }}, + + {"gzip", gh, ignorePathname, expectsChecksum(sha512.Sum384([]byte{0})), pkg.WNew, nil}, + }) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/" + pkg.Encode(sha512.Sum384([]byte{0})): {Mode: 0400, Data: []byte{0}}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/" + pkg.Encode(pkg.NewIR().Ident(gh).Value()): {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + }) +} diff --git a/pkg/exec.go b/pkg/exec.go new file mode 100644 index 00000000..b40ae84b --- /dev/null +++ b/pkg/exec.go @@ -0,0 +1,808 @@ +package pkg + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "sync" + "syscall" + "time" + "unique" + + "hakurei.app/check" + "hakurei.app/container" + "hakurei.app/container/seccomp" + "hakurei.app/container/std" + "hakurei.app/ext" + "hakurei.app/fhs" + "hakurei.app/message" +) + +// AbsWork is the container pathname [TContext.GetWorkDir] is mounted on. +var AbsWork = fhs.AbsRoot.Append("work/") + +const ( + // EnvJobs is the name of the environment variable holding a decimal + // representation of the preferred job count. Its value must not affect cure + // outcome. + EnvJobs = "CURE_JOBS" + // EnvLoad is the name of the environment variable holding a decimal + // representation of the preferred loadavg target. Its value must not affect + // cure outcome. + EnvLoad = "CURE_LOAD" +) + +// ExecPath is a slice of [Artifact] and the [check.Absolute] pathname to make +// it available at under in the container. +type ExecPath struct { + // Pathname in the container mount namespace. + P *check.Absolute + // Artifacts to mount on the pathname, must contain at least one [Artifact]. + // If there are multiple entries or W is true, P is set up as an overlay + // mount, and entries of A must not implement [FileArtifact]. + A []Artifact + // Whether to make the mount point writable via the temp directory. + W bool +} + +// GetArtifactFunc is the function signature of [FContext.GetArtifact]. +type GetArtifactFunc func(Artifact) (*check.Absolute, unique.Handle[Checksum]) + +// PromoteLayers returns artifacts with identical-by-content layers promoted to +// the highest priority instance, as if mounted via [ExecPath]. +func PromoteLayers( + artifacts []Artifact, + getArtifact GetArtifactFunc, + report func(i int, d Artifact), +) []*check.Absolute { + layers := make([]*check.Absolute, 0, len(artifacts)) + checksums := make(map[unique.Handle[Checksum]]struct{}, len(artifacts)) + for i := range artifacts { + d := artifacts[len(artifacts)-1-i] + pathname, checksum := getArtifact(d) + if _, ok := checksums[checksum]; ok { + report(len(artifacts)-1-i, d) + continue + } + checksums[checksum] = struct{}{} + layers = append(layers, pathname) + } + slices.Reverse(layers) + return layers +} + +// layers returns pathnames collected from A deduplicated via [PromoteLayers]. +func (p *ExecPath) layers( + msg message.Msg, + getArtifact GetArtifactFunc, + ident func(a Artifact) unique.Handle[ID], +) []*check.Absolute { + return PromoteLayers(p.A, getArtifact, func(i int, d Artifact) { + if msg.IsVerbose() { + msg.Verbosef("promoted layer %d as %s", i, reportName(d, ident(d))) + } + }) +} + +// Path returns a populated [ExecPath]. +func Path(pathname *check.Absolute, writable bool, a ...Artifact) ExecPath { + return ExecPath{pathname, a, writable} +} + +// MustPath is like [Path], but takes a string pathname via [check.MustAbs]. +func MustPath(pathname string, writable bool, a ...Artifact) ExecPath { + return ExecPath{check.MustAbs(pathname), a, writable} +} + +var ( + binfmt map[string]container.BinfmtEntry + binfmtMu sync.RWMutex +) + +// RegisterArch arranges for [KindExec] and [KindExecNet] to support a new +// architecture via a binfmt_misc entry. Each architecture must be registered +// at most once. +func RegisterArch(arch string, e container.BinfmtEntry) { + if arch == "" { + panic(UnsupportedArchError(arch)) + } + + binfmtMu.Lock() + defer binfmtMu.Unlock() + + if binfmt == nil { + binfmt = make(map[string]container.BinfmtEntry) + } + + if _, ok := binfmt[arch]; ok { + panic("attempting to register " + strconv.Quote(arch) + " twice") + } + binfmt[arch] = e +} + +const ( + // ExecTimeoutDefault replaces out of range [NewExec] timeout values. + ExecTimeoutDefault = 15 * time.Minute + // ExecTimeoutMax is the arbitrary upper bound of [NewExec] timeout. + ExecTimeoutMax = 48 * time.Hour +) + +// An execArtifact is an [Artifact] that produces output by running a program +// part of another [Artifact] in a [container] to produce its output. +// +// Methods of execArtifact does not modify any struct field or underlying arrays +// referred to by slices. +type execArtifact struct { + // Caller-supplied user-facing reporting name, guaranteed to be nonzero + // during initialisation. + name string + // Target architecture. + arch string + // Caller-supplied inner mount points. + paths []ExecPath + + // Passed through to [container.Params]. + dir *check.Absolute + // Passed through to [container.Params]. + env []string + // Passed through to [container.Params]. + path *check.Absolute + // Passed through to [container.Params]. + args []string + + // Duration the initial process is allowed to run. The zero value is + // equivalent to [ExecTimeoutDefault]. + timeout time.Duration + + // Caller-supplied exclusivity value, returned as is by IsExclusive. + exclusive bool +} + +var _ fmt.Stringer = new(execArtifact) + +// execMeasuredArtifact is like execArtifact but implements [KnownChecksum] and +// has its resulting container optionally keep the host net namespace. +type execMeasuredArtifact struct { + checksum Checksum + + // Whether to keep host net namespace. + hostNet bool + + execArtifact +} + +var _ KnownChecksum = new(execMeasuredArtifact) + +// Checksum returns the caller-supplied checksum. +func (a *execMeasuredArtifact) Checksum() Checksum { return a.checksum } + +// Kind returns [KindExecNet], or [KindExec] if hostNet is false. +func (a *execMeasuredArtifact) Kind() Kind { + if a == nil || a.hostNet { + return KindExecNet + } + return KindExec +} + +// Cure cures the [Artifact] in the container described by the caller. The +// container optionally retains host networking. +func (a *execMeasuredArtifact) Cure(f *FContext) error { + return a.cure(f, a.hostNet) +} + +// ErrNetChecksum is panicked by [NewExec] if host net namespace is requested +// with a nil checksum. +var ErrNetChecksum = errors.New("attempting to keep net namespace without checksum") + +// NewExec returns a new [Artifact] that executes the program path in a +// container with specified paths bind mounted read-only in order. A private +// instance of /proc and /dev is made available to the container. +// +// The working and temporary directories are both created and mounted writable +// on [AbsWork] and [fhs.AbsTmp] respectively. If one or more paths target +// [AbsWork], the final entry is set up as a writable overlay mount on /work for +// which the upperdir is the host side work directory. In this configuration, +// the W field is ignored, and the program must avoid causing whiteout files to +// be created. Cure fails if upperdir ends up with entries other than directory, +// regular or symlink. +// +// If checksum is non-nil, the resulting [Artifact] implements [KnownChecksum] +// and its container optionally runs in the host net namespace. +// +// The container is allowed to run for the specified duration before the initial +// process and all processes originating from it is terminated. A zero or +// negative timeout value is equivalent tp [ExecTimeoutDefault], a timeout value +// greater than [ExecTimeoutMax] is equivalent to [ExecTimeoutMax]. +// +// The user-facing name and exclusivity value are not accessible from the +// container and does not affect curing outcome. Because of this, it is omitted +// from parameter data for computing identifier. +func NewExec( + name, arch string, + checksum *Checksum, + timeout time.Duration, + hostNet, exclusive bool, + + dir *check.Absolute, + env []string, + pathname *check.Absolute, + args []string, + + paths ...ExecPath, +) Artifact { + if name == "" { + name = "exec-" + filepath.Base(pathname.String()) + } + if arch == "" { + arch = runtime.GOARCH + } + if timeout <= 0 { + timeout = ExecTimeoutDefault + } + if timeout > ExecTimeoutMax { + timeout = ExecTimeoutMax + } + a := execArtifact{name, arch, paths, dir, env, pathname, args, timeout, exclusive} + if checksum == nil { + if hostNet { + panic(ErrNetChecksum) + } + return &a + } + return &execMeasuredArtifact{*checksum, hostNet, a} +} + +// Kind returns the hardcoded [Kind] constant. +func (*execArtifact) Kind() Kind { return KindExec } + +// Params writes paths, executable pathname and args. +func (a *execArtifact) Params(ctx *IContext) { + ctx.WriteString(a.arch) + ctx.WriteString(a.name) + + ctx.WriteUint32(uint32(len(a.paths))) + for _, p := range a.paths { + if p.P != nil { + ctx.WriteString(p.P.String()) + } else { + ctx.WriteString("invalid P\x00") + } + + ctx.WriteUint32(uint32(len(p.A))) + for _, d := range p.A { + ctx.WriteIdent(d) + } + + if p.W { + ctx.WriteUint32(1) + } else { + ctx.WriteUint32(0) + } + } + + ctx.WriteString(a.dir.String()) + + ctx.WriteUint32(uint32(len(a.env))) + for _, e := range a.env { + ctx.WriteString(e) + } + + ctx.WriteString(a.path.String()) + + ctx.WriteUint32(uint32(len(a.args))) + for _, arg := range a.args { + ctx.WriteString(arg) + } + + ctx.WriteUint32(uint32(a.timeout & 0xffffffff)) + ctx.WriteUint32(uint32(a.timeout >> 32)) + + if a.exclusive { + ctx.WriteUint32(1) + } else { + ctx.WriteUint32(0) + } +} + +// UnsupportedArchError describes an unsupported or invalid architecture. +type UnsupportedArchError string + +func (e UnsupportedArchError) Error() string { + if e == "" { + return "invalid architecture name" + } + return "unsupported architecture " + string(e) +} + +// readExecArtifact interprets IR values and returns the address of execArtifact +// or execNetArtifact. +func readExecArtifact(r *IRReader, net bool) Artifact { + r.DiscardAll() + + arch := r.ReadString() + if arch == "" { + panic(UnsupportedArchError(arch)) + } + + name := r.ReadString() + + sz := r.ReadUint32() + if sz > irMaxDeps { + panic(ErrIRDepend) + } + paths := make([]ExecPath, sz) + for i := range paths { + paths[i].P = check.MustAbs(r.ReadString()) + + sz = r.ReadUint32() + if sz > irMaxDeps { + panic(ErrIRDepend) + } + paths[i].A = make([]Artifact, sz) + for j := range paths[i].A { + paths[i].A[j] = r.ReadIdent() + } + + paths[i].W = r.ReadUint32() != 0 + } + + dir := check.MustAbs(r.ReadString()) + + sz = r.ReadUint32() + if sz > irMaxValues { + panic(ErrIRValues) + } + env := make([]string, sz) + for i := range env { + env[i] = r.ReadString() + } + + pathname := check.MustAbs(r.ReadString()) + + sz = r.ReadUint32() + if sz > irMaxValues { + panic(ErrIRValues) + } + args := make([]string, sz) + for i := range args { + args[i] = r.ReadString() + } + + timeout := time.Duration(r.ReadUint32()) + timeout |= time.Duration(r.ReadUint32()) << 32 + + exclusive := r.ReadUint32() != 0 + + checksum, ok := r.Finalise() + var checksumP *Checksum + if ok { + checksumP = new(checksum.Value()) + } + + if net && !ok { + panic(ErrExpectedChecksum) + } + + return NewExec( + name, arch, checksumP, timeout, net, exclusive, dir, env, pathname, args, paths..., + ) +} + +func init() { + register(KindExec, + func(r *IRReader) Artifact { return readExecArtifact(r, false) }) + register(KindExecNet, + func(r *IRReader) Artifact { return readExecArtifact(r, true) }) +} + +// Inputs returns a slice of all artifacts collected from caller-supplied +// [ExecPath]. +func (a *execArtifact) Inputs() []Artifact { + artifacts := make([][]Artifact, 0, len(a.paths)) + for _, p := range a.paths { + artifacts = append(artifacts, p.A) + } + return slices.Concat(artifacts...) +} + +// IsExclusive returns the caller-supplied exclusivity value. +func (a *execArtifact) IsExclusive() bool { return a.exclusive } + +// String returns the caller-supplied reporting name. +func (a *execArtifact) String() string { return a.name } + +// Cure cures the [Artifact] in the container described by the caller. +func (a *execArtifact) Cure(f *FContext) (err error) { + return a.cure(f, false) +} + +const ( + // execWaitDelay is passed through to [container.Params]. + execWaitDelay = time.Nanosecond +) + +// scanLinesCR is like [bufio.ScanLines], but also treats a bare \r as an +// end-of-line marker. +func scanLinesCR(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + ri, ni := bytes.IndexByte(data, '\r'), bytes.IndexByte(data, '\n') + + if ri >= 0 && (ni < 0 || ri < ni) { + if ri+1 == ni { + // We have a full \r\n-terminated line. + return ri + 2, data[:ri], nil + } + // We have a bare \r, probably some kind of progress indicator. + return ri + 1, data[:ri], nil + } + if ni >= 0 && (ri < 0 || ni < ri) { + // We have a full newline-terminated line. + return ni + 1, data[:ni], nil + } + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} + +// scanVerbose prefixes program output for a verbose [message.Msg]. +func scanVerbose( + msg message.Msg, + cancel context.CancelFunc, + done chan<- struct{}, + prefix, suffix string, + r io.Reader, +) { + defer close(done) + s := bufio.NewScanner(r) + s.Split(scanLinesCR) + s.Buffer( + make([]byte, bufio.MaxScanTokenSize), + bufio.MaxScanTokenSize<<12, + ) + for s.Scan() { + msg.Verbose(prefix, s.Text()+suffix) + } + if err := s.Err(); err != nil && !errors.Is(err, os.ErrClosed) { + cancel() + msg.Verbose("*"+prefix, err.Error()+suffix) + } +} + +var ( + // ErrInvalidPaths is returned for an [Artifact] of [KindExec] or + // [KindExecNet] specified with invalid paths. + ErrInvalidPaths = errors.New("invalid mount point") +) + +// SeccompPresets is the [seccomp] presets used by exec artifacts. +const SeccompPresets = std.PresetStrict & + ^(std.PresetDenyNS | std.PresetDenyDevel) + +// makeContainer sets up the specified temp and work directories and returns the +// corresponding [container.Container] that would have run for cure. +func (a *execArtifact) makeContainer( + ctx context.Context, + msg message.Msg, + flags, jobs, load int, + hostNet bool, + temp, work *check.Absolute, + getArtifact GetArtifactFunc, + ident func(a Artifact) unique.Handle[ID], +) (z *container.Container, err error) { + overlayWorkIndex := -1 + for i, p := range a.paths { + if p.P == nil || len(p.A) == 0 { + return nil, ErrInvalidPaths + } + if p.P.Is(AbsWork) { + overlayWorkIndex = i + } + } + + var artifactCount int + for _, p := range a.paths { + artifactCount += len(p.A) + } + + z = container.New(ctx, msg) + z.WaitDelay = execWaitDelay + z.SeccompPresets = SeccompPresets + z.SeccompFlags |= seccomp.AllowMultiarch + z.ParentPerm = 0700 + z.HostNet = hostNet + z.HostAbstract = flags&CHostAbstract != 0 + z.Hostname = "cure" + z.SetScheduler = flags&CSchedIdle != 0 + z.SchedPolicy = ext.SCHED_IDLE + if z.HostNet { + z.Hostname = "cure-net" + } + z.Quiet = flags&CSuppressInit != 0 + z.Uid, z.Gid = (1<<10)-1, (1<<10)-1 + z.Dir, z.Path, z.Args = a.dir, a.path, a.args + z.Env = slices.Concat(a.env, []string{ + EnvJobs + "=" + strconv.Itoa(jobs), + EnvLoad + "=" + strconv.Itoa(load), + }) + z.Grow(len(a.paths) + 4) + + if a.arch != runtime.GOARCH { + binfmtMu.RLock() + e, ok := binfmt[a.arch] + binfmtMu.RUnlock() + if !ok { + return nil, UnsupportedArchError(a.arch) + } + z.Binfmt = []container.BinfmtEntry{e} + z.InitAsRoot = true + } + + for i, b := range a.paths { + if i == overlayWorkIndex { + if err = os.MkdirAll(work.String(), 0700); err != nil { + return + } + tempWork := temp.Append(".work") + if err = os.MkdirAll(tempWork.String(), 0700); err != nil { + return + } + z.Overlay( + AbsWork, + work, + tempWork, + b.layers(msg, getArtifact, ident)..., + ) + continue + } + + if a.paths[i].W { + tempUpper, tempWork := temp.Append( + ".upper", strconv.Itoa(i), + ), temp.Append( + ".work", strconv.Itoa(i), + ) + if err = os.MkdirAll(tempUpper.String(), 0700); err != nil { + return + } + if err = os.MkdirAll(tempWork.String(), 0700); err != nil { + return + } + z.Overlay(b.P, tempUpper, tempWork, b.layers(msg, getArtifact, ident)...) + } else if len(b.A) == 1 { + pathname, _ := getArtifact(b.A[0]) + z.Bind(pathname, b.P, 0) + } else { + z.OverlayReadonly(b.P, b.layers(msg, getArtifact, ident)...) + } + } + if overlayWorkIndex < 0 { + z.Bind( + work, + AbsWork, + std.BindWritable|std.BindEnsure, + ) + } + z.Bind( + temp, + fhs.AbsTmp, + std.BindWritable|std.BindEnsure, + ) + z.Proc(fhs.AbsProc).Dev(fhs.AbsDev, true) + return +} + +var ( + // ErrExecBusy is returned entering [Cache.EnterExec] while another + // goroutine has not yet returned from it. + ErrExecBusy = errors.New("scratch directories in use") + // ErrNotExec is returned for unsupported implementations of [Artifact] + // passed to [Cache.EnterExec]. + ErrNotExec = errors.New("attempting to run a non-exec artifact") +) + +// EnterExec runs the container of an [Artifact] of [KindExec] or [KindExecNet] +// with its entry point, argument, and standard streams replaced with values +// supplied by the caller. +func (c *Cache) EnterExec( + ctx context.Context, + a Artifact, + hostname string, + retainSession bool, + stdin io.Reader, + stdout, stderr io.Writer, + path *check.Absolute, + args ...string, +) (err error) { + if !c.inExec.CompareAndSwap(false, true) { + return ErrExecBusy + } + defer c.inExec.Store(false) + + var hostNet bool + var e *execArtifact + switch f := a.(type) { + case *execArtifact: + e = f + + case *execMeasuredArtifact: + e = &f.execArtifact + hostNet = f.hostNet + + default: + return ErrNotExec + } + + deps := Collect(a.Inputs()) + if _, _, err = c.Cure(&deps); err == nil { + return errors.New("unreachable") + } else if !IsCollected(err) { + return + } + + dm := make(map[Artifact]cureRes) + for i, p := range deps { + var res cureRes + res.pathname, res.checksum, err = c.Cure(p) + if err != nil { + return + } + dm[deps[i]] = res + } + + scratch := c.base.Append(dirExecScratch) + temp, work := scratch.Append("temp"), scratch.Append("work") + // work created during makeContainer + if err = os.MkdirAll(temp.String(), 0700); err != nil { + return + } + defer func() { + if chmodErr, removeErr := removeAll(scratch); chmodErr != nil || removeErr != nil { + err = errors.Join(err, chmodErr, removeErr) + } + }() + + var z *container.Container + z, err = e.makeContainer( + ctx, c.msg, + c.attr.Flags, + c.attr.Jobs, + c.attr.Load, + hostNet, + temp, work, + func(a Artifact) (*check.Absolute, unique.Handle[Checksum]) { + if res, ok := dm[a]; ok { + return res.pathname, res.checksum + } + panic(InvalidLookupError(c.Ident(a).Value())) + }, + c.Ident, + ) + if err != nil { + return + } + z.Stdin, z.Stdout, z.Stderr = stdin, stdout, stderr + z.Path, z.Args = path, args + z.RetainSession = retainSession + if stdin == os.Stdin { + if s, ok := os.LookupEnv("TERM"); ok { + z.Env = append(z.Env, "TERM="+s) + } + } + if hostname != "" { + z.Hostname = hostname + } + + if err = z.Start(); err != nil { + return + } + if err = z.Serve(); err != nil { + return + } + return z.Wait() +} + +// cure is like Cure but allows optional host net namespace. +func (a *execArtifact) cure(f *FContext, hostNet bool) (err error) { + ctx, cancel := context.WithTimeout(f.Unwrap(), a.timeout) + defer cancel() + + msg := f.GetMessage() + var z *container.Container + if z, err = a.makeContainer( + ctx, msg, f.cache.attr.Flags, f.GetJobs(), f.GetLoad(), hostNet, + f.GetTempDir(), f.GetWorkDir(), + f.GetArtifact, + f.cache.Ident, + ); err != nil { + return + } + + var status io.Writer + if status, err = f.GetStatusWriter(); err != nil { + return + } + + if msg.IsVerbose() { + var stdout, stderr io.ReadCloser + if stdout, err = z.StdoutPipe(); err != nil { + return + } + if stderr, err = z.StderrPipe(); err != nil { + _ = stdout.Close() + return + } + + brStdout, brStderr := f.cache.getReader(stdout), f.cache.getReader(stderr) + stdoutDone, stderrDone := make(chan struct{}), make(chan struct{}) + + var suffix, prefixO, prefixE string + if f.cache.attr.Flags&CColourOutput != 0 { + suffix = "\x1b[0m" + prefixO = "\x1b[1;37m(" + a.name + ")\x1b[0m" + prefixE = "\x1b[1;97m(" + a.name + ")\x1b[0m" + } else { + prefixO = "(" + a.name + ":1)" + prefixE = "(" + a.name + ":2)" + } + + go scanVerbose( + msg, cancel, stdoutDone, + prefixO, suffix, + io.TeeReader(brStdout, status), + ) + go scanVerbose( + msg, cancel, stderrDone, + prefixE, suffix, + io.TeeReader(brStderr, status), + ) + defer func() { + if err != nil && !errors.As(err, new(*exec.ExitError)) { + _ = stdout.Close() + _ = stderr.Close() + } + + <-stdoutDone + <-stderrDone + f.cache.putReader(brStdout) + f.cache.putReader(brStderr) + }() + } else { + z.Stdout, z.Stderr = status, status + } + + if err = z.Start(); err != nil { + return + } + if err = z.Serve(); err != nil { + return + } + if err = z.Wait(); err != nil { + return + } + + // do not allow empty directories to succeed + for { + err = syscall.Rmdir(f.GetWorkDir().String()) + if err != syscall.EINTR { + break + } + } + if err != nil && errors.Is(err, syscall.ENOTEMPTY) { + err = nil + } + return +} diff --git a/pkg/exec_test.go b/pkg/exec_test.go new file mode 100644 index 00000000..051af551 --- /dev/null +++ b/pkg/exec_test.go @@ -0,0 +1,670 @@ +package pkg_test + +import ( + "bufio" + "bytes" + _ "embed" + "encoding/gob" + "errors" + "io/fs" + "net" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + _ "unsafe" // for go:linkname + + "hakurei.app/check" + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/internal/info" + "hakurei.app/internal/stub" + "hakurei.app/pkg" + + "hakurei.app/pkg/internal/testtool/expected" +) + +// testtoolBin is the container test tool binary made available to the +// execArtifact for testing its curing environment. +// +//go:generate env CGO_ENABLED=0 go build -tags testtool -o internal/testtool ./internal/testtool +//go:embed internal/testtool/testtool +var testtoolBin []byte + +func init() { + pathname, err := filepath.Abs("internal/testtool/testtool") + if err != nil { + panic(err) + } + pkg.RegisterArch("cafe", container.BinfmtEntry{ + Magic: expected.Magic, + Interpreter: check.MustAbs(pathname), + }) +} + +// scanLinesCR is like [bufio.ScanLines], but also treats a bare \r as an +// end-of-line marker. +// +//go:linkname scanLinesCR hakurei.app/pkg.scanLinesCR +func scanLinesCR(data []byte, atEOF bool) (advance int, token []byte, err error) + +func TestScan(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + data string + want []string + }{ + {"progress indicator", "Updating files: 76% (8655/11256)\r" + + "Updating files: 77% (8668/11256)\r" + + "Updating files: 78% (8780/11256)\r" + + "Updating files: 79% (8893/11256)\r" + + "Updating files: 80% (9005/11256)\r" + + "Updating files: 81% (9118/11256)\r" + + "Updating files: 82% (9230/11256)\r" + + "Updating files: 83% (9343/11256)\r" + + "Updating files: 84% (9456/11256)\r" + + "Updating files: 85% (9568/11256)\r" + + "Updating files: 86% (9681/11256)\r" + + "Updating files: 87% (9793/11256)\r" + + "Updating files: 88% (9906/11256)\r" + + "Updating files: 89% (10018/11256)\r" + + "Updating files: 90% (10131/11256)\r" + + "Updating files: 91% (10243/11256)\r" + + "Updating files: 92% (10356/11256)\r" + + "Updating files: 93% (10469/11256)\r" + + "Updating files: 94% (10581/11256)\r" + + "Updating files: 95% (10694/11256)\r" + + "Updating files: 96% (10806/11256)\r" + + "Updating files: 97% (10919/11256)\r" + + "Updating files: 98% (11031/11256)\r" + + "Updating files: 99% (11144/11256)\r" + + "Updating files: 100% (11256/11256)\r" + + "Updating files: 100% (11256/11256), done.\n", []string{ + "Updating files: 76% (8655/11256)", + "Updating files: 77% (8668/11256)", + "Updating files: 78% (8780/11256)", + "Updating files: 79% (8893/11256)", + "Updating files: 80% (9005/11256)", + "Updating files: 81% (9118/11256)", + "Updating files: 82% (9230/11256)", + "Updating files: 83% (9343/11256)", + "Updating files: 84% (9456/11256)", + "Updating files: 85% (9568/11256)", + "Updating files: 86% (9681/11256)", + "Updating files: 87% (9793/11256)", + "Updating files: 88% (9906/11256)", + "Updating files: 89% (10018/11256)", + "Updating files: 90% (10131/11256)", + "Updating files: 91% (10243/11256)", + "Updating files: 92% (10356/11256)", + "Updating files: 93% (10469/11256)", + "Updating files: 94% (10581/11256)", + "Updating files: 95% (10694/11256)", + "Updating files: 96% (10806/11256)", + "Updating files: 97% (10919/11256)", + "Updating files: 98% (11031/11256)", + "Updating files: 99% (11144/11256)", + "Updating files: 100% (11256/11256)", + "Updating files: 100% (11256/11256), done.", + }}, + + {"crlf", "0\r1\n2\n3\r\n4\n5\r6\r\n7\n", []string{ + "0", "1", "2", "3", "4", "5", "6", "7", + }}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := bufio.NewScanner(strings.NewReader(tc.data)) + s.Split(scanLinesCR) + got := make([]string, 0, len(tc.want)) + for s.Scan() { + got = append(got, s.Text()) + } + if err := s.Err(); err != nil { + t.Fatal(err) + } + if !slices.Equal(got, tc.want) { + t.Fatalf("Scan: %q, want %q", got, tc.want) + } + }) + } +} + +func TestExec(t *testing.T) { + t.Parallel() + + wantOffline := expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + + "check": {Mode: 0400, Data: []byte{0}}, + } + wantOfflineEncode := pkg.Encode(wantOffline.hash()) + failingArtifact := &stubArtifact{ + kind: pkg.KindTar, + params: []byte("doomed artifact"), + cure: func(t *pkg.TContext) error { + return stub.UniqueError(0xcafe) + }, + } + + checkWithCache(t, []cacheTestCase{ + {"offline", pkg.CValidateKnown | checkDestroySubstitutes, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + testtool, testtoolDestroy := newTesttool() + + cureMany(t, c, []cureStep{ + {"container", pkg.NewExec( + "exec-offline", "", new(wantOffline.hash()), 0, false, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool"}, + + pkg.MustPath("/file", false, newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xfe, 0}, + nil, + nil, nil, + )), + pkg.MustPath("/.hakurei", false, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }), + pkg.MustPath("/opt", false, testtool), + ), ignorePathname, wantOffline, pkg.WNew, nil}, + + {"substitution", pkg.NewExec( + "exec-offline", "", new(wantOffline.hash()), 0, false, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool"}, + + pkg.MustPath("/file", false, newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xfe, 0}, + nil, + nil, nil, + )), + // substitution miss fails in testtool due to differing idents + pkg.MustPath("/.hakurei", false, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory (substituted)"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }), + pkg.MustPath("/opt", false, testtool), + ), ignorePathname, wantOffline, pkg.WSubstitute, nil}, + + {"error passthrough", pkg.NewExec( + "", "", nil, 0, false, true, + pkg.AbsWork, + []string{"HAKUREI_TEST=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool"}, + + pkg.MustPath("/proc/nonexistent", false, failingArtifact), + ), nil, nil, pkg.WNew, pkg.InputError{ + failingArtifact: stub.UniqueError(0xcafe), + }}, + + {"invalid paths", pkg.NewExec( + "", "", nil, 0, false, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool"}, + + pkg.ExecPath{}, + ), nil, nil, pkg.WNew, pkg.ErrInvalidPaths}, + }) + + // check init failure passthrough + initFailureArtifact := pkg.NewExec( + "", "", nil, 0, false, false, + pkg.AbsWork, + nil, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool"}, + ) + var exitError *exec.ExitError + if _, _, err := c.Cure(initFailureArtifact); !errors.As(err, &exitError) || + exitError.ExitCode() != hst.ExitFailure { + t.Fatalf("Cure: error = %v, want init exit status 1", err) + } + + var faultStatus []byte + if faults, err := c.ReadFaults(initFailureArtifact); err != nil { + t.Fatal(err) + } else if len(faults) != 1 { + t.Fatalf("ReadFaults: %v", faults) + } else if faultStatus, err = os.ReadFile(faults[0].String()); err != nil { + t.Fatal(err) + } else if err = faults[0].Destroy(); err != nil { + t.Fatal(err) + } else { + t.Logf("destroyed expected fault at %s", faults[0].Time().UTC()) + } + + if !bytes.HasPrefix(faultStatus, []byte( + "hakurei.app/pkg ", + )) || !bytes.Contains(faultStatus, []byte( + "\ninit: fork/exec /opt/bin/testtool: no such file or directory\n", + )) { + t.Errorf("unexpected status:\n%s", string(faultStatus)) + } + + destroyStatus(t, base, 2, 1) + testtoolDestroy(t, base, c) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/" + wantOfflineEncode: {Mode: fs.ModeDir | 0500}, + "checksum/" + wantOfflineEncode + "/check": {Mode: 0400, Data: []byte{0}}, + "checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU": {Mode: fs.ModeDir | 0500}, + "checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb": {Mode: 0400, Data: []byte{}}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/MEaM1v6MLRQ60JQOOKbfDzfJOdC3L8i30IgOpYbQdX68enwF0FN-P36_M4RX_0Vd": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + "identifier/_gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb")}, + "identifier/" + expected.Offline: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantOfflineEncode)}, + "identifier/" + expected.OfflineS: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantOfflineEncode)}, + "identifier/_OHzj_xdmfpaWfFN-_D9JpGpPlIjh84873E3Ui9xwv9PPPIt0BlzvH-cnPNeRsTl": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "temp": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"net", pkg.CValidateKnown | checkDestroySubstitutes, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + testtool, testtoolDestroy := newTesttool() + + wantNet := expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + + "check": {Mode: 0400, Data: []byte("net")}, + } + cureMany(t, c, []cureStep{ + {"container", pkg.NewExec( + "exec-net", "", new(wantNet.hash()), 0, true, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool", "net"}, + + pkg.MustPath("/file", false, newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xfe, 0}, + nil, + nil, nil, + )), + pkg.MustPath("/.hakurei", false, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }), + pkg.MustPath("/opt", false, testtool), + ), ignorePathname, wantNet, pkg.WNew, nil}, + }) + + destroyStatus(t, base, 2, 0) + testtoolDestroy(t, base, c) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU": {Mode: fs.ModeDir | 0500}, + "checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb": {Mode: 0400, Data: []byte{}}, + "checksum/a1F_i9PVQI4qMcoHgTQkORuyWLkC1GLIxOhDt2JpU1NGAxWc5VJzdlfRK-PYBh3W": {Mode: fs.ModeDir | 0500}, + "checksum/a1F_i9PVQI4qMcoHgTQkORuyWLkC1GLIxOhDt2JpU1NGAxWc5VJzdlfRK-PYBh3W/check": {Mode: 0400, Data: []byte("net")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/" + expected.Net: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/a1F_i9PVQI4qMcoHgTQkORuyWLkC1GLIxOhDt2JpU1NGAxWc5VJzdlfRK-PYBh3W")}, + "identifier/_gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb")}, + "identifier/MEaM1v6MLRQ60JQOOKbfDzfJOdC3L8i30IgOpYbQdX68enwF0FN-P36_M4RX_0Vd": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "temp": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"overlay root", pkg.CValidateKnown | checkDestroySubstitutes, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + testtool, testtoolDestroy := newTesttool() + + cureMany(t, c, []cureStep{ + {"container", pkg.NewExec( + "exec-overlay-root", "", nil, 0, false, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1", "HAKUREI_ROOT=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool"}, + + pkg.MustPath("/", true, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }), + pkg.MustPath("/opt", false, testtool), + ), ignorePathname, wantOffline, pkg.WNew, nil}, + }) + + destroyStatus(t, base, 2, 0) + testtoolDestroy(t, base, c) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/" + wantOfflineEncode: {Mode: fs.ModeDir | 0500}, + "checksum/" + wantOfflineEncode + "/check": {Mode: 0400, Data: []byte{0}}, + "checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU": {Mode: fs.ModeDir | 0500}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/" + expected.OvlRoot: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantOfflineEncode)}, + "identifier/MEaM1v6MLRQ60JQOOKbfDzfJOdC3L8i30IgOpYbQdX68enwF0FN-P36_M4RX_0Vd": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "temp": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"overlay work", pkg.CValidateKnown | checkDestroySubstitutes, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + testtool, testtoolDestroy := newTesttool() + + cureMany(t, c, []cureStep{ + {"container", pkg.NewExec( + "exec-overlay-work", "", nil, 0, false, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1", "HAKUREI_ROOT=1"}, + check.MustAbs("/work/bin/testtool"), + []string{"testtool"}, + + pkg.MustPath("/", true, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }), pkg.MustPath("/work/", false, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }), pkg.Path(pkg.AbsWork, false /* ignored */, testtool), + ), ignorePathname, wantOffline, pkg.WNew, nil}, + }) + + destroyStatus(t, base, 2, 0) + testtoolDestroy(t, base, c) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/" + wantOfflineEncode: {Mode: fs.ModeDir | 0500}, + "checksum/" + wantOfflineEncode + "/check": {Mode: 0400, Data: []byte{0}}, + "checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU": {Mode: fs.ModeDir | 0500}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/" + expected.Work: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantOfflineEncode)}, + "identifier/MEaM1v6MLRQ60JQOOKbfDzfJOdC3L8i30IgOpYbQdX68enwF0FN-P36_M4RX_0Vd": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "temp": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"multiple layers", pkg.CValidateKnown | checkDestroySubstitutes, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + testtool, testtoolDestroy := newTesttool() + + cureMany(t, c, []cureStep{ + {"container", pkg.NewExec( + "exec-multiple-layers", "", nil, 0, false, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1", "HAKUREI_ROOT=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool", "layers"}, + + pkg.MustPath("/", true, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }, &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("test sample with dependencies"), + + deps: slices.Repeat([]pkg.Artifact{newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xfe, 0}, + nil, + nil, nil, + ), &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory"), + + // this is queued and might run instead of the other + // one so do not leave it as nil + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }}, 1<<5 /* concurrent cache hits */), + + cure: func(f *pkg.FContext) error { + work := f.GetWorkDir() + if err := os.MkdirAll(work.String(), 0700); err != nil { + return err + } + return os.WriteFile(work.Append("check").String(), []byte("layers"), 0400) + }, + }), + pkg.MustPath("/opt", false, testtool), + ), ignorePathname, wantOffline, pkg.WNew, nil}, + }) + + destroyStatus(t, base, 2, 0) + testtoolDestroy(t, base, c) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/" + wantOfflineEncode: {Mode: fs.ModeDir | 0500}, + "checksum/" + wantOfflineEncode + "/check": {Mode: 0400, Data: []byte{0}}, + "checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU": {Mode: fs.ModeDir | 0500}, + "checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb": {Mode: 0400, Data: []byte{}}, + "checksum/nY_CUdiaUM1OL4cPr5TS92FCJ3rCRV7Hm5oVTzAvMXwC03_QnTRfQ5PPs7mOU9fK": {Mode: fs.ModeDir | 0500}, + "checksum/nY_CUdiaUM1OL4cPr5TS92FCJ3rCRV7Hm5oVTzAvMXwC03_QnTRfQ5PPs7mOU9fK/check": {Mode: 0400, Data: []byte("layers")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/_gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb")}, + "identifier/2uIvmnqtzAx4Ed4PWV8xjZCWeJCwfE2IcCc0evM-Nd8mDUlgSU6nLtPLcpPUcMlG": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/nY_CUdiaUM1OL4cPr5TS92FCJ3rCRV7Hm5oVTzAvMXwC03_QnTRfQ5PPs7mOU9fK")}, + "identifier/" + expected.Layers: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantOfflineEncode)}, + "identifier/MEaM1v6MLRQ60JQOOKbfDzfJOdC3L8i30IgOpYbQdX68enwF0FN-P36_M4RX_0Vd": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "temp": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"overlay layer promotion", pkg.CValidateKnown | checkDestroySubstitutes, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + testtool, testtoolDestroy := newTesttool() + + cureMany(t, c, []cureStep{ + {"container", pkg.NewExec( + "exec-layer-promotion", "", nil, 0, false, true, + pkg.AbsWork, + []string{"HAKUREI_TEST=1", "HAKUREI_ROOT=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool", "promote"}, + + pkg.MustPath("/", true, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("another empty directory"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }), + pkg.MustPath("/opt", false, testtool), + ), ignorePathname, wantOffline, pkg.WNew, nil}, + }) + + destroyStatus(t, base, 2, 0) + testtoolDestroy(t, base, c) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/" + wantOfflineEncode: {Mode: fs.ModeDir | 0500}, + "checksum/" + wantOfflineEncode + "/check": {Mode: 0400, Data: []byte{0}}, + "checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU": {Mode: fs.ModeDir | 0500}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/Vzj9qaSVBtNyBQv_PW6uojFOWTwraHoaC0C6xdaGG1q_HTvSh4KLVxRcEQU1jd2Y": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + "identifier/MEaM1v6MLRQ60JQOOKbfDzfJOdC3L8i30IgOpYbQdX68enwF0FN-P36_M4RX_0Vd": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + "identifier/" + expected.Promote: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantOfflineEncode)}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "temp": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"binfmt", pkg.CValidateKnown | checkDestroySubstitutes, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + if info.CanDegrade && os.Getenv("ROSA_SKIP_BINFMT") != "" { + t.Skip("binfmt_misc test explicitly skipped") + } + + cureMany(t, c, []cureStep{ + {"container", pkg.NewExec( + "exec-binfmt", "cafe", nil, 0, false, true, + pkg.AbsWork, + []string{"HAKUREI_TEST=1", "HAKUREI_BINFMT=1"}, + check.MustAbs("/opt/bin/sample"), + []string{"sample"}, + + pkg.MustPath("/", true, &stubArtifact{ + kind: pkg.KindTar, + params: []byte("empty directory"), + cure: func(t *pkg.TContext) error { + return os.MkdirAll(t.GetWorkDir().String(), 0700) + }, + }), + pkg.MustPath("/opt", false, overrideIdent{pkg.ID{0xfe, 0xff}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + work := t.GetWorkDir() + if err := os.MkdirAll( + work.Append("bin").String(), + 0700, + ); err != nil { + return err + } + + return os.WriteFile(t.GetWorkDir().Append( + "bin", + "sample", + ).String(), []byte(expected.Full), 0500) + }, + }}), + ), ignorePathname, expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + + "check": {Mode: 0400, Data: []byte("binfmt")}, + }, pkg.WNew, nil}, + }) + + destroyStatus(t, base, 2, 0) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/5aevg3YpDxjqQZ-pdvXK7YqgkL5JKqcoStYQxeD96kuYar6K2mRQWMHib6NQRnpV": {Mode: fs.ModeDir | 0500}, + "checksum/5aevg3YpDxjqQZ-pdvXK7YqgkL5JKqcoStYQxeD96kuYar6K2mRQWMHib6NQRnpV/bin": {Mode: fs.ModeDir | 0700}, + "checksum/5aevg3YpDxjqQZ-pdvXK7YqgkL5JKqcoStYQxeD96kuYar6K2mRQWMHib6NQRnpV/bin/sample": {Mode: 0500, Data: []byte("\xca\xfe\xba\xbe\xfd\xfd:3")}, + "checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU": {Mode: fs.ModeDir | 0500}, + "checksum/UnDo4B5KneEUY5b4vRUk_y9MWgkWuw2N8f8a2XayO686xXur-aZmX2-7n_8tKMe3": {Mode: fs.ModeDir | 0500}, + "checksum/UnDo4B5KneEUY5b4vRUk_y9MWgkWuw2N8f8a2XayO686xXur-aZmX2-7n_8tKMe3/check": {Mode: 0400, Data: []byte("binfmt")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/c-HodmoEG0f_WGdst9aN-duLMH7QDDXOUi7UcyArXMeM1fc9lYKKQlTCW8ktaK8X": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/UnDo4B5KneEUY5b4vRUk_y9MWgkWuw2N8f8a2XayO686xXur-aZmX2-7n_8tKMe3")}, + "identifier/_v8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/5aevg3YpDxjqQZ-pdvXK7YqgkL5JKqcoStYQxeD96kuYar6K2mRQWMHib6NQRnpV")}, + "identifier/MEaM1v6MLRQ60JQOOKbfDzfJOdC3L8i30IgOpYbQdX68enwF0FN-P36_M4RX_0Vd": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "temp": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + }) +} + +// newTesttool returns an [Artifact] that cures into testtoolBin. The returned +// function must be called at the end of the test but not deferred. +func newTesttool() ( + testtool pkg.Artifact, + testtoolDestroy func(t *testing.T, base *check.Absolute, c *pkg.Cache), +) { + // testtoolBin is built during go:generate and is not deterministic + testtool = overrideIdent{pkg.ID{0xfe, 0xff}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + work := t.GetWorkDir() + if err := os.MkdirAll( + work.Append("bin").String(), + 0700, + ); err != nil { + return err + } + + if ift, err := net.Interfaces(); err != nil { + return err + } else { + var f *os.File + if f, err = os.Create(t.GetWorkDir().Append( + "ift", + ).String()); err != nil { + return err + } else { + err = gob.NewEncoder(f).Encode(ift) + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + } + } + + return os.WriteFile(t.GetWorkDir().Append( + "bin", + "testtool", + ).String(), testtoolBin, 0500) + }, + }} + testtoolDestroy = newDestroyArtifactFunc(testtool) + return +} diff --git a/pkg/file.go b/pkg/file.go new file mode 100644 index 00000000..14467153 --- /dev/null +++ b/pkg/file.go @@ -0,0 +1,100 @@ +package pkg + +import ( + "bytes" + "crypto/sha512" + "fmt" + "io" +) + +// A fileArtifact is an [Artifact] that cures into data known ahead of time. +type fileArtifact []byte + +var _ KnownChecksum = new(fileArtifact) +var _ CuresExempt = new(fileArtifact) +var _ RevisionArtifact = new(fileArtifact) + +// fileArtifactNamed embeds fileArtifact alongside a caller-supplied name. +type fileArtifactNamed struct { + fileArtifact + // Caller-supplied user-facing reporting name. + name string +} + +var _ fmt.Stringer = new(fileArtifactNamed) +var _ KnownChecksum = new(fileArtifactNamed) + +// String returns the caller-supplied reporting name. +func (a *fileArtifactNamed) String() string { return a.name } + +// Params writes the caller-supplied reporting name and the file body. +func (a *fileArtifactNamed) Params(ctx *IContext) { + ctx.WriteString(a.name) + ctx.Write(a.fileArtifact) +} + +// NewFile returns a [FileArtifact] that cures into a caller-supplied byte slice. +// +// Caller must not modify data after NewFile returns. +func NewFile(name string, data []byte) FileArtifact { + f := fileArtifact(data) + if name != "" { + return &fileArtifactNamed{f, name} + } + return &f +} + +// Kind returns the hardcoded [Kind] constant. +func (*fileArtifact) Kind() Kind { return KindFile } + +// Params writes an empty string and the file body. +func (a *fileArtifact) Params(ctx *IContext) { + ctx.WriteString("") + ctx.Write(*a) +} + +func init() { + register(KindFile, func(r *IRReader) Artifact { + name := r.ReadString() + data := r.ReadStringBytes() + if _, ok := r.Finalise(); !ok { + panic(ErrExpectedChecksum) + } + return NewFile(name, data) + }) +} + +// Inputs returns a nil slice. +func (*fileArtifact) Inputs() []Artifact { return nil } + +// IsExclusive returns false: Cure returns a prepopulated buffer. +func (*fileArtifact) IsExclusive() bool { return false } + +// Checksum computes and returns the checksum of caller-supplied data. +func (a *fileArtifact) Checksum() Checksum { + h := sha512.New384() + h.Write(*a) + return Checksum(h.Sum(nil)) +} + +// Revision satisfies [RevisionArtifact] for incompatible IsExecutable behaviour. +func (*fileArtifact) Revision() uint64 { return 0 } + +// IsExecutable returns whether the contents begin with shebang or the ELF +// magic number. +func (a *fileArtifact) IsExecutable() bool { + return a != nil && + (bytes.HasPrefix( + *a, []byte{'#', '!'}, + ) || bytes.HasPrefix( + *a, []byte{0x7f, 'E', 'L', 'F'}, + )) +} + +// Cure returns the caller-supplied data. +func (a *fileArtifact) Cure(*RContext) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(*a)), nil +} + +// CuresExempt exempts the cheap [KindFile] implementation. +func (*fileArtifact) CuresExempt() {} diff --git a/pkg/file_test.go b/pkg/file_test.go new file mode 100644 index 00000000..5742ca04 --- /dev/null +++ b/pkg/file_test.go @@ -0,0 +1,56 @@ +package pkg_test + +import ( + "io/fs" + "testing" + + "hakurei.app/check" + "hakurei.app/pkg" +) + +func TestFile(t *testing.T) { + t.Parallel() + + want := expectsFile{0} + const ident = "N7M6W8-e55Wky_1q1a6qRKFVDw1S5XtPdB4HE2YZObG8OFum8HittyJgZUG5Ru2u" + wantShebang := expectsFile("#!/bin/sh\n") + const identShebang = "oKTGZb_TRtJf5G1iGGj4SOx1WUbI6YhmsUeEsCNvmK9tZYRwdwQf-DLFUxRPx5qm" + wantELF := expectsFile("\x7fELF\xde\xad\xbe\xef") + const identELF = "LjeHmIJgD1SiXIH8ewpGRed2_LU84NLapstnOAa_0hxLd8YY7x4KM0aApsj-RwCh" + checkWithCache(t, []cacheTestCase{ + {"file", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + cureMany(t, c, []cureStep{ + {"short", pkg.NewFile("null", want), base.Append( + "identifier", + ident, + ), want, pkg.WNew, nil}, + + {"shebang", pkg.NewFile("shebang", wantShebang), base.Append( + "identifier", + identShebang, + ), wantShebang, pkg.WNew, nil}, + + {"elf", pkg.NewFile("ELF", wantELF), base.Append( + "identifier", + identELF, + ), wantELF, pkg.WNew, nil}, + }) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/" + pkg.Encode(want.hash()): {Mode: 0400, Data: want}, + "checksum/" + pkg.Encode(wantShebang.hash()): {Mode: 0500, Data: wantShebang}, + "checksum/" + pkg.Encode(wantELF.hash()): {Mode: 0500, Data: wantELF}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/" + ident: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + pkg.Encode(want.hash()))}, + "identifier/" + identShebang: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + pkg.Encode(wantShebang.hash()))}, + "identifier/" + identELF: {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + pkg.Encode(wantELF.hash()))}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "work": {Mode: fs.ModeDir | 0700}, + }}, + }) +} diff --git a/pkg/internal/testtool/expected/expected.go b/pkg/internal/testtool/expected/expected.go new file mode 100644 index 00000000..c0e8e4a2 --- /dev/null +++ b/pkg/internal/testtool/expected/expected.go @@ -0,0 +1,9 @@ +// Package expected contains data shared between test helper and test harness. +package expected + +const ( + // Magic are magic bytes in the binfmt test case. + Magic = "\xca\xfe\xba\xbe\xfd\xfd" + // Full is the full content of the binfmt test case executable. + Full = Magic + ":3" +) diff --git a/pkg/internal/testtool/expected/sum_amd64.go b/pkg/internal/testtool/expected/sum_amd64.go new file mode 100644 index 00000000..354931be --- /dev/null +++ b/pkg/internal/testtool/expected/sum_amd64.go @@ -0,0 +1,11 @@ +package expected + +const ( + Offline = "IKmVhALejtYA3JhN1PCfjKF-Dio1XVcOeMWDHhfSHCbb4iBNhGjFy2UspZveZvb8" + OfflineS = "6ZubjSyCnz4-TKgQZi_BbY-JUwsStYCtD_f-BTDOOotfEbqkm47W20AazHRaLviz" + OvlRoot = "Yox7WpG8B_OqIXWF4HWFZkUfsEl7DGzpFyZM0qtd9T8nQ-7SAkEUurlAe6_DJ6Iu" + Layers = "aAwfxDB-efVo379FNl178jK7HEKU3LEjHbZdaVVjk7GnXGZGEANNsXnB3SCUxM9A" + Net = "IU3GwU2jTBd7HjotvFo2wsDWBQGVGpamwiSKdc2G1EnhM-ZaacxAuK6q0wbTVxra" + Promote = "rOjetGHzT_jPErrBR24IAZKDj8Q9dLLqxFQqloMgjsULss0VXDI--n6LxuMBHvmZ" + Work = "7f9XDeREfvJIjCe0qesND0s0tasExAIcc-OH33-eq9tsrQPUXhJp4KEkpsYH4hCD" +) diff --git a/pkg/internal/testtool/expected/sum_arm64.go b/pkg/internal/testtool/expected/sum_arm64.go new file mode 100644 index 00000000..2e6f7cbf --- /dev/null +++ b/pkg/internal/testtool/expected/sum_arm64.go @@ -0,0 +1,11 @@ +package expected + +const ( + Offline = "MTgCSzwPrzGigp9phvDPRwUTNGLm1KM2EsEtbz-l2cNieqjaGtnzm65CDXlSxXrl" + OfflineS = "p89bQg7_jR4nwaSbpEd08ne1jHOEw7Wlnlyv5FHq7ba1Kml3HSBZwEKRzu0gCqEb" + OvlRoot = "9ifwO6MAaV9kTqRR2O5PJzm8XhfXbr50mFLWyEHQm8OHHcJtAEUgXZzJYoZt9YVV" + Layers = "INuGActFPwJtS7mAR9Bkt7FD4yoHlXHSJ9CaSAQJrpkUBL_k815Cs_jvRfxy5-a7" + Net = "kJvy24uk4ePvHoxSKf4vMOhR4NxLbmyWlA9bTS7d3z3fE9WF1Tvpao9g-QuYU4vm" + Promote = "lYqyYrfjPawIbld5Qz0NbKvd_bmI3nJC1eEnlIQWzsjWE9uA1HuKPAaClnDeyG6L" + Work = "V6k4FqOBMRCmHiqchs5-joVGZ_MhNWZyXm3gdbU0S7S5eMze7G4SwYMTf66ojKQL" +) diff --git a/pkg/internal/testtool/expected/sum_riscv64.go b/pkg/internal/testtool/expected/sum_riscv64.go new file mode 100644 index 00000000..53872c08 --- /dev/null +++ b/pkg/internal/testtool/expected/sum_riscv64.go @@ -0,0 +1,11 @@ +package expected + +const ( + Offline = "LlieSHVDe1lnKvfI8t4oGRg_2hPxwyy1drmaqc-EKCOENuS9C2YHrp2VY5pSyhbg" + OfflineS = "qccjM8oYjUuEYPKFFzAxDFVzMerSUKUZc18NdVQoiPWf-70XOsNK8NaUQqjTgTAD" + OvlRoot = "lpV2bb_KGYFHPiOn-jtGop8K_ew0y1p3BezRp0ebM93_a-T9J-vkqGD5uokda1aN" + Layers = "sM3DJm210CHPGHkAquIbrXhspB81FpVC60ApQrvJGD5El2i7cSONj7LWf7EWqKpT" + Net = "JihQUwsVETRYjb2-yIXLYZ6WVsaFebwl8btSYAsNQ2QapS5MZVaI-dYwrVoySycR" + Promote = "lGqwR4W2OjylSVitMQP1lc6wApOBkbMjsmIqkxQbfEZ3DHEQBbJr84ErW6Xb54zo" + Work = "aZgtyv6aMtoHFkHAaJr6nLaA7N_SHjShgWPyQ5UdzaIqYSiVqvCrZqSokGTCcTii" +) diff --git a/pkg/internal/testtool/main.go b/pkg/internal/testtool/main.go new file mode 100644 index 00000000..edffab7e --- /dev/null +++ b/pkg/internal/testtool/main.go @@ -0,0 +1,277 @@ +//go:build testtool + +package main + +import ( + "encoding/gob" + "log" + "net" + "os" + "path/filepath" + "reflect" + "runtime" + "slices" + "strconv" + "strings" + + "hakurei.app/fhs" + "hakurei.app/vfs" + + "hakurei.app/pkg/internal/testtool/expected" +) + +func main() { + log.SetFlags(0) + log.SetPrefix("testtool: ") + + if os.Getenv("HAKUREI_BINFMT") == "1" { + wantArgs := []string{"/interpreter", "/opt/bin/sample"} + if !slices.Equal(os.Args, wantArgs) { + log.Fatalf("Args: %q, want %q", os.Args, wantArgs) + } + + if err := os.WriteFile("check", []byte("binfmt"), 0400); err != nil { + log.Fatal(err) + } + + return + } + + environ := slices.DeleteFunc(slices.Clone(os.Environ()), func(s string) bool { + for _, t := range []string{ + "CURE_JOBS=" + strconv.Itoa(runtime.NumCPU()), + "CURE_LOAD=" + strconv.Itoa(runtime.NumCPU()+2), + } { + if s == t { + return true + } + } + return false + }) + + var hostNet, layers, promote bool + if len(os.Args) == 2 && os.Args[0] == "testtool" { + switch os.Args[1] { + case "net": + hostNet = true + log.SetPrefix("testtool(net): ") + break + + case "layers": + layers = true + log.SetPrefix("testtool(layers): ") + break + + case "promote": + promote = true + log.SetPrefix("testtool(promote): ") + + default: + log.Fatalf("Args: %q", os.Args) + return + } + } else if wantArgs := []string{"testtool"}; !slices.Equal(os.Args, wantArgs) { + log.Fatalf("Args: %q, want %q", os.Args, wantArgs) + } + + var overlayRoot bool + wantEnv := []string{"HAKUREI_TEST=1"} + if len(environ) == 2 { + overlayRoot = true + if !layers && !promote { + log.SetPrefix("testtool(overlay root): ") + } + wantEnv = []string{"HAKUREI_TEST=1", "HAKUREI_ROOT=1"} + } + if !slices.Equal(wantEnv, environ) { + log.Fatalf("Environ: %q, want %q", environ, wantEnv) + } + + var overlayWork bool + const ( + wantExec = "/opt/bin/testtool" + wantExecWork = "/work/bin/testtool" + ) + var iftPath string + if got, err := os.Executable(); err != nil { + log.Fatalf("Executable: error = %v", err) + } else { + iftPath = filepath.Join(filepath.Dir(filepath.Dir(got)), "ift") + + if got != wantExec { + switch got { + case wantExecWork: + overlayWork = true + log.SetPrefix("testtool(overlay work): ") + + default: + log.Fatalf("Executable: %q, want %q", got, wantExec) + } + } + } + + wantHostname := "cure" + if hostNet { + wantHostname += "-net" + } + + if hostname, err := os.Hostname(); err != nil { + log.Fatalf("Hostname: error = %v", err) + } else if hostname != wantHostname { + log.Fatalf("Hostname: %q, want %q", hostname, wantHostname) + } + + var m *vfs.MountInfo + if f, err := os.Open(fhs.Proc + "self/mountinfo"); err != nil { + log.Fatalf("Open: error = %v", err) + } else { + err = vfs.NewMountInfoDecoder(f).Decode(&m) + closeErr := f.Close() + if err != nil { + log.Fatalf("Decode: error = %v", err) + } + if closeErr != nil { + log.Fatalf("Close: error = %v", err) + } + } + + if ift, err := net.Interfaces(); err != nil { + log.Fatal(err) + } else if !hostNet { + if len(ift) != 1 || ift[0].Name != "lo" { + log.Fatalln("got interfaces", strings.Join(slices.Collect(func(yield func(ifn string) bool) { + for _, ifi := range ift { + if !yield(ifi.Name) { + break + } + } + }), ", ")) + } + } else { + var iftParent []net.Interface + + var r *os.File + if r, err = os.Open(iftPath); err != nil { + log.Fatal(err) + } else { + err = gob.NewDecoder(r).Decode(&iftParent) + closeErr := r.Close() + if err != nil { + log.Fatal(err) + } + if closeErr != nil { + log.Fatal(closeErr) + } + } + + if !reflect.DeepEqual(ift, iftParent) { + log.Fatalf("Interfaces: %#v, want %#v", ift, iftParent) + } + } + + const checksumEmptyDir = "MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU" + ident := expected.Offline + log.Println(m) + next := func() { m = m.Next; log.Println(m) } + + if overlayRoot { + ident = expected.OvlRoot + + if m.Root != "/" || m.Target != "/" || + m.Source != "overlay" || m.FsType != "overlay" { + log.Fatal("unexpected root mount entry") + } + var lowerdir []string + for _, o := range strings.Split(m.FsOptstr, ",") { + const lowerdirKey = "lowerdir+=" + if strings.HasPrefix(o, lowerdirKey) { + lowerdir = append(lowerdir, o[len(lowerdirKey):]) + } + } + if !layers { + if len(lowerdir) != 1 || filepath.Base(lowerdir[0]) != checksumEmptyDir { + log.Fatal("unexpected artifact checksum") + } + } else { + ident = expected.Layers + + if len(lowerdir) != 2 || + filepath.Base(lowerdir[0]) != "MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU" || + filepath.Base(lowerdir[1]) != "nY_CUdiaUM1OL4cPr5TS92FCJ3rCRV7Hm5oVTzAvMXwC03_QnTRfQ5PPs7mOU9fK" { + log.Fatalf("unexpected lowerdirs %s", strings.Join(lowerdir, ", ")) + } + } + } else { + if hostNet { + ident = expected.Net + } + + if m.Root != "/sysroot" || m.Target != "/" { + log.Fatal("unexpected root mount entry") + } + + next() + if filepath.Base(m.Root) != "OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb" { + log.Fatal("unexpected file artifact checksum") + } + + next() + if filepath.Base(m.Root) != checksumEmptyDir { + log.Fatal("unexpected artifact checksum") + } + } + + if promote { + ident = expected.Promote + } + + next() // testtool artifact + + next() + if overlayWork { + ident = expected.Work + if m.Root != "/" || m.Target != "/work" || + m.Source != "overlay" || m.FsType != "overlay" { + log.Fatal("unexpected work mount entry") + } + } else { + if filepath.Base(m.Root) != ident || m.Target != "/work" { + log.Fatal("unexpected work mount entry") + } + } + + next() + if filepath.Base(m.Root) != ident || m.Target != "/tmp" { + log.Fatal("unexpected temp mount entry") + } + + next() + if m.Root != "/" || m.Target != "/proc" || m.Source != "proc" || m.FsType != "proc" { + log.Fatal("unexpected proc mount entry") + } + + next() + if m.Root != "/" || m.Target != "/dev" || m.Source != "devtmpfs" || m.FsType != "tmpfs" { + log.Fatal("unexpected dev mount entry") + } + + for i := 0; i < 9; i++ { // private /dev entries + next() + } + + if m.Next != nil { + log.Println("unexpected extra mount entries") + for m.Next != nil { + next() + } + os.Exit(1) + } + + checkData := []byte{0} + if hostNet { + checkData = []byte("net") + } + if err := os.WriteFile("check", checkData, 0400); err != nil { + log.Fatal(err) + } +} diff --git a/pkg/ir.go b/pkg/ir.go new file mode 100644 index 00000000..d9f984a7 --- /dev/null +++ b/pkg/ir.go @@ -0,0 +1,865 @@ +package pkg + +import ( + "bufio" + "bytes" + "crypto/sha512" + "encoding/binary" + "errors" + "fmt" + "io" + "iter" + "slices" + "strconv" + "sync" + "syscall" + "unique" + "unsafe" +) + +// wordSize is the boundary which binary segments are always aligned to. +const wordSize = 8 + +// alignSize returns the padded size for aligning sz. +func alignSize[T int | uint64](sz T) T { + return sz + (wordSize-(sz)%wordSize)%wordSize +} + +// panicToError recovers from a panic and replaces a nil error with the panicked +// error value. If the value does not implement error, it is re-panicked. +func panicToError(errP *error) { + r := recover() + if r == nil { + return + } + + if err, ok := r.(error); !ok { + panic(r) + } else if *errP == nil { + *errP = err + } +} + +// irCache implements [IRCache]. +type irCache struct { + // Artifact to [unique.Handle] of identifier cache. + artifact sync.Map + // Identifier free list, must not be accessed directly. + identPool sync.Pool +} + +// zeroIRCache returns the initialised value of irCache. +func zeroIRCache() irCache { + return irCache{ + identPool: sync.Pool{New: func() any { return new(extIdent) }}, + } +} + +// IRCache provides memory management and caching primitives for IR and +// identifier operations against [Artifact] implementations. +// +// The zero value is not safe for use. +type IRCache struct{ irCache } + +// NewIR returns the address of a new [IRCache]. +func NewIR() *IRCache { + return &IRCache{zeroIRCache()} +} + +// Inputs returns an iterator over direct and transitive inputs of an [Artifact] +// in randomised order. +func Inputs(a Artifact) iter.Seq2[Artifact, unique.Handle[ID]] { + ic := NewIR() + ic.Ident(a) + return func(yield func(Artifact, unique.Handle[ID]) bool) { + ic.artifact.Range(func(key, value any) bool { + return yield(key.(Artifact), value.(unique.Handle[ID])) + }) + } +} + +// IContext is passed to [Artifact.Params] and provides methods for writing +// values to the IR writer. It does not expose the underlying [io.Writer]. +// +// IContext is valid until [Artifact.Params] returns. +type IContext struct { + // Address of underlying irCache, should be zeroed or made unusable after + // [Artifact.Params] returns and must not be exposed directly. + ic *irCache + // Written to by various methods, should be zeroed after [Artifact.Params] + // returns and must not be exposed directly. + w io.Writer + // Optional [Artifact] to cureRes cache, replaces [IRKindIdent] with + // checksum values if non-nil. The pathname field may not be populated. + inputs map[Artifact]cureRes +} + +// irZero is a zero IR word. +var irZero [wordSize]byte + +// IRValueKind denotes the kind of encoded value. +type IRValueKind uint32 + +const ( + // IRKindEnd denotes the end of the current parameters stream. The ancillary + // value is interpreted as [IREndFlag]. + IRKindEnd IRValueKind = iota + // IRKindIdent denotes the identifier of a dependency [Artifact]. The + // ancillary value is reserved for future use. + IRKindIdent + // IRKindUint32 denotes an inlined uint32 value. + IRKindUint32 + // IRKindString denotes a string with its true length encoded in header + // ancillary data. Its wire length is always aligned to 8 byte boundary. + IRKindString + + irHeaderShift = 32 + irHeaderMask = 0xffffffff +) + +// String returns a user-facing name of k. +func (k IRValueKind) String() string { + switch k { + case IRKindEnd: + return "terminator" + case IRKindIdent: + return "ident" + case IRKindUint32: + return "uint32" + case IRKindString: + return "string" + default: + return "invalid kind " + strconv.Itoa(int(k)) + } +} + +// irValueHeader encodes [IRValueKind] and a 32-bit ancillary value. +type irValueHeader uint64 + +// encodeHeader returns irValueHeader encoding [IRValueKind] and ancillary data. +func (k IRValueKind) encodeHeader(v uint32) irValueHeader { + return irValueHeader(v)<<irHeaderShift | irValueHeader(k) +} + +// put stores h in b[0:8]. +func (h irValueHeader) put(b []byte) { + binary.LittleEndian.PutUint64(b[:], uint64(h)) +} + +// append appends the bytes of h to b and returns the appended slice. +func (h irValueHeader) append(b []byte) []byte { + return binary.LittleEndian.AppendUint64(b, uint64(h)) +} + +// IREndFlag is ancillary data encoded in the header of an [IRKindEnd] value and +// specifies the presence of optional fields in the remaining [IRKindEnd] data. +// Order of present fields is the order of their corresponding constants defined +// below. +type IREndFlag uint32 + +const ( + // IREndKnownChecksum denotes a [KnownChecksum] artifact. For an [IRKindEnd] + // value with this flag set, the remaining data contains the [Checksum]. + IREndKnownChecksum IREndFlag = 1 << iota +) + +// mustWrite writes to IContext.w and panics on error. The panic is recovered +// from by the caller and used as the return value. +func (i *IContext) mustWrite(p []byte) { + if _, err := i.w.Write(p); err != nil { + panic(err) + } +} + +// WriteIdent writes the identifier of [Artifact] to the IR. The behaviour of +// WriteIdent is not defined for an [Artifact] not part of the slice returned by +// [Artifact.Inputs]. +func (i *IContext) WriteIdent(a Artifact) { + buf := i.ic.getIdentBuf() + defer i.ic.putIdentBuf(buf) + + IRKindIdent.encodeHeader(0).put(buf[:]) + if i.inputs != nil { + res, ok := i.inputs[a] + if !ok { + panic(InvalidLookupError(i.ic.Ident(a).Value())) + } + *(*ID)(buf[wordSize:]) = res.checksum.Value() + } else { + *(*ID)(buf[wordSize:]) = i.ic.Ident(a).Value() + } + i.mustWrite(buf[:]) +} + +// WriteUint32 writes a uint32 value to the IR. +func (i *IContext) WriteUint32(v uint32) { + i.mustWrite(IRKindUint32.encodeHeader(v).append(nil)) +} + +// irMaxStringLength is the maximum acceptable wire size of [IRKindString]. +const irMaxStringLength = 1 << 24 + +// IRStringError is a string value too big to encode in IR. +type IRStringError string + +func (IRStringError) Error() string { + return "params value too big to encode in IR" +} + +// Write writes p as a string value to the IR. +func (i *IContext) Write(p []byte) { + sz := alignSize(len(p)) + if len(p) > irMaxStringLength || sz > irMaxStringLength { + panic(IRStringError(p)) + } + + i.mustWrite(IRKindString.encodeHeader(uint32(len(p))).append(nil)) + i.mustWrite(p) + + psz := sz - len(p) + if psz > 0 { + i.mustWrite(irZero[:psz]) + } +} + +// WriteString writes s as a string value to the IR. +func (i *IContext) WriteString(s string) { + p := unsafe.Slice(unsafe.StringData(s), len(s)) + i.Write(p) +} + +// Encode writes a deterministic, efficient representation of a to w and returns +// the first non-nil error encountered while writing to w. +func (ic *irCache) Encode(w io.Writer, a Artifact) (err error) { + return ic.encode(w, a, nil) +} + +// encode implements Encode but replaces identifiers with their cured checksums +// for a non-nil ident. The pathname field is unused. Caller must acquire +// Cache.identMu. +func (ic *irCache) encode( + w io.Writer, + a Artifact, + inputs map[Artifact]cureRes, +) (err error) { + deps := a.Inputs() + idents := make([]*extIdent, len(deps)) + if inputs == nil { + for i, d := range deps { + dbuf, did := ic.unsafeIdent(d, true) + if dbuf == nil { + dbuf = ic.getIdentBuf() + binary.LittleEndian.PutUint64(dbuf[:], uint64(d.Kind())) + *(*ID)(dbuf[wordSize:]) = did.Value() + } else { + ic.storeIdent(d, dbuf) + } + defer ic.putIdentBuf(dbuf) + idents[i] = dbuf + } + } else { + for i, d := range deps { + res, ok := inputs[d] + if !ok { + return InvalidLookupError(ic.Ident(d).Value()) + } + + dbuf := ic.getIdentBuf() + binary.LittleEndian.PutUint64(dbuf[:], uint64(d.Kind())) + *(*ID)(dbuf[wordSize:]) = res.checksum.Value() + defer ic.putIdentBuf(dbuf) + idents[i] = dbuf + } + } + slices.SortFunc(idents, func(a, b *extIdent) int { + return bytes.Compare(a[:], b[:]) + }) + idents = slices.CompactFunc(idents, func(a, b *extIdent) bool { + return *a == *b + }) + + // kind uint64 | rev uint64 | deps_sz uint64 + var buf [wordSize * 3]byte + binary.LittleEndian.PutUint64(buf[:], uint64(a.Kind())) + binary.LittleEndian.PutUint64(buf[wordSize:], GetRevision(a)) + binary.LittleEndian.PutUint64(buf[wordSize*2:], uint64(len(idents))) + if _, err = w.Write(buf[:]); err != nil { + return + } + + for _, dn := range idents { + // kind uint64 | ident ID + if _, err = w.Write(dn[:]); err != nil { + return + } + } + + func() { + i := IContext{ic, w, inputs} + + defer panicToError(&err) + defer func() { i.ic, i.w = nil, nil }() + + a.Params(&i) + }() + if err != nil { + return + } + + var f IREndFlag + kcBuf := ic.getIdentBuf() + sz := wordSize + if kc, ok := a.(KnownChecksum); ok { + f |= IREndKnownChecksum + *(*Checksum)(kcBuf[wordSize:]) = kc.Checksum() + sz += len(Checksum{}) + } + IRKindEnd.encodeHeader(uint32(f)).put(kcBuf[:]) + + _, err = w.Write(kcBuf[:sz]) + ic.putIdentBuf(kcBuf) + return +} + +// encodeAll implements EncodeAll by recursively encoding dependencies and +// performs deduplication by value via the encoded map. +func (ic *irCache) encodeAll( + w io.Writer, + a Artifact, + encoded map[Artifact]struct{}, +) (err error) { + if _, ok := encoded[a]; ok { + return + } + + for _, d := range a.Inputs() { + if err = ic.encodeAll(w, d, encoded); err != nil { + return + } + } + + encoded[a] = struct{}{} + return ic.Encode(w, a) +} + +// EncodeAll writes a self-describing IR stream of a to w and returns the first +// non-nil error encountered while writing to w. +// +// EncodeAll tries to avoid encoding the same [Artifact] more than once, however +// it will fail to do so if they do not compare equal by value, as that will +// require buffering and greatly reduce performance. It is therefore up to the +// caller to avoid causing dependencies to be represented in a way such that +// two equivalent artifacts do not compare equal. While an IR stream with +// repeated artifacts is valid, it is somewhat inefficient, and the reference +// [IRDecoder] implementation produces a warning for it. +// +// Note that while EncodeAll makes use of the ident free list, it does not use +// the ident cache, nor does it contribute identifiers it computes back to the +// ident cache. Because of this, multiple invocations of EncodeAll will have +// similar cost and does not amortise when combined with a call to Cure. +func (ic *irCache) EncodeAll(w io.Writer, a Artifact) error { + return ic.encodeAll(w, a, make(map[Artifact]struct{})) +} + +// ErrRemainingIR is returned for a [IRReadFunc] that failed to call +// [IRReader.Finalise] before returning. +var ErrRemainingIR = errors.New("implementation did not consume final value") + +// DanglingIdentError is an identifier in a [IRKindIdent] value that was never +// described in the IR stream before it was encountered. +type DanglingIdentError unique.Handle[ID] + +func (e DanglingIdentError) Error() string { + return "artifact " + Encode(unique.Handle[ID](e).Value()) + + " was never described" +} + +type ( + // IRDecoder decodes [Artifact] from an IR stream. The stream is read to + // EOF and the final [Artifact] is returned. Previous artifacts may be + // looked up by their identifier. + // + // An [Artifact] may appear more than once in the same IR stream. A + // repeating [Artifact] generates a warning via [Cache] and will appear if + // verbose logging is enabled. Artifacts may only depend on artifacts + // previously described in the IR stream. + // + // IRDecoder rejects an IR stream on the first decoding error, it does not + // check against nonzero reserved ancillary data or incorrectly ordered or + // redundant unstructured dependencies. An invalid IR stream as such will + // yield [Artifact] values with identifiers disagreeing with those computed + // by IRDecoder. For this reason, IRDecoder does not access the ident cache + // to avoid putting [Cache] into an inconsistent state. + // + // Methods of IRDecoder are not safe for concurrent use. + IRDecoder struct { + // Address of underlying [Cache], must not be exposed directly. + c *Cache + + // Underlying IR reader. Methods of [IRReader] must not use this as it + // bypasses ident measurement. + r io.Reader + // Artifacts already seen in the IR stream. + ident map[unique.Handle[ID]]Artifact + + // Whether Decode returned, and the entire IR stream was decoded. + done, ok bool + } + + // IRReader provides methods to decode the IR wire format and read values + // from the reader embedded in the underlying [IRDecoder]. It is + // deliberately impossible to obtain the [IRValueKind] of the next value, + // and callers must never recover from panics in any read method. + // + // It is the responsibility of the caller to call Finalise after all IR + // values have been read. Failure to call Finalise causes the resulting + // [Artifact] to be rejected with [ErrRemainingIR]. + // + // For an [Artifact] expected to have dependencies, the caller must consume + // all dependencies by calling Next until all dependencies are depleted, or + // call DiscardAll to explicitly discard them and rely on values encoded as + // [IRKindIdent] instead. Failure to consume all unstructured dependencies + // causes the resulting [Artifact] to be rejected with [MissedDependencyError]. + // + // Requesting the value of an unstructured dependency not yet described in + // the IR stream via Next, or reading an [IRKindIdent] value not part of + // unstructured dependencies via ReadIdent may cause the resulting + // [Artifact] to be rejected with [DanglingIdentError], however either + // method may return a non-nil [Artifact] implementation of unspecified + // value. + IRReader struct { + // Address of underlying [IRDecoder], should be zeroed or made unusable + // after finalisation and must not be exposed directly. + d *IRDecoder + // Common buffer for word-sized reads. + buf [wordSize]byte + + // Inputs sent before params, sorted by identifier. Resliced on + // each call to Next and checked to be depleted during Finalise. + inputs []*extIdent + + // Number of values already read, -1 denotes a finalised IRReader. + count int + // Header of value currently being read. + h irValueHeader + + // Measured IR reader. All reads for the current [Artifact] must go + // through this to produce a correct ident. + r io.Reader + // Buffers measure writes. Flushed and returned to d during Finalise. + ibw *bufio.Writer + } + + // IRReadFunc reads IR values written by [Artifact.Params] to produce an + // instance of [Artifact] identical to the one to produce these values. + IRReadFunc func(r *IRReader) Artifact +) + +// kind returns the [IRValueKind] encoded in h. +func (h irValueHeader) kind() IRValueKind { + return IRValueKind(h & irHeaderMask) +} + +// value returns ancillary data encoded in h. +func (h irValueHeader) value() uint32 { + return uint32(h >> irHeaderShift) +} + +// irArtifact refers to artifact IR interpretation functions and must not be +// written to directly. +var irArtifact = make(map[Kind]IRReadFunc) + +// InvalidKindError is an unregistered [Kind] value. +type InvalidKindError Kind + +func (e InvalidKindError) Error() string { + return "invalid artifact kind " + strconv.Itoa(int(e)) +} + +// register records the [IRReadFunc] of an implementation of [Artifact] under +// the specified [Kind]. Expecting to be used only during initialization, it +// panics if the mapping between [Kind] and [IRReadFunc] is not a bijection. +// +// register is not safe for concurrent use. register must not be called after +// the first instance of [Cache] has been opened. +func register(k Kind, f IRReadFunc) { + openMu.Lock() + defer openMu.Unlock() + + if opened { + panic("attempting to register after open") + } + if _, ok := irArtifact[k]; ok { + panic("attempting to register " + strconv.Itoa(int(k)) + " twice") + } + irArtifact[k] = f +} + +// Register records the [IRReadFunc] of a custom implementation of [Artifact] +// under the specified [Kind]. Expecting to be used only during initialization, +// it panics if the mapping between [Kind] and [IRReadFunc] is not a bijection, +// or the specified [Kind] is below [KindCustomOffset]. +// +// Register is not safe for concurrent use. Register must not be called after +// the first instance of [Cache] has been opened. +func Register(k Kind, f IRReadFunc) { + if k < KindCustomOffset { + panic("attempting to register within internal kind range") + } + register(k, f) +} + +// NewDecoder returns a new [IRDecoder] that reads from the [io.Reader]. +func (c *Cache) NewDecoder(r io.Reader) *IRDecoder { + return &IRDecoder{c, r, make(map[unique.Handle[ID]]Artifact), false, false} +} + +const ( + // irMaxValues is the arbitrary maximum number of values allowed to be + // written by [Artifact.Params] and subsequently read via [IRReader]. + irMaxValues = 1 << 12 + + // irMaxDeps is the arbitrary maximum number of direct dependencies allowed + // to be returned by [Artifact.Inputs] and subsequently decoded by + // [IRDecoder]. + irMaxDeps = 1 << 10 +) + +var ( + // ErrIRValues is returned for an [Artifact] with too many parameter values. + ErrIRValues = errors.New("artifact has too many IR parameter values") + + // ErrIRDepend is returned for an [Artifact] with too many dependencies. + ErrIRDepend = errors.New("artifact has too many dependencies") + + // ErrAlreadyFinalised is returned when attempting to use an [IRReader] that + // has already been finalised. + ErrAlreadyFinalised = errors.New("reader has already finalised") +) + +// enterReader panics with an appropriate error for an out-of-bounds count and +// must be called at some point in any exported method. +func (ir *IRReader) enterReader(read bool) { + if ir.count < 0 { + panic(ErrAlreadyFinalised) + } + if ir.count >= irMaxValues { + panic(ErrIRValues) + } + + if read { + ir.count++ + } +} + +// IRKindError describes an attempt to read an IR value of unexpected kind. +type IRKindError struct { + Got, Want IRValueKind + Ancillary uint32 +} + +func (e *IRKindError) Error() string { + return fmt.Sprintf( + "got %s IR value (%#x) instead of %s", + e.Got, e.Ancillary, e.Want, + ) +} + +// readFull reads until either p is filled or an error is encountered. +func (ir *IRReader) readFull(p []byte) (n int, err error) { + for n < len(p) && err == nil { + var nn int + nn, err = ir.r.Read(p[n:]) + n += nn + } + return +} + +// mustRead reads from the underlying measured reader and panics on error. If +// an [io.EOF] is encountered and n != len(p), the error is promoted to a +// [io.ErrUnexpectedEOF], if n == 0, [io.EOF] is kept as is, otherwise it is +// zeroed. +func (ir *IRReader) mustRead(p []byte) { + n, err := ir.readFull(p) + if err == nil { + return + } + + if errors.Is(err, io.EOF) { + if n == len(p) { + return + } + err = io.ErrUnexpectedEOF + } + panic(err) +} + +// mustReadHeader reads the next header via d and checks its kind. +func (ir *IRReader) mustReadHeader(k IRValueKind) { + ir.mustRead(ir.buf[:]) + ir.h = irValueHeader(binary.LittleEndian.Uint64(ir.buf[:])) + if wk := ir.h.kind(); wk != k { + panic(&IRKindError{wk, k, ir.h.value()}) + } +} + +// putAll returns all dependency buffers to the underlying [Cache]. +func (ir *IRReader) putAll() { + for _, buf := range ir.inputs { + ir.d.c.putIdentBuf(buf) + } + ir.inputs = nil +} + +// DiscardAll discards all unstructured dependencies. This is useful to +// implementations that encode dependencies as [IRKindIdent] which are read back +// via ReadIdent. +func (ir *IRReader) DiscardAll() { + if ir.inputs == nil { + panic("attempting to discard dependencies twice") + } + ir.putAll() +} + +// ErrDependencyDepleted is returned when attempting to advance to the next +// unstructured dependency when there are none left. +var ErrDependencyDepleted = errors.New("reading past end of dependencies") + +// Next returns the next unstructured dependency. +func (ir *IRReader) Next() Artifact { + if len(ir.inputs) == 0 { + panic(ErrDependencyDepleted) + } + + id := unique.Make(ID(ir.inputs[0][wordSize:])) + ir.d.c.putIdentBuf(ir.inputs[0]) + ir.inputs = ir.inputs[1:] + + if a, ok := ir.d.ident[id]; !ok { + ir.putAll() + panic(DanglingIdentError(id)) + } else { + return a + } +} + +// MissedDependencyError is the number of unstructured dependencies remaining +// in [IRReader] that was never requested or explicitly discarded before +// finalisation. +type MissedDependencyError int + +func (e MissedDependencyError) Error() string { + return "missed " + strconv.Itoa(int(e)) + " unstructured dependencies" +} + +var ( + // ErrUnexpectedChecksum is returned by a [IRReadFunc] that does not expect + // a checksum but received one in [IRKindEnd] anyway. + ErrUnexpectedChecksum = errors.New("checksum specified on unsupported artifact") + // ErrExpectedChecksum is returned by a [IRReadFunc] that expects a checksum + // but did not receive one in [IRKindEnd]. + ErrExpectedChecksum = errors.New("checksum required but not specified") +) + +// Finalise reads the final [IRKindEnd] value and marks r as finalised. Methods +// of r are invalid upon entry into Finalise. If a [Checksum] is available via +// [IREndKnownChecksum], its handle is returned and the caller must store its +// value in the resulting [Artifact]. +func (ir *IRReader) Finalise() (checksum unique.Handle[Checksum], ok bool) { + ir.enterReader(true) + ir.count = -1 + + ir.mustReadHeader(IRKindEnd) + f := IREndFlag(ir.h.value()) + + if f&IREndKnownChecksum != 0 { + buf := ir.d.c.getIdentBuf() + defer ir.d.c.putIdentBuf(buf) + + ir.mustRead(buf[wordSize:]) + checksum = unique.Make(Checksum(buf[wordSize:])) + ok = true + } + + if err := ir.ibw.Flush(); err != nil { + panic(err) + } + ir.r, ir.ibw = nil, nil + + if len(ir.inputs) != 0 { + panic(MissedDependencyError(len(ir.inputs))) + } + + return +} + +// ReadIdent reads the next value as [IRKindIdent]. +func (ir *IRReader) ReadIdent() Artifact { + ir.enterReader(true) + ir.mustReadHeader(IRKindIdent) + + buf := ir.d.c.getIdentBuf() + defer ir.d.c.putIdentBuf(buf) + + ir.mustRead(buf[wordSize:]) + id := unique.Make(ID(buf[wordSize:])) + + if a, ok := ir.d.ident[id]; !ok { + panic(DanglingIdentError(id)) + } else { + return a + } +} + +// ReadUint32 reads the next value as [IRKindUint32]. +func (ir *IRReader) ReadUint32() uint32 { + ir.enterReader(true) + ir.mustReadHeader(IRKindUint32) + return ir.h.value() +} + +// ReadStringBytes reads the next value as [IRKindString] but returns it as a +// byte slice instead. +func (ir *IRReader) ReadStringBytes() []byte { + ir.enterReader(true) + ir.mustReadHeader(IRKindString) + + sz := int(ir.h.value()) + szWire := alignSize(sz) + if szWire > irMaxStringLength { + panic(IRStringError("\x00")) + } + + p := make([]byte, szWire) + ir.mustRead(p) + return p[:sz] +} + +// ReadString reads the next value as [IRKindString]. +func (ir *IRReader) ReadString() string { + p := ir.ReadStringBytes() + return unsafe.String(unsafe.SliceData(p), len(p)) +} + +// A RevisionError describes the first [Artifact] in an IR stream claiming a +// revision not supported by the registered implementation. +type RevisionError [2]uint64 + +func (e RevisionError) Error() string { + return "claimed revision " + strconv.FormatUint(e[0], 10) + + " differs from implementation value " + strconv.FormatUint(e[1], 10) +} + +// decode decodes the next [Artifact] in the IR stream and returns any buffer +// originating from [Cache] before returning. decode returns [io.EOF] if and +// only if the underlying [io.Reader] is already read to EOF. +func (d *IRDecoder) decode() (a Artifact, err error) { + defer panicToError(&err) + var ir IRReader + + defer func() { ir.d = nil }() + ir.d = d + + h := sha512.New384() + ir.ibw = d.c.getWriter(h) + defer d.c.putWriter(ir.ibw) + ir.r = io.TeeReader(d.r, ir.ibw) + + if n, _err := ir.readFull(ir.buf[:]); _err != nil { + if errors.Is(_err, io.EOF) { + if n != 0 { + _err = io.ErrUnexpectedEOF + } + } + + err = _err + return + } + ak := Kind(binary.LittleEndian.Uint64(ir.buf[:])) + f, ok := irArtifact[ak] + if !ok { + err = InvalidKindError(ak) + return + } + + ir.mustRead(ir.buf[:]) + rev := binary.LittleEndian.Uint64(ir.buf[:]) + + defer ir.putAll() + ir.mustRead(ir.buf[:]) + sz := binary.LittleEndian.Uint64(ir.buf[:]) + if sz > irMaxDeps { + err = ErrIRDepend + return + } + ir.inputs = make([]*extIdent, sz) + for i := range ir.inputs { + ir.inputs[i] = d.c.getIdentBuf() + } + for _, buf := range ir.inputs { + ir.mustRead(buf[:]) + } + + a = f(&ir) + if a == nil { + err = syscall.ENOTRECOVERABLE + return + } + if _rev := GetRevision(a); _rev != rev { + err = RevisionError{rev, _rev} + return + } + + if ir.count != -1 { + err = ErrRemainingIR + return + } + + buf := d.c.getIdentBuf() + h.Sum(buf[wordSize:wordSize]) + id := unique.Make(ID(buf[wordSize:])) + d.c.putIdentBuf(buf) + if _, ok = d.ident[id]; !ok { + d.ident[id] = a + } else { + d.c.msg.Verbosef( + "artifact %s%s%s appeared more than once in IR stream", + d.c.sgrIdent, Encode(id.Value()), d.c.sgrRes, + ) + } + + return +} + +// Decode consumes the IR stream to EOF and returns the final [Artifact]. After +// Decode returns, Lookup is available and Decode must not be called again. +func (d *IRDecoder) Decode() (a Artifact, err error) { + if d.done { + panic("attempting to decode an IR stream twice") + } + defer func() { d.done = true }() + + var cur Artifact +next: + a, err = d.decode() + + if err == nil { + cur = a + goto next + } + + if errors.Is(err, io.EOF) { + a, err = cur, nil + d.ok = true + } + return +} + +// Lookup looks up an [Artifact] described by the IR stream by its identifier. +func (d *IRDecoder) Lookup(id unique.Handle[ID]) (a Artifact, ok bool) { + if !d.ok { + panic("attempting to look up artifact without full IR stream") + } + a, ok = d.ident[id] + return +} diff --git a/pkg/ir_test.go b/pkg/ir_test.go new file mode 100644 index 00000000..9cc024ae --- /dev/null +++ b/pkg/ir_test.go @@ -0,0 +1,170 @@ +package pkg_test + +import ( + "bytes" + "io" + "io/fs" + "reflect" + "testing" + + "hakurei.app/check" + "hakurei.app/pkg" +) + +func TestIRRoundtrip(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + a pkg.Artifact + }{ + {"http get aligned", pkg.NewHTTPGet( + nil, "file:///testdata", + pkg.Checksum(bytes.Repeat([]byte{0xfd}, len(pkg.Checksum{}))), + )}, + {"http get unaligned", pkg.NewHTTPGet( + nil, "https://hakurei.app", + pkg.Checksum(bytes.Repeat([]byte{0xfc}, len(pkg.Checksum{}))), + )}, + + {"http get tar", pkg.NewTar(pkg.NewDecompress(pkg.NewHTTPGet( + nil, "file:///testdata", + pkg.Checksum(bytes.Repeat([]byte{0xff}, len(pkg.Checksum{}))), + ), pkg.Bzip2))}, + {"http get tar unaligned", pkg.NewTar(pkg.NewHTTPGet( + nil, "https://hakurei.app", + pkg.Checksum(bytes.Repeat([]byte{0xfe}, len(pkg.Checksum{}))), + ))}, + + {"exec offline", pkg.NewExec( + "exec-offline", "", nil, 0, false, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool"}, + + pkg.MustPath("/file", false, pkg.NewFile("file", []byte( + "stub file", + ))), pkg.MustPath("/.hakurei", false, pkg.NewTar(pkg.NewHTTPGet( + nil, "file:///hakurei.tar", + pkg.Checksum(bytes.Repeat([]byte{0xfc}, len(pkg.Checksum{}))), + ))), pkg.MustPath("/opt", false, pkg.NewTar(pkg.NewDecompress(pkg.NewHTTPGet( + nil, "file:///testtool.tar.gz", + pkg.Checksum(bytes.Repeat([]byte{0xfc}, len(pkg.Checksum{}))), + ), pkg.Gzip))), + )}, + + {"exec net", pkg.NewExec( + "exec-net", "", + (*pkg.Checksum)(bytes.Repeat([]byte{0xfc}, len(pkg.Checksum{}))), + 0, false, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool", "net"}, + + pkg.MustPath("/file", false, pkg.NewFile("file", []byte( + "stub file", + ))), pkg.MustPath("/.hakurei", false, pkg.NewTar(pkg.NewHTTPGet( + nil, "file:///hakurei.tar", + pkg.Checksum(bytes.Repeat([]byte{0xfc}, len(pkg.Checksum{}))), + ))), pkg.MustPath("/opt", false, pkg.NewTar(pkg.NewDecompress(pkg.NewHTTPGet( + nil, "file:///testtool.tar.gz", + pkg.Checksum(bytes.Repeat([]byte{0xfc}, len(pkg.Checksum{}))), + ), pkg.Gzip))), + )}, + + {"exec measured", pkg.NewExec( + "exec-measured", "", + (*pkg.Checksum)(bytes.Repeat([]byte{0xfd}, len(pkg.Checksum{}))), + 0, false, false, + pkg.AbsWork, + []string{"HAKUREI_TEST=1"}, + check.MustAbs("/opt/bin/testtool"), + []string{"testtool", "measured"}, + + pkg.MustPath("/file", false, pkg.NewFile("file", []byte( + "stub file", + ))), pkg.MustPath("/.hakurei", false, pkg.NewTar(pkg.NewHTTPGet( + nil, "file:///hakurei.tar", + pkg.Checksum(bytes.Repeat([]byte{0xfd}, len(pkg.Checksum{}))), + ))), pkg.MustPath("/opt", false, pkg.NewTar(pkg.NewDecompress(pkg.NewHTTPGet( + nil, "file:///testtool.tar.gz", + pkg.Checksum(bytes.Repeat([]byte{0xfd}, len(pkg.Checksum{}))), + ), pkg.Gzip))), + )}, + + {"file anonymous", pkg.NewFile("", []byte{0})}, + {"file", pkg.NewFile("stub", []byte("stub"))}, + + {"decompress", pkg.NewDecompress(pkg.NewFile("", []byte{0}), pkg.Bzip2)}, + + {"archive", pkg.NewArchive(pkg.NewFile("", []byte{0}))}, + } + testCasesCache := make([]cacheTestCase, len(testCases)) + for i, tc := range testCases { + want := tc.a + testCasesCache[i] = cacheTestCase{tc.name, 0, nil, + func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + r, w := io.Pipe() + + done := make(chan error, 1) + go func() { + t.Helper() + done <- c.EncodeAll(w, want) + _ = w.Close() + }() + + if got, err := c.NewDecoder(r).Decode(); err != nil { + t.Fatalf("Decode: error = %v", err) + } else if !reflect.DeepEqual(got, want) { + t.Fatalf("Decode: %#v, want %#v", got, want) + } + + if err := <-done; err != nil { + t.Fatalf("EncodeAll: error = %v", err) + } + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + "checksum": {Mode: fs.ModeDir | 0700}, + "identifier": {Mode: fs.ModeDir | 0700}, + "substitute": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }, + } + } + checkWithCache(t, testCasesCache) +} + +func TestRevision(t *testing.T) { + checkWithCache(t, []cacheTestCase{{"revision", 0, nil, + func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + a := pkg.NewFile("", nil) + var buf bytes.Buffer + if err := c.EncodeAll(&buf, a); err != nil { + t.Fatal(err) + } + + if _, err := c.NewDecoder( + bytes.NewReader(buf.Bytes()), + ).Decode(); err != nil { + t.Fatalf("Decode: error = %v", err) + } + + p := buf.Bytes() + p[8] = 0xfd + wantErr := pkg.RevisionError{0xfd, 0} + if _, err := c.NewDecoder( + bytes.NewReader(p), + ).Decode(); !reflect.DeepEqual(err, wantErr) { + t.Fatalf("Decode: error = %v, want %v", err, wantErr) + } + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + "checksum": {Mode: fs.ModeDir | 0700}, + "identifier": {Mode: fs.ModeDir | 0700}, + "substitute": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }, + }}) +} diff --git a/pkg/net.go b/pkg/net.go new file mode 100644 index 00000000..cd30e3bb --- /dev/null +++ b/pkg/net.go @@ -0,0 +1,109 @@ +package pkg + +import ( + "fmt" + "io" + "net/http" + "path" + "unique" +) + +// An httpArtifact is an [Artifact] backed by a [http] url string. The method is +// hardcoded as [http.MethodGet]. Request body is not allowed because it cannot +// be deterministically represented by Params. +type httpArtifact struct { + // Caller-supplied url string. + url string + + // Caller-supplied checksum of the response body. This is validated when + // closing the [io.ReadCloser] returned by Cure. + checksum unique.Handle[Checksum] + + // client is the address of the caller-supplied [http.Client]. + client *http.Client +} + +var _ KnownChecksum = new(httpArtifact) +var _ fmt.Stringer = new(httpArtifact) + +// NewHTTPGet returns a new [FileArtifact] backed by the supplied client. A GET +// request is set up for url. If c is nil, [http.DefaultClient] is used instead. +func NewHTTPGet( + c *http.Client, + url string, + checksum Checksum, +) FileArtifact { + return &httpArtifact{url: url, checksum: unique.Make(checksum), client: c} +} + +// Kind returns the hardcoded [Kind] constant. +func (*httpArtifact) Kind() Kind { return KindHTTPGet } + +// Params writes the backing url string. Client is not represented as it does +// not affect [Cache.Cure] outcome. +func (a *httpArtifact) Params(ctx *IContext) { ctx.WriteString(a.url) } + +func init() { + register(KindHTTPGet, func(r *IRReader) Artifact { + url := r.ReadString() + checksum, ok := r.Finalise() + if !ok { + panic(ErrExpectedChecksum) + } + return NewHTTPGet(nil, url, checksum.Value()) + }) +} + +// Inputs returns a nil slice. +func (*httpArtifact) Inputs() []Artifact { return nil } + +// IsExclusive returns false: Cure returns as soon as a response is received. +func (*httpArtifact) IsExclusive() bool { return false } + +// Checksum returns the caller-supplied checksum. +func (a *httpArtifact) Checksum() Checksum { return a.checksum.Value() } + +// String returns [path.Base] over the backing url. +func (a *httpArtifact) String() string { return path.Base(a.url) } + +// ResponseStatusError is returned for a response returned by an [http.Client] +// with a status code other than [http.StatusOK]. +type ResponseStatusError int + +func (e ResponseStatusError) Error() string { + return "the requested URL returned non-OK status: " + http.StatusText(int(e)) +} + +// IsExecutable returns false. +func (*httpArtifact) IsExecutable() bool { return false } + +// Cure sends the http request and returns the resulting response body reader +// wrapped to perform checksum validation. It is valid but not encouraged to +// close the resulting [io.ReadCloser] before it is read to EOF, as that causes +// Close to block until all remaining data is consumed and validated. +func (a *httpArtifact) Cure(r *RContext) (rc io.ReadCloser, err error) { + var req *http.Request + req, err = http.NewRequestWithContext(r.Unwrap(), http.MethodGet, a.url, nil) + if err != nil { + return + } + req.Header.Set("User-Agent", "Hakurei/1.1") + + c := a.client + if c == nil { + c = http.DefaultClient + } + + var resp *http.Response + if resp, err = c.Do(req); err != nil { + return + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, ResponseStatusError(resp.StatusCode) + } + + rc = r.NewMeasuredReader(resp.Body, a.checksum) + return +} diff --git a/pkg/net_test.go b/pkg/net_test.go new file mode 100644 index 00000000..b16a2019 --- /dev/null +++ b/pkg/net_test.go @@ -0,0 +1,168 @@ +package pkg_test + +import ( + "crypto/sha512" + "io" + "io/fs" + "net/http" + "reflect" + "testing" + "testing/fstest" + "unique" + + "hakurei.app/check" + "hakurei.app/pkg" +) + +func TestHTTPGet(t *testing.T) { + t.Parallel() + + const testdata = "\x7f\xe1\x69\xa2\xdd\x63\x96\x26\x83\x79\x61\x8b\xf0\x3f\xd5\x16\x9a\x39\x3a\xdb\xcf\xb1\xbc\x8d\x33\xff\x75\xee\x62\x56\xa9\xf0\x27\xac\x13\x94\x69" + + testdataChecksum := func() unique.Handle[pkg.Checksum] { + h := sha512.New384() + h.Write([]byte(testdata)) + return unique.Make(pkg.Checksum(h.Sum(nil))) + }() + + var transport http.Transport + client := http.Client{Transport: &transport} + transport.RegisterProtocol("file", http.NewFileTransportFS(fstest.MapFS{ + "testdata": {Data: []byte(testdata), Mode: 0400}, + })) + + checkWithCache(t, []cacheTestCase{ + {"direct", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + r := newRContext(t, c) + f := pkg.NewHTTPGet( + &client, + "file:///testdata", + testdataChecksum.Value(), + ) + var got []byte + if rc, err := f.Cure(r); err != nil { + t.Fatalf("Cure: error = %v", err) + } else if got, err = io.ReadAll(rc); err != nil { + t.Fatalf("ReadAll: error = %v", err) + } else if string(got) != testdata { + t.Fatalf("Cure: %x, want %x", got, testdata) + } else if err = rc.Close(); err != nil { + t.Fatalf("Close: error = %v", err) + } + + // check direct validation + f = pkg.NewHTTPGet( + &client, + "file:///testdata", + pkg.Checksum{}, + ) + wantErrMismatch := &pkg.ChecksumMismatchError{ + Got: testdataChecksum.Value(), + } + if rc, err := f.Cure(r); err != nil { + t.Fatalf("Cure: error = %v", err) + } else if got, err = io.ReadAll(rc); err != nil { + t.Fatalf("ReadAll: error = %v", err) + } else if string(got) != testdata { + t.Fatalf("Cure: %x, want %x", got, testdata) + } else if err = rc.Close(); !reflect.DeepEqual(err, wantErrMismatch) { + t.Fatalf("Close: error = %#v, want %#v", err, wantErrMismatch) + } + + // check fallback validation + if rc, err := f.Cure(r); err != nil { + t.Fatalf("Cure: error = %v", err) + } else if err = rc.Close(); !reflect.DeepEqual(err, wantErrMismatch) { + t.Fatalf("Close: error = %#v, want %#v", err, wantErrMismatch) + } + + // check direct response error + f = pkg.NewHTTPGet( + &client, + "file:///nonexistent", + pkg.Checksum{}, + ) + wantErrNotFound := pkg.ResponseStatusError(http.StatusNotFound) + if _, err := f.Cure(r); !reflect.DeepEqual(err, wantErrNotFound) { + t.Fatalf("Cure: error = %#v, want %#v", err, wantErrNotFound) + } + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + "checksum": {Mode: fs.ModeDir | 0700}, + "identifier": {Mode: fs.ModeDir | 0700}, + "substitute": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"cure", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + r := newRContext(t, c) + + f := pkg.NewHTTPGet( + &client, + "file:///testdata", + testdataChecksum.Value(), + ) + wantPathname := base.Append( + "identifier", + "COBRUwkxpIvZWJggpRxn7HVXiw6epXZOex8jGRJKpowpwukV7GRBY_LBvOJNQHue", + ) + if pathname, checksum, err := c.Cure(f); err != nil { + t.Fatalf("Cure: error = %v", err) + } else if !pathname.Is(wantPathname) { + t.Fatalf("Cure: %q, want %q", pathname, wantPathname) + } else if checksum != testdataChecksum { + t.Fatalf("Cure: %x, want %x", checksum.Value(), testdataChecksum.Value()) + } + + var got []byte + if rc, err := f.Cure(r); err != nil { + t.Fatalf("Cure: error = %v", err) + } else if got, err = io.ReadAll(rc); err != nil { + t.Fatalf("ReadAll: error = %v", err) + } else if string(got) != testdata { + t.Fatalf("Cure: %x, want %x", got, testdata) + } else if err = rc.Close(); err != nil { + t.Fatalf("Close: error = %v", err) + } + + // check load from cache + f = pkg.NewHTTPGet( + &client, + "file:///testdata", + testdataChecksum.Value(), + ) + if rc, err := f.Cure(r); err != nil { + t.Fatalf("Cure: error = %v", err) + } else if got, err = io.ReadAll(rc); err != nil { + t.Fatalf("ReadAll: error = %v", err) + } else if string(got) != testdata { + t.Fatalf("Cure: %x, want %x", got, testdata) + } else if err = rc.Close(); err != nil { + t.Fatalf("Close: error = %v", err) + } + + // check error passthrough + f = pkg.NewHTTPGet( + &client, + "file:///nonexistent", + pkg.Checksum{}, + ) + wantErrNotFound := pkg.ResponseStatusError(http.StatusNotFound) + if _, _, err := c.Cure(f); !reflect.DeepEqual(err, wantErrNotFound) { + t.Fatalf("Pathname: error = %#v, want %#v", err, wantErrNotFound) + } + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/fLYGIMHgN1louE-JzITJZJo2SDniPu-IHBXubtvQWFO-hXnDVKNuscV7-zlyr5fU": {Mode: 0400, Data: []byte("\x7f\xe1\x69\xa2\xdd\x63\x96\x26\x83\x79\x61\x8b\xf0\x3f\xd5\x16\x9a\x39\x3a\xdb\xcf\xb1\xbc\x8d\x33\xff\x75\xee\x62\x56\xa9\xf0\x27\xac\x13\x94\x69")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/COBRUwkxpIvZWJggpRxn7HVXiw6epXZOex8jGRJKpowpwukV7GRBY_LBvOJNQHue": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/fLYGIMHgN1louE-JzITJZJo2SDniPu-IHBXubtvQWFO-hXnDVKNuscV7-zlyr5fU")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "work": {Mode: fs.ModeDir | 0700}, + }}, + }) +} diff --git a/pkg/pkg.go b/pkg/pkg.go new file mode 100644 index 00000000..5d9d8d48 --- /dev/null +++ b/pkg/pkg.go @@ -0,0 +1,2952 @@ +// Package pkg provides low-level primitives for packaging software. +// +// The public interface, IR format, and IR representation of [Artifact] +// implementations not satisfying [RevisionArtifact] are covered by the +// compatibility promise. [RevisionArtifact] revisions may change between any +// two releases. +package pkg + +import ( + "bufio" + "bytes" + "cmp" + "context" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "hash" + "io" + "io/fs" + "iter" + "maps" + "math" + "os" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" + "unique" + "unsafe" + + "hakurei.app/check" + "hakurei.app/internal/info" + "hakurei.app/internal/lockedfile" + "hakurei.app/message" +) + +const ( + // programName is the string identifying this build system. + programName = "hakurei.app/pkg" +) + +type ( + // A Checksum is a SHA-384 checksum computed for a cured [Artifact]. + Checksum = [sha512.Size384]byte + + // An ID is a unique identifier returned by [KnownIdent.ID]. This value must + // be deterministically determined ahead of time. + ID Checksum +) + +// Encode is abbreviation for base64.URLEncoding.EncodeToString(checksum[:]). +func Encode(checksum Checksum) string { + return base64.URLEncoding.EncodeToString(checksum[:]) +} + +// Decode is abbreviation for base64.URLEncoding.Decode(checksum[:], []byte(s)). +func Decode(buf *Checksum, s string) (err error) { + var n int + n, err = base64.URLEncoding.Decode(buf[:], []byte(s)) + if err == nil && n != len(buf) { + err = io.ErrUnexpectedEOF + } + return +} + +// MustDecode decodes a string representation of [Checksum] and panics if there +// is a decoding error or the resulting data is too short. +func MustDecode(s string) (checksum Checksum) { + if err := Decode(&checksum, s); err != nil { + panic(err) + } + return +} + +var ( + // extension is a string uniquely identifying a set of custom [Artifact] + // implementations registered by calling [Register]. + extension string + + // openMu synchronises access to global state for initialisation. + openMu sync.Mutex + // opened is false if [Open] was never called. + opened bool +) + +// Extension returns a string uniquely identifying the currently registered set +// of custom [Artifact], or the zero value if none was registered. +func Extension() string { return extension } + +// ValidExtension returns whether s is valid for use in a call to SetExtension. +func ValidExtension(s string) bool { + if l := len(s); l == 0 || l > 128 { + return false + } + for _, v := range s { + if v < 'a' || v > 'z' { + return false + } + } + return true +} + +// ErrInvalidExtension is returned for a variant identification string for which +// [ValidExtension] returns false. +var ErrInvalidExtension = errors.New("invalid extension variant identification string") + +// SetExtension sets the extension variant identification string. SetExtension +// must be called before [Open] if custom [Artifact] implementations had been +// recorded by calling [Register]. +// +// The variant identification string must be between 1 and 128 bytes long and +// consists of only bytes between 'a' and 'z'. +// +// SetExtension is not safe for concurrent use. SetExtension is called at most +// once and must not be called after the first instance of Cache has been opened. +func SetExtension(s string) { + openMu.Lock() + defer openMu.Unlock() + + if opened { + panic("attempting to set extension after open") + } + if extension != "" { + panic("attempting to set extension twice") + } + if !ValidExtension(s) { + panic(ErrInvalidExtension) + } + extension = s + statusHeader = makeStatusHeader(s) +} + +// common holds elements and receives methods shared between different contexts. +type common struct { + // Context specific to this [Artifact]. The toplevel context in [Cache] must + // not be exposed directly. + ctx context.Context + + // Address of underlying [Cache], should be zeroed or made unusable after + // Cure returns and must not be exposed directly. + cache *Cache +} + +// TContext is passed to [TrivialArtifact.Cure] and provides information and +// methods required for curing the [TrivialArtifact]. +// +// Methods of TContext are safe for concurrent use. TContext is valid +// until [TrivialArtifact.Cure] returns. +type TContext struct { + // Populated during [Cache.Cure]. + work, temp *check.Absolute + + // Target [Artifact] encoded identifier. + ids string + // Pathname status was created at. + statusPath, statusSPath *check.Absolute + // File statusHeader and logs are written to. + status *os.File + // Error value during prepareStatus. + statusErr error + + common +} + +// makeStatusHeader creates the header written to every status file. This should +// not be called directly, its result is stored in statusHeader and will not +// change after the first [Cache] is opened. +func makeStatusHeader(extension string) string { + s := programName + if v := info.Version(); v != info.FallbackVersion { + s += " " + v + } + if extension != "" { + s += " with " + extension + " extensions" + } + s += " (" + runtime.GOARCH + ")" + if name, err := os.Hostname(); err == nil { + s += " on " + name + } + s += "\n\n" + return s +} + +// statusHeader is the header written to all status files in dirStatus. +var statusHeader = makeStatusHeader("") + +// prepareStatus initialises the status file once. +func (t *TContext) prepareStatus(writeHeader bool) error { + if t.statusPath != nil || t.status != nil { + return t.statusErr + } + + t.statusPath = t.cache.base.Append( + dirStatus, + t.ids, + ) + if t.status, t.statusErr = os.OpenFile( + t.statusPath.String(), + syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, + 0400, + ); t.statusErr != nil { + return t.statusErr + } + + if writeHeader { + _, t.statusErr = t.status.WriteString(statusHeader) + } + return t.statusErr +} + +// GetStatusWriter returns a [io.Writer] for build logs. The caller must not +// seek this writer before the position it was first returned in. +func (t *TContext) GetStatusWriter() (io.Writer, error) { + err := t.prepareStatus(true) + return t.status, err +} + +// destroy destroys the temporary directory and joins its errors with the error +// referred to by errP. If the error referred to by errP is non-nil, the work +// directory is removed similarly. [Cache] is responsible for making sure work +// is never left behind for a successful [Cache.Cure]. +// +// If implementation had requested status, it is closed with error joined with +// the error referred to by errP. If the error referred to by errP is non-nil, +// the status file is removed from the filesystem. +// +// destroy must be deferred by [Cache.Cure] if [TContext] is passed to any Cure +// implementation. It should not be called prior to that point. +func (t *TContext) destroy(errP *error) { + if chmodErr, removeErr := removeAll(t.temp); chmodErr != nil || removeErr != nil { + *errP = errors.Join(*errP, chmodErr, removeErr) + } + + if *errP != nil { + chmodErr, removeErr := removeAll(t.work) + if chmodErr != nil || removeErr != nil { + *errP = errors.Join(*errP, chmodErr, removeErr) + } else if errors.Is(*errP, os.ErrExist) { + if linkError, ok := errors.AsType[*os.LinkError](*errP); ok && + linkError != nil && + linkError.Op == "rename" { + // two artifacts may be backed by the same file + *errP = nil + } + } + } + + if t.status != nil { + if err := t.status.Close(); err != nil { + *errP = errors.Join(*errP, err) + } + if *errP != nil { + *errP = errors.Join(*errP, os.Rename( + t.statusPath.String(), t.cache.base.Append( + dirFault, + t.ids+"."+strconv.FormatUint(uint64( + time.Now().UnixNano(), + ), 10), + ).String(), + )) + if t.statusSPath != nil { + t.cache.checksumMu.Lock() + *errP = errors.Join(*errP, os.Remove(t.statusSPath.String())) + t.cache.checksumMu.Unlock() + } + } + t.status = nil + } +} + +// Unwrap returns the underlying [context.Context]. +func (c *common) Unwrap() context.Context { return c.ctx } + +// GetMessage returns [message.Msg] held by the underlying [Cache]. +func (c *common) GetMessage() message.Msg { return c.cache.msg } + +// GetJobs returns the preferred number of jobs to run, when applicable. Its +// value must not affect cure outcome. +func (c *common) GetJobs() int { return c.cache.attr.Jobs } + +// GetLoad returns the preferred load average target, when applicable. Its +// value must not affect cure outcome. +func (c *common) GetLoad() int { return c.cache.attr.Load } + +// GetWorkDir returns a pathname to a directory which [Artifact] is expected to +// write its output to. This is not the final resting place of the [Artifact] +// and this pathname should not be directly referred to in the final contents. +func (t *TContext) GetWorkDir() *check.Absolute { return t.work } + +// GetTempDir returns a pathname which implementations may use as scratch space. +// A directory is not created automatically, implementations are expected to +// create it if they wish to use it, using [os.MkdirAll]. +func (t *TContext) GetTempDir() *check.Absolute { return t.temp } + +// Open tries to open [Artifact] for reading. If a implements [FileArtifact], +// its reader might be used directly, eliminating the roundtrip to vfs. +// Otherwise, it must cure into a directory containing a single regular file. +// +// If err is nil, the caller must close the resulting [io.ReadCloser] and return +// its error, if any. Failure to read r to EOF may result in a spurious +// [ChecksumMismatchError], or the underlying implementation may block on Close. +func (c *common) Open(a Artifact) (r io.ReadCloser, err error) { + if f, ok := a.(FileArtifact); ok { + return c.cache.openFile(c.ctx, f) + } + + var pathname *check.Absolute + if pathname, _, _, err = c.cache.cure(a, true, false); err != nil { + return + } + + var entries []os.DirEntry + if entries, err = os.ReadDir(pathname.String()); err != nil { + return + } + + if len(entries) != 1 || !entries[0].Type().IsRegular() { + err = errors.New( + "input directory does not contain a single regular file", + ) + return + } else { + return os.Open(pathname.Append(entries[0].Name()).String()) + } +} + +// FContext is passed to [FloodArtifact.Cure] and provides information and +// methods required for curing the [FloodArtifact]. +// +// Methods of FContext are safe for concurrent use. FContext is valid +// until [FloodArtifact.Cure] returns. +type FContext struct { + TContext + + // Cured top-level inputs looked up by Pathname. + inputs map[Artifact]cureRes +} + +// linkSubstitute links status for substitute if populated. +func (f *FContext) linkSubstitute(ids, substitutes string) (err error) { + if f.status == nil || ids == substitutes { + return + } + + statusS := f.cache.base.Append( + dirStatus, + substitutes, + ) + f.cache.checksumMu.Lock() + err = os.Link(f.cache.base.Append( + dirStatus, + ids, + ).String(), statusS.String()) + f.cache.checksumMu.Unlock() + if err == nil { + f.statusSPath = statusS + } + return +} + +// InvalidLookupError is the identifier of non-input [Artifact] looked up +// via [FContext.GetArtifact] by a misbehaving [Artifact] implementation. +type InvalidLookupError ID + +func (e InvalidLookupError) Error() string { + return "attempting to look up non-input artifact " + Encode(e) +} + +var _ error = InvalidLookupError{} + +// GetArtifact returns the identifier pathname and checksum of an [Artifact]. +// Calling Pathname with an [Artifact] not part of the slice returned by +// [Artifact.Inputs] panics. +func (f *FContext) GetArtifact(a Artifact) ( + pathname *check.Absolute, + checksum unique.Handle[Checksum], +) { + if res, ok := f.inputs[a]; ok { + return res.pathname, res.checksum + } + panic(InvalidLookupError(f.cache.Ident(a).Value())) +} + +// RContext is passed to [FileArtifact.Cure] and provides helper methods useful +// for curing the [FileArtifact]. +// +// Methods of RContext are safe for concurrent use. RContext is valid +// until [FileArtifact.Cure] returns. +type RContext struct{ common } + +// An Artifact is a read-only reference to a piece of data that may be created +// deterministically but might not currently be available in memory or on the +// filesystem. +type Artifact interface { + // Kind returns the [Kind] of artifact. This is usually unique to the + // concrete type but two functionally identical implementations of + // [Artifact] is allowed to return the same [Kind] value. + Kind() Kind + + // Params writes deterministic values describing [Artifact]. Implementations + // must guarantee that these values are unique among differing instances + // of the same implementation with identical dependencies and conveys enough + // information to create another instance of [Artifact] identical to the + // instance emitting these values. The new instance created via [IRReadFunc] + // from these values must then produce identical IR values. + // + // Result must remain identical across multiple invocations. + Params(ctx *IContext) + + // Inputs returns a slice of [Artifact] the current instance has access to + // while producing its output. + // + // Callers must not modify the retuned slice. + // + // Result must remain identical across multiple invocations. + Inputs() []Artifact + + // IsExclusive returns whether the [Artifact] is exclusive. Exclusive + // artifacts might not run in parallel with each other, and are still + // subject to the cures limit. + // + // Some implementations may saturate the CPU for a nontrivial amount of + // time. Curing multiple such implementations simultaneously causes + // significant CPU scheduler overhead. An exclusive artifact will generally + // not be cured alongside another exclusive artifact, thus alleviating this + // overhead. + // + // Note that [Cache] reserves the right to still cure exclusive + // artifacts concurrently as this is not a synchronisation primitive but + // an optimisation one. Implementations are forbidden from accessing global + // state regardless of exclusivity. + // + // Result must remain identical across multiple invocations. + IsExclusive() bool +} + +// FloodArtifact refers to an [Artifact] requiring its entire dependency graph +// to be cured prior to curing itself. +type FloodArtifact interface { + // Cure cures the current [Artifact] to the working directory obtained via + // [TContext.GetWorkDir] embedded in [FContext]. + // + // Implementations must not retain c. + Cure(f *FContext) (err error) + + Artifact +} + +// TrivialArtifact refers to an [Artifact] that cures without requiring that +// any other [Artifact] is cured before it. Its dependency tree is ignored after +// computing its identifier. +// +// TrivialArtifact is unable to cure any other [Artifact] and it cannot access +// pathnames. This type of [Artifact] is primarily intended for dependency-less +// artifacts or direct dependencies that only consists of [FileArtifact]. +type TrivialArtifact interface { + // Cure cures the current [Artifact] to the working directory obtained via + // [TContext.GetWorkDir]. + // + // Implementations must not retain c. + Cure(t *TContext) (err error) + + Artifact +} + +// KnownIdent is optionally implemented by [Artifact] and is used instead of +// [Cache.Ident] when it is available. +// +// This is very subtle to use correctly. The implementation must ensure that +// this value is globally unique, otherwise [Cache] can enter an inconsistent +// state. This should not be implemented outside of testing. +type KnownIdent interface { + // ID returns a globally unique identifier referring to the current + // [Artifact]. This value must be known ahead of time and guaranteed to be + // unique without having obtained the full contents of the [Artifact]. + ID() ID + + Artifact +} + +// KnownChecksum is optionally implemented by [Artifact] for an artifact with +// output known ahead of time. +type KnownChecksum interface { + // Checksum returns the address of a known checksum. + // + // Callers must not modify the [Checksum]. + // + // Result must remain identical across multiple invocations. + Checksum() Checksum + + Artifact +} + +// CuresExempt is optionally implemented for an artifact exempt to the +// cache-wide cures counter and limit. +type CuresExempt interface { + Artifact + + // CuresExempt is a no-op function but serves to distinguish implementations + // that are cures-exempt. + CuresExempt() +} + +// FileArtifact refers to an [Artifact] backed by a single file. +// +// FileArtifact does not support fine-grained cancellation. Its context is +// inherited from the first [TrivialArtifact] or [FloodArtifact] that opens it. +type FileArtifact interface { + // IsExecutable returns whether the resulting filesystem entry should be made + // executable, if the [FileArtifact] is cured to the on-disk cache. + // + // Result must remain identical across multiple invocations. + IsExecutable() bool + + // Cure returns [io.ReadCloser] of the full contents of [FileArtifact]. If + // [FileArtifact] implements [KnownChecksum], Cure is responsible for + // validating any data it produces and must return [ChecksumMismatchError] + // if validation fails. This error is conventionally returned during the + // first call to Close, but may be returned during any call to Read before + // EOF, or by Cure itself. + // + // Callers are responsible for closing the resulting [io.ReadCloser]. + // + // The resulting [io.ReadCloser] across multiple invocations must have + // identical behaviour. + Cure(r *RContext) (io.ReadCloser, error) + + Artifact +} + +// RevisionArtifact is optionally implemented by an artifact that had undergone +// internal changes affecting its behaviour while retaining its IR structure. +type RevisionArtifact interface { + // Revision returns the revision number of [Artifact]. This value is always + // represented in the IR. An IR stream produced from the same [Kind] with + // differing revision is rejected. + // + // Result must remain identical across multiple invocations. + Revision() uint64 + + Artifact +} + +// GetRevision returns the revision number of an [Artifact]. +func GetRevision(a Artifact) (revision uint64) { + revision = math.MaxUint64 + if r, ok := a.(RevisionArtifact); ok { + revision = r.Revision() + } + return +} + +// reportName returns a string describing [Artifact] presented to the user. +func reportName(a Artifact, id unique.Handle[ID]) string { + r := Encode(id.Value()) + if s, ok := a.(fmt.Stringer); ok { + if name := s.String(); name != "" { + r += "-" + name + } + } + return r +} + +// Kind corresponds to the concrete type of [Artifact] and is used to create +// identifier for an [Artifact] with dependencies. +type Kind uint64 + +const ( + // KindHTTPGet is the kind of [Artifact] returned by [NewHTTPGet]. + KindHTTPGet Kind = iota + // KindTar is the kind of [Artifact] returned by [NewTar]. + KindTar + // KindExec is the kind of [Artifact] returned by [NewExec]. + KindExec + // KindExecNet is the kind of [Artifact] returned by [NewExec] but with a + // non-nil checksum. + KindExecNet + // KindFile is the kind of [Artifact] returned by [NewFile]. + KindFile + // KindDecompress is the kind of [Artifact] returned by [NewDecompress]. + KindDecompress + // KindArchive is the kind of [Artifact] returned by [NewArchive]. + KindArchive + + // _kindEnd is the total number of kinds and does not denote a kind. + _kindEnd + + // KindCustomOffset is the first [Kind] value reserved for implementations + // not from this package. + KindCustomOffset = 1 << 31 +) + +const ( + // kindCollection is the kind of [Collect]. It never cures successfully. + kindCollection Kind = KindCustomOffset - 1 - iota +) + +const ( + // fileLock is the lock file for exclusive access to the cache directory. + fileLock = "lock" + // fileVariant is a file holding the variant identification string set by a + // prior call to [SetExtension]. + fileVariant = "variant" + + // dirSubstitute holds symlinks to artifacts by checksum, named after their + // substitute identifier. + dirSubstitute = "substitute" + // dirIdentifier holds symlinks to artifacts by checksum, named after their + // IR-based identifier. + dirIdentifier = "identifier" + // dirChecksum holds artifacts named after their [Checksum]. + dirChecksum = "checksum" + // dirStatus holds artifact metadata and logs named after their IR-based + // identifier. For [FloodArtifact], the same file is also available under + // its substitute identifier. + dirStatus = "status" + // dirFault holds status files of faulted cures. + dirFault = "fault" + + // dirWork holds working pathnames set up during [Cache.Cure]. + dirWork = "work" + // dirTemp holds scratch space allocated during [Cache.Cure]. + dirTemp = "temp" + + // dirExecScratch is scratch space set up for the container started by + // [Cache.EnterExec]. Exclusivity via Cache.inExec. + dirExecScratch = "scratch" + + // checksumLinknamePrefix is prepended to the encoded [Checksum] value + // of an [Artifact] when creating a symbolic link to dirChecksum. + checksumLinknamePrefix = "../" + dirChecksum + "/" +) + +// cureRes are the non-error results returned by [Cache.Cure]. +type cureRes struct { + pathname *check.Absolute + checksum unique.Handle[Checksum] +} + +// A pendingArtifactDep is an input [Artifact] pending concurrent curing, +// subject to the cures limit. Values pointed to by result addresses are safe +// to access after the [sync.WaitGroup] associated with this pendingArtifactDep +// is done. pendingArtifactDep must not be reused or modified after it is sent +// to cure. +type pendingArtifactDep struct { + // Dependency artifact populated during [Cache.Cure]. + a Artifact + + // Address of result pathname populated during [Cache.Cure] and dereferenced + // if curing succeeds. + resP *cureRes + + // Address of result error map populated during [Cache.Cure], dereferenced + // after acquiring errsMu if curing fails. No additional action is taken, + // [Cache] and its caller are responsible for further error handling. + errs InputError + // Address of mutex synchronising access to errs. + errsMu *sync.Mutex + + // For synchronising access to result buffer. + *sync.WaitGroup +} + +const ( + // CValidateKnown arranges for [KnownChecksum] outcomes to be validated to + // match its intended checksum. + // + // A correct implementation of [KnownChecksum] does not successfully cure + // with output not matching its intended checksum. When an implementation + // fails to perform this validation correctly, the on-disk format enters + // an inconsistent state (correctable by [Cache.Scrub]). + // + // This flag causes [Cache.Cure] to always compute the checksum, and reject + // a cure if it does not match the intended checksum. + // + // This behaviour significantly reduces performance and is not recommended + // outside of testing a custom [Artifact] implementation. + CValidateKnown = 1 << iota + + // CSchedIdle arranges for the [ext.SCHED_IDLE] scheduling priority to be + // set for [KindExec] and [KindExecNet] containers. + CSchedIdle + + // CAssumeChecksum enables the use of [KnownChecksum] for duplicate function + // call suppression via the on-disk cache. + // + // This may cause incorrect cure outcome if an impossible checksum is + // specified that matches an output already present in the on-disk cache. + // This may be avoided by purposefully specifying a statistically + // unattainable checksum, like the zero value. + // + // While this optimisation might seem appealing, it is almost never + // applicable in real world use. Almost every time this path was taken, it + // was caused by an incorrect checksum accidentally left behind while + // bumping a package. Only enable this if you are really sure you need it. + CAssumeChecksum + + // CHostAbstract disables restriction of sandboxed processes from connecting + // to an abstract UNIX socket created by a host process. + // + // This is considered less secure in some systems, but does not introduce + // impurity due to [KindExecNet] being [KnownChecksum]. This flag exists + // to support kernels without Landlock LSM enabled. + CHostAbstract + + // CPromoteVariant allows [pkg.Open] to promote an unextended on-disk cache + // to the current extension variant. This is a one-way operation. + CPromoteVariant + + // CSuppressInit arranges for verbose output of the container init to be + // suppressed regardless of [message.Msg] state. + CSuppressInit + + // CIgnoreSubstitutes disables content-based input substitution. + CIgnoreSubstitutes + + // CExternShallow arranges for only non-flood inputs to be fetched when + // curing an [Artifact] available via the external cache. + CExternShallow + + // CColourOutput enables output colouring via ANSI control sequences. + CColourOutput +) + +// toplevel holds [context.WithCancel] over caller-supplied context, where all +// [Artifact] context are derived from. +type toplevel struct { + ctx context.Context + cancel context.CancelFunc +} + +// newToplevel returns the address of a new toplevel via ctx. +func newToplevel(ctx context.Context) *toplevel { + var t toplevel + t.ctx, t.cancel = context.WithCancel(ctx) + return &t +} + +// pendingCure provides synchronisation and cancellation for pending cures. +type pendingCure struct { + // Closed on cure completion. + done <-chan struct{} + // Error outcome, safe to access after done is closed. + err error + // Cancels the corresponding cure. + cancel context.CancelFunc +} + +// An External cache provides prepared [Artifact] cure outcomes. +type External interface { + // Artifact returns the address of the [Checksum] of the cure outcome of + // an [Artifact] corresponding to id, or nil if this [Artifact] is not + // available in the external cache. + Artifact(ctx context.Context, id unique.Handle[ID]) (*Checksum, error) + // Checksum returns an [Artifact] producing the specified checksum. + Checksum(checksum unique.Handle[Checksum]) Artifact + // Status returns [io.ReadCloser] of the status file of an [Artifact] + // corresponding to id, or nil if this [Artifact] is not available or a + // status file is not present. + Status(r *RContext, id unique.Handle[ID]) (io.ReadCloser, error) +} + +// Cache is a support layer that implementations of [Artifact] can use to store +// cured [Artifact] data in a content addressed fashion. +type Cache struct { + // Cures of any variant of [Artifact] sends to cures before entering the + // implementation and receives an equal amount of elements after. + cures chan struct{} + + // Parent context which toplevel was derived from. + parent context.Context + // For deriving curing context, must not be accessed directly. + toplevel atomic.Pointer[toplevel] + // For waiting on input curing goroutines. + wg sync.WaitGroup + // Reports new cures and passed to [Artifact]. + msg message.Msg + // Select graphics rendition sequences, populated by Open. + sgrRes, sgrIdent, sgrWarn, sgrErr string + + // Directory where all [Cache] related files are placed. + base *check.Absolute + // Immutable [CacheAttr] populated by [Open]. + attr CacheAttr + + // Must not be exposed directly. + irCache + + // Synchronises access to dirChecksum. + checksumMu sync.RWMutex + + // Presence of an alternative in the cache. Keys are not valid identifiers + // and must not be used as such. + substitute map[unique.Handle[ID]]unique.Handle[Checksum] + // Synchronises access to substitute and corresponding filesystem entries. + substituteMu sync.RWMutex + // Identifier to content pair cache. + ident map[unique.Handle[ID]]unique.Handle[Checksum] + // Identifier to error pair for unrecoverably faulted [Artifact]. + identErr map[unique.Handle[ID]]error + // Pending identifiers, accessed through Cure for entries not in ident. + identPending map[unique.Handle[ID]]*pendingCure + // Synchronises access to ident and corresponding filesystem entries. + identMu sync.RWMutex + // Synchronises entry into Abort and Cure. + abortMu sync.RWMutex + + // Synchronises entry into exclusive artifacts for the cure method. + exclMu sync.Mutex + // Buffered I/O free list, must not be accessed directly. + brPool, bwPool sync.Pool + + // Optional external cache implementation. + extern External + // Caches responses from extern. + externCache map[unique.Handle[ID]]unique.Handle[Checksum] + // Synchronises access to extern. + externMu sync.RWMutex + + // Unlocks the on-filesystem cache. Must only be called from Close. + unlock func() + // Whether [Cache] is considered closed. + closed bool + // Synchronises calls to Abort and Close. + closeMu sync.Mutex + + // Whether EnterExec has not yet returned. + inExec atomic.Bool +} + +// extIdent is a [Kind] concatenated with [ID]. +type extIdent [wordSize + len(ID{})]byte + +// getIdentBuf returns the address of an extIdent for Ident. +func (ic *irCache) getIdentBuf() *extIdent { return ic.identPool.Get().(*extIdent) } + +// putIdentBuf adds buf to identPool. +func (ic *irCache) putIdentBuf(buf *extIdent) { ic.identPool.Put(buf) } + +// storeIdent adds an [Artifact] to the artifact cache. +func (ic *irCache) storeIdent(a Artifact, buf *extIdent) unique.Handle[ID] { + idu := unique.Make(ID(buf[wordSize:])) + ic.artifact.Store(a, idu) + return idu +} + +// Ident returns the identifier of an [Artifact]. +func (ic *irCache) Ident(a Artifact) unique.Handle[ID] { + buf, idu := ic.unsafeIdent(a, false) + if buf != nil { + idu = ic.storeIdent(a, buf) + ic.putIdentBuf(buf) + } + return idu +} + +// unsafeIdent implements Ident but returns the underlying buffer for a newly +// computed identifier. Callers must return this buffer to identPool. encodeKind +// is only a hint, kind may still be encoded in the buffer. +func (ic *irCache) unsafeIdent(a Artifact, encodeKind bool) ( + buf *extIdent, + idu unique.Handle[ID], +) { + if id, ok := ic.artifact.Load(a); ok { + idu = id.(unique.Handle[ID]) + return + } + + if ki, ok := a.(KnownIdent); ok { + buf = ic.getIdentBuf() + if encodeKind { + binary.LittleEndian.PutUint64(buf[:], uint64(a.Kind())) + } + *(*ID)(buf[wordSize:]) = ki.ID() + return + } + + buf = ic.getIdentBuf() + h := sha512.New384() + if err := ic.Encode(h, a); err != nil { + // unreachable + panic(err) + } + binary.LittleEndian.PutUint64(buf[:], uint64(a.Kind())) + h.Sum(buf[wordSize:wordSize]) + return +} + +// getReader is like [bufio.NewReader] but for brPool. +func (c *Cache) getReader(r io.Reader) *bufio.Reader { + br := c.brPool.Get().(*bufio.Reader) + br.Reset(r) + return br +} + +// putReader adds br to brPool. +func (c *Cache) putReader(br *bufio.Reader) { c.brPool.Put(br) } + +// bufioReadCloser is the concrete type of value returned by Cache.getReaderRC. +type bufioReadCloser struct { + // Saved close error. + closeErr error + // Synchronises calls to Close. + closeOnce sync.Once + + // For backing freelist. + c *Cache + // Underlying reader. + r io.ReadCloser + // Allocated from c. + *bufio.Reader +} + +// Close closes the underlying reader, saves its return value, and returns the +// [bufio.Reader] instance to the backing [Cache]. +func (brc *bufioReadCloser) Close() error { + brc.closeOnce.Do(func() { + br := brc.Reader + brc.Reader = nil + brc.c.putReader(br) + brc.closeErr = brc.r.Close() + }) + return brc.closeErr +} + +// getReaderRC is like getReader, but returns an [io.ReadCloser]. +func (c *Cache) getReaderRC(r io.ReadCloser) io.ReadCloser { + return &bufioReadCloser{c: c, r: r, Reader: c.getReader(r)} +} + +// getWriter is like [bufio.NewWriter] but for bwPool. +func (c *Cache) getWriter(w io.Writer) *bufio.Writer { + bw := c.bwPool.Get().(*bufio.Writer) + bw.Reset(w) + return bw +} + +// putWriter adds bw to bwPool. +func (c *Cache) putWriter(bw *bufio.Writer) { c.bwPool.Put(bw) } + +// A ChecksumMismatchError describes an [Artifact] with unexpected content. +type ChecksumMismatchError struct { + // Actual and expected checksums. + Got, Want Checksum +} + +func (e *ChecksumMismatchError) Error() string { + return "got " + Encode(e.Got) + + " instead of " + Encode(e.Want) +} + +// LinknamePrefixError describes a malformed linkname to a [Checksum]. +type LinknamePrefixError string + +func (e LinknamePrefixError) Error() string { + return "linkname " + strconv.Quote(string(e)) + " missing prefix" +} + +// readlinkChecksum reads a symbolic link to a dirChecksum entry and saves the +// decoded [Checksum] to the value pointed to by buf. The checksumLinknamePrefix +// is required. +func readlinkChecksum(a *check.Absolute, buf *Checksum) error { + linkname, err := os.Readlink(a.String()) + if err != nil { + return nil + } + + if !strings.HasPrefix(linkname, checksumLinknamePrefix) { + return LinknamePrefixError(linkname) + } + return Decode(buf, linkname[len(checksumLinknamePrefix):]) +} + +// SetExternal sets e as the [External] implementation of c. +func (c *Cache) SetExternal(e External) { + c.externMu.Lock() + c.externCache = make(map[unique.Handle[ID]]unique.Handle[Checksum]) + c.extern = e + c.externMu.Unlock() +} + +// ScrubError describes the outcome of a [Cache.Scrub] call where errors were +// found and removed from the underlying storage of [Cache]. +type ScrubError struct { + // Content-addressed entries not matching their checksum. This can happen + // if an incorrect [FileArtifact] implementation was cured against + // a non-strict [Cache]. + ChecksumMismatches []ChecksumMismatchError + // Dangling identifier symlinks. This can happen if the content-addressed + // entry was removed while scrubbing due to a checksum mismatch. + DanglingIdentifiers []ID + // Dangling status files. This can happen if a dangling status symlink was + // removed while scrubbing. + DanglingStatus []ID + // Miscellaneous errors, including [os.ReadDir] on checksum and identifier + // directories, [Decode] on entry names and [os.RemoveAll] on inconsistent + // entries. + Errs map[unique.Handle[string]][]error +} + +// errs is a deterministic iterator over Errs. +func (e *ScrubError) errs(yield func(unique.Handle[string], []error) bool) { + keys := slices.AppendSeq( + make([]unique.Handle[string], 0, len(e.Errs)), + maps.Keys(e.Errs), + ) + slices.SortFunc(keys, func(a, b unique.Handle[string]) int { + return strings.Compare(a.Value(), b.Value()) + }) + for _, key := range keys { + if !yield(key, e.Errs[key]) { + break + } + } +} + +// Unwrap returns a concatenation of ChecksumMismatches and Errs. +func (e *ScrubError) Unwrap() []error { + s := make([]error, 0, len(e.ChecksumMismatches)+len(e.Errs)) + for _, err := range e.ChecksumMismatches { + s = append(s, &err) + } + for _, errs := range e.errs { + s = append(s, errs...) + } + return s +} + +// Error returns a multi-line representation of [ScrubError]. +func (e *ScrubError) Error() string { + var segments []string + var buf strings.Builder + + if len(e.ChecksumMismatches) > 0 { + buf.Reset() + buf.WriteString("checksum mismatches:\n") + for _, m := range e.ChecksumMismatches { + buf.WriteString(m.Error() + "\n") + } + segments = append(segments, buf.String()) + } + if len(e.DanglingIdentifiers) > 0 { + buf.Reset() + buf.WriteString("dangling identifiers:\n") + for _, id := range e.DanglingIdentifiers { + buf.WriteString(Encode(id) + "\n") + } + segments = append(segments, buf.String()) + } + if len(e.DanglingStatus) > 0 { + buf.Reset() + buf.WriteString("dangling status:\n") + for _, id := range e.DanglingStatus { + buf.WriteString(Encode(id) + "\n") + } + segments = append(segments, buf.String()) + } + if len(e.Errs) > 0 { + buf.Reset() + buf.WriteString("errors during scrub:\n") + for pathname, errs := range e.errs { + buf.WriteString(" " + pathname.Value() + ":\n") + for _, err := range errs { + buf.WriteString(" " + err.Error() + "\n") + } + } + segments = append(segments, buf.String()) + } + return strings.Join(segments, "\n") +} + +// Scrub frees internal in-memory identifier to content pair cache, verifies all +// cached artifacts against their checksums, checks for dangling identifier +// symlinks and removes them if found. +// +// This method is not safe for concurrent use with any other method. +func (c *Cache) Scrub(checks int) error { + if checks <= 0 { + checks = runtime.NumCPU() + } + + c.substituteMu.Lock() + defer c.substituteMu.Unlock() + c.identMu.Lock() + defer c.identMu.Unlock() + c.checksumMu.Lock() + defer c.checksumMu.Unlock() + + c.substitute = make(map[unique.Handle[ID]]unique.Handle[Checksum]) + c.ident = make(map[unique.Handle[ID]]unique.Handle[Checksum]) + c.identErr = make(map[unique.Handle[ID]]error) + c.artifact.Clear() + + var ( + se = ScrubError{Errs: make(map[unique.Handle[string]][]error)} + seMu sync.Mutex + + addErr = func(pathname *check.Absolute, err error) { + seMu.Lock() + se.Errs[pathname.Handle()] = append(se.Errs[pathname.Handle()], err) + seMu.Unlock() + } + ) + + type checkEntry struct { + ent os.DirEntry + check func(ent os.DirEntry, want *Checksum) bool + } + var ( + dir *check.Absolute + wg sync.WaitGroup + w = make(chan checkEntry, checks) + p = sync.Pool{New: func() any { return new(Checksum) }} + ) + condemn := func(ent os.DirEntry) { + pathname := dir.Append(ent.Name()) + chmodErr, removeErr := removeAll(pathname) + if chmodErr != nil { + addErr(pathname, chmodErr) + } + if removeErr != nil { + addErr(pathname, removeErr) + } + } + for i := 0; i < checks; i++ { + go func() { + for ce := range w { + want := p.Get().(*Checksum) + ent := ce.ent + if err := Decode(want, ent.Name()); err != nil { + addErr(dir.Append(ent.Name()), err) + wg.Go(func() { condemn(ent) }) + } else if !ce.check(ent, want) { + wg.Go(func() { condemn(ent) }) + } else { + c.msg.Verbosef( + "%s%s%s is consistent", + c.sgrIdent, ent.Name(), c.sgrRes, + ) + } + p.Put(want) + wg.Done() + } + }() + } + defer close(w) + + dir = c.base.Append(dirChecksum) + if entries, readdirErr := os.ReadDir(dir.String()); readdirErr != nil { + addErr(dir, readdirErr) + } else { + wg.Add(len(entries)) + for _, ent := range entries { + w <- checkEntry{ent, func(ent os.DirEntry, want *Checksum) bool { + got := p.Get().(*Checksum) + defer p.Put(got) + + pathname := dir.Append(ent.Name()) + if ent.IsDir() { + if err := SumDir(got, pathname); err != nil { + addErr(pathname, err) + return true + } + } else if ent.Type().IsRegular() { + h := sha512.New384() + + if r, err := os.Open(pathname.String()); err != nil { + addErr(pathname, err) + return true + } else { + _, err = io.Copy(h, r) + closeErr := r.Close() + if closeErr != nil { + addErr(pathname, closeErr) + } + if err != nil { + addErr(pathname, err) + } + } + h.Sum(got[:0]) + } else { + addErr(pathname, InvalidFileModeError(ent.Type())) + return false + } + + if *got != *want { + seMu.Lock() + se.ChecksumMismatches = append(se.ChecksumMismatches, + ChecksumMismatchError{Got: *got, Want: *want}, + ) + seMu.Unlock() + return false + } + return true + }} + } + wg.Wait() + } + + for _, suffix := range []string{ + dirSubstitute, + dirIdentifier, + } { + dir = c.base.Append(suffix) + if entries, readdirErr := os.ReadDir(dir.String()); readdirErr != nil { + addErr(dir, readdirErr) + } else { + wg.Add(len(entries)) + for _, ent := range entries { + w <- checkEntry{ent, func(ent os.DirEntry, want *Checksum) bool { + got := p.Get().(*Checksum) + defer p.Put(got) + + pathname := dir.Append(ent.Name()) + if linkname, err := os.Readlink( + pathname.String(), + ); err != nil { + seMu.Lock() + se.Errs[pathname.Handle()] = append(se.Errs[pathname.Handle()], err) + se.DanglingIdentifiers = append(se.DanglingIdentifiers, *want) + seMu.Unlock() + return false + } else if err = Decode(got, filepath.Base(linkname)); err != nil { + seMu.Lock() + lnp := dir.Append(linkname) + se.Errs[lnp.Handle()] = append(se.Errs[lnp.Handle()], err) + se.DanglingIdentifiers = append(se.DanglingIdentifiers, *want) + seMu.Unlock() + return false + } + + if _, err := os.Stat(pathname.String()); err != nil { + if !errors.Is(err, os.ErrNotExist) { + addErr(pathname, err) + } + seMu.Lock() + se.DanglingIdentifiers = append(se.DanglingIdentifiers, *want) + seMu.Unlock() + return false + } + return true + }} + } + wg.Wait() + } + } + + dir = c.base.Append(dirStatus) + if entries, readdirErr := os.ReadDir(dir.String()); readdirErr != nil { + if !errors.Is(readdirErr, os.ErrNotExist) { + addErr(dir, readdirErr) + } + } else { + wg.Add(len(entries)) + for _, ent := range entries { + w <- checkEntry{ent, func(ent os.DirEntry, want *Checksum) bool { + got := p.Get().(*Checksum) + defer p.Put(got) + + var ok bool + for _, name := range [...]string{ + dirIdentifier, + dirSubstitute, + } { + if _, err := os.Stat(c.base.Append( + name, + ent.Name(), + ).String()); err != nil { + if !errors.Is(err, os.ErrNotExist) { + addErr(dir.Append(ent.Name()), err) + } + continue + } + ok = true + } + if !ok { + seMu.Lock() + se.DanglingStatus = append(se.DanglingStatus, *want) + seMu.Unlock() + } + return ok + }} + } + wg.Wait() + } + + if len(c.identPending) > 0 { + addErr(c.base, errors.New( + "scrub began with pending artifacts", + )) + } else { + pathname := c.base.Append(dirWork) + chmodErr, removeErr := removeAll(pathname) + if chmodErr != nil { + addErr(pathname, chmodErr) + } + if removeErr != nil { + addErr(pathname, removeErr) + } + + if err := os.Mkdir(pathname.String(), 0700); err != nil { + addErr(pathname, err) + } + + pathname = c.base.Append(dirTemp) + chmodErr, removeErr = removeAll(pathname) + if chmodErr != nil { + addErr(pathname, chmodErr) + } + if removeErr != nil { + addErr(pathname, removeErr) + } + } + + if len(se.ChecksumMismatches) > 0 || + len(se.DanglingIdentifiers) > 0 || + len(se.DanglingStatus) > 0 || + len(se.Errs) > 0 { + slices.SortFunc(se.ChecksumMismatches, func(a, b ChecksumMismatchError) int { + return bytes.Compare(a.Want[:], b.Want[:]) + }) + slices.SortFunc(se.DanglingIdentifiers, func(a, b ID) int { + return bytes.Compare(a[:], b[:]) + }) + slices.SortFunc(se.DanglingStatus, func(a, b ID) int { + return bytes.Compare(a[:], b[:]) + }) + return &se + } else { + return nil + } +} + +// loadOrStoreIdent attempts to load a cached [Artifact] by its identifier or +// wait for a pending [Artifact] to cure. If neither is possible, the current +// identifier is stored in identPending and a non-nil channel is returned. +// +// Since identErr is treated as grow-only, loadOrStoreIdent must not be entered +// without holding a read lock on abortMu. +func (c *Cache) loadOrStoreIdent(id unique.Handle[ID]) ( + ctx context.Context, + done chan<- struct{}, + checksum unique.Handle[Checksum], + err error, +) { + var ok bool + + c.identMu.Lock() + if checksum, ok = c.ident[id]; ok { + c.identMu.Unlock() + return + } + if err, ok = c.identErr[id]; ok { + c.identMu.Unlock() + return + } + + var pending *pendingCure + if pending, ok = c.identPending[id]; ok { + c.identMu.Unlock() + <-pending.done + c.identMu.RLock() + if checksum, ok = c.ident[id]; !ok { + err = pending.err + } + c.identMu.RUnlock() + return + } + + d := make(chan struct{}) + pending = &pendingCure{done: d} + ctx, pending.cancel = context.WithCancel(c.toplevel.Load().ctx) + c.wg.Add(1) + c.identPending[id] = pending + c.identMu.Unlock() + done = d + return +} + +// finaliseIdent commits a checksum or error to ident for an identifier +// previously submitted to identPending. +func (c *Cache) finaliseIdent( + done chan<- struct{}, + id unique.Handle[ID], + checksum unique.Handle[Checksum], + err error, +) { + c.identMu.Lock() + if err != nil { + c.identPending[id].err = err + c.identErr[id] = err + } else { + c.ident[id] = checksum + } + delete(c.identPending, id) + c.identMu.Unlock() + c.wg.Done() + + close(done) +} + +// zeroChecksum is a zero [Checksum] handle, used for comparison only. +var zeroChecksum unique.Handle[Checksum] + +// loadSubstitute returns a checksum corresponding to a substitute identifier, +// or zeroChecksum if an alternative is not available. +func (c *Cache) loadSubstitute( + substitute unique.Handle[ID], +) (unique.Handle[Checksum], error) { + c.substituteMu.RLock() + if checksum, ok := c.substitute[substitute]; ok { + c.substituteMu.RUnlock() + return checksum, nil + } + + linkname, err := os.Readlink(c.base.Append( + dirSubstitute, + Encode(substitute.Value()), + ).String()) + c.substituteMu.RUnlock() + + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return zeroChecksum, err + } + + c.substituteMu.Lock() + c.substitute[substitute] = zeroChecksum + c.substituteMu.Unlock() + return zeroChecksum, nil + } + + var checksum unique.Handle[Checksum] + buf := c.getIdentBuf() + err = Decode((*Checksum)(buf[:]), filepath.Base(linkname)) + if err == nil { + checksum = unique.Make(Checksum(buf[:])) + + c.substituteMu.Lock() + c.substitute[substitute] = checksum + c.substituteMu.Unlock() + } + c.putIdentBuf(buf) + + return checksum, err +} + +// Done returns a channel that is closed when the ongoing cure of an [Artifact] +// referred to by the specified identifier completes. Done may return nil if +// no ongoing cure of the specified identifier exists. +func (c *Cache) Done(id unique.Handle[ID]) <-chan struct{} { + c.identMu.RLock() + pending, ok := c.identPending[id] + c.identMu.RUnlock() + if !ok || pending == nil { + return nil + } + return pending.done +} + +// Cancel cancels the ongoing cure of an [Artifact] referred to by the specified +// identifier. Cancel returns whether the [context.CancelFunc] has been killed. +// Cancel returns after the cure is complete. +func (c *Cache) Cancel(id unique.Handle[ID]) bool { + c.identMu.RLock() + pending, ok := c.identPending[id] + c.identMu.RUnlock() + if !ok || pending == nil || pending.cancel == nil { + return false + } + pending.cancel() + <-pending.done + + c.abortMu.Lock() + c.identMu.Lock() + delete(c.identErr, id) + c.identMu.Unlock() + c.abortMu.Unlock() + return true +} + +// openFile tries to load [FileArtifact] from [Cache], and if that fails, +// obtains it via [FileArtifact.Cure] instead. Notably, it does not cure +// [FileArtifact] to the filesystem. If err is nil, the caller is responsible +// for closing the resulting [io.ReadCloser]. +// +// The context must originate from loadOrStoreIdent to enable cancellation. +func (c *Cache) openFile( + ctx context.Context, + f FileArtifact, +) (r io.ReadCloser, err error) { + if kc, ok := f.(KnownChecksum); c.attr.Flags&CAssumeChecksum != 0 && ok { + c.checksumMu.RLock() + r, err = os.Open(c.base.Append( + dirChecksum, + Encode(kc.Checksum()), + ).String()) + c.checksumMu.RUnlock() + } else { + c.identMu.RLock() + r, err = os.Open(c.base.Append( + dirIdentifier, + Encode(c.Ident(f).Value()), + ).String()) + c.identMu.RUnlock() + } + + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return + } + id := c.Ident(f) + if c.msg.IsVerbose() { + rn := reportName(f, id) + c.msg.Verbosef("curing %s%s%s in memory...", c.sgrIdent, rn, c.sgrRes) + defer func() { + if err == nil { + c.msg.Verbosef("opened %s%s%s for reading", c.sgrIdent, rn, c.sgrRes) + } + }() + } + return f.Cure(&RContext{common{ctx, c}}) + } + return +} + +// InvalidFileModeError describes a [FloodArtifact.Cure] or +// [TrivialArtifact.Cure] that did not result in a regular file or directory +// located at the work pathname. +type InvalidFileModeError fs.FileMode + +// Error returns a constant string. +func (e InvalidFileModeError) Error() string { + return "artifact did not produce a regular file or directory" +} + +// NoOutputError describes a [FloodArtifact.Cure] or [TrivialArtifact.Cure] +// that did not populate its work pathname despite completing successfully. +type NoOutputError struct{} + +// Unwrap returns [os.ErrNotExist]. +func (NoOutputError) Unwrap() error { return os.ErrNotExist } + +// Error returns a constant string. +func (NoOutputError) Error() string { + return "artifact cured successfully but did not produce any output" +} + +// removeAll is similar to [os.RemoveAll] but is robust against any permissions. +func removeAll(pathname *check.Absolute) (chmodErr, removeErr error) { + chmodErr = filepath.WalkDir(pathname.String(), func( + path string, + d fs.DirEntry, + err error, + ) error { + if err != nil { + return err + } + if d.IsDir() { + return os.Chmod(path, 0700) + } + return nil + }) + if errors.Is(chmodErr, os.ErrNotExist) { + chmodErr = nil + } + removeErr = os.RemoveAll(pathname.String()) + return +} + +// zeroTimes zeroes atime and mtime for the named file. +func zeroTimes(path string) (err error) { + // include/uapi/linux/fcntl.h + const ( + AT_FDCWD = -100 + AT_SYMLINK_NOFOLLOW = 0x100 + ) + _AT_FDCWD := AT_FDCWD + + var _p0 *byte + _p0, err = syscall.BytePtrFromString(path) + if err != nil { + return + } + if _, _, errno := syscall.Syscall6( + syscall.SYS_UTIMENSAT, + uintptr(_AT_FDCWD), + uintptr(unsafe.Pointer(_p0)), + uintptr(unsafe.Pointer(new([2]syscall.Timespec))), + AT_SYMLINK_NOFOLLOW, + 0, 0, + ); errno != 0 { + return os.NewSyscallError("utimensat", errno) + } + return +} + +// overrideFileInfo overrides the permission bits of [fs.FileInfo] to 0500 and +// is the concrete type returned by overrideFile.Stat. +type overrideFileInfo struct{ fs.FileInfo } + +// Mode returns [fs.FileMode] with its permission bits set to 0500. +func (fi overrideFileInfo) Mode() fs.FileMode { + return fi.FileInfo.Mode()&(^fs.FileMode(0777)) | 0500 +} + +// Sys returns nil to avoid passing the original permission bits. +func (fi overrideFileInfo) Sys() any { return nil } + +// overrideFile overrides the permission bits of [fs.File] to 0500 and is the +// concrete type returned by dotOverrideFS for calls with "." passed as name. +type overrideFile struct{ fs.File } + +func (f overrideFile) Stat() (fi fs.FileInfo, err error) { + fi, err = f.File.Stat() + if err != nil { + return + } + fi = overrideFileInfo{fi} + return +} + +// dirFS is implemented by the concrete type of the return value of [os.DirFS]. +type dirFS interface { + fs.StatFS + fs.ReadFileFS + fs.ReadDirFS + fs.ReadLinkFS +} + +// dotOverrideFS overrides the permission bits of "." to 0500 to avoid the extra +// system calls to add and remove write bit from the target directory. +type dotOverrideFS struct{ dirFS } + +// Open wraps the underlying [fs.FS] with "." special case. +func (fsys dotOverrideFS) Open(name string) (f fs.File, err error) { + f, err = fsys.dirFS.Open(name) + if err != nil || name != "." { + return + } + f = overrideFile{f} + return +} + +// Stat wraps the underlying [fs.FS] with "." special case. +func (fsys dotOverrideFS) Stat(name string) (fi fs.FileInfo, err error) { + fi, err = fsys.dirFS.Stat(name) + if err != nil || name != "." { + return + } + fi = overrideFileInfo{fi} + return +} + +// InvalidArtifactError describes an artifact that does not implement a +// supported Cure method. +type InvalidArtifactError ID + +func (e InvalidArtifactError) Error() string { + return "artifact " + Encode(e) + " cannot be cured" +} + +// Cure cures the [Artifact] and returns its pathname and [Checksum]. Direct +// calls to Cure are not subject to the cures limit. +func (c *Cache) Cure(a Artifact) ( + pathname *check.Absolute, + checksum unique.Handle[Checksum], + err error, +) { + c.abortMu.RLock() + defer c.abortMu.RUnlock() + + if err = c.toplevel.Load().ctx.Err(); err != nil { + return + } + + pathname, checksum, _, err = c.cure(a, true, false) + return +} + +// CureWhence is like Cure, but returns the whence value. +func (c *Cache) CureWhence(a Artifact) ( + pathname *check.Absolute, + checksum unique.Handle[Checksum], + whence int, + err error, +) { + c.abortMu.RLock() + defer c.abortMu.RUnlock() + + if err = c.toplevel.Load().ctx.Err(); err != nil { + return + } + + return c.cure(a, true, false) +} + +// CureNew is like Cure, but always enters the implementation. +func (c *Cache) CureNew(a Artifact) ( + pathname *check.Absolute, + checksum unique.Handle[Checksum], + err error, +) { + c.abortMu.RLock() + defer c.abortMu.RUnlock() + + if err = c.toplevel.Load().ctx.Err(); err != nil { + return + } + + var whence int +retry: + pathname, checksum, whence, err = c.cure(a, true, true) + if err != nil || whence == WNew { + return + } + goto retry +} + +// An InputError describes inputs of a [FloodArtifact] which had failed to cure. +type InputError map[Artifact]error + +// unwrap returns an iterator over sorted, deduplicated [Artifact] and their +// corresponding identifier. +func (e InputError) unwrap() iter.Seq2[Artifact, unique.Handle[ID]] { + ir := NewIR() + + type input struct { + a Artifact + id unique.Handle[ID] + } + p := make([]input, 0, len(e)) + for a := range e { + p = append(p, input{a, ir.Ident(a)}) + } + + var identBuf [2]ID + slices.SortFunc(p, func(a, b input) int { + identBuf[0], identBuf[1] = a.id.Value(), b.id.Value() + return slices.Compare(identBuf[0][:], identBuf[1][:]) + }) + p = slices.CompactFunc(p, func(a, b input) bool { return a.id == b.id }) + + return func(yield func(Artifact, unique.Handle[ID]) bool) { + for _, i := range p { + if !yield(i.a, i.id) { + return + } + } + } +} + +// Error returns a user-facing, deterministic text representation of e. +func (e InputError) Error() string { + var buf strings.Builder + buf.WriteString("errors curing inputs:") + for a, id := range e.unwrap() { + buf.WriteString("\n\t") + buf.WriteString(reportName(a, id)) + buf.WriteString(": ") + buf.WriteString(e[a].Error()) + } + return buf.String() +} + +// Unwrap returns a slice of underlying errors sorted by identifier. +func (e InputError) Unwrap() []error { + errs := make([]error, 0, len(e)) + for a := range e.unwrap() { + errs = append(errs, e[a]) + } + return errs +} + +// enterCure must be called before entering an [Artifact] implementation. +func (c *Cache) enterCure(a Artifact, curesExempt bool) error { + if c.attr.Notify != nil { + c.attr.Notify <- true + } + + if a.IsExclusive() { + c.exclMu.Lock() + } + if curesExempt { + return nil + } + + ctx := c.toplevel.Load().ctx + select { + case c.cures <- struct{}{}: + return nil + + case <-ctx.Done(): + if a.IsExclusive() { + c.exclMu.Unlock() + } + return ctx.Err() + } +} + +// exitCure must be called after exiting an [Artifact] implementation. +func (c *Cache) exitCure(a Artifact, curesExempt bool) { + if c.attr.Notify != nil { + c.attr.Notify <- false + } + + if a.IsExclusive() { + c.exclMu.Unlock() + } + if curesExempt { + return + } + + <-c.cures +} + +// measuredReader implements [io.ReadCloser] and measures the checksum during +// Close. If the underlying reader is not read to EOF, Close blocks until all +// remaining data is consumed and validated. +type measuredReader struct { + // Underlying reader. Never exposed directly. + r io.ReadCloser + // For validating checksum. Never exposed directly. + h hash.Hash + // Buffers writes to h, initialised by [Cache]. Never exposed directly. + hbw *bufio.Writer + // Expected checksum, compared during Close. + want unique.Handle[Checksum] + + // For accessing free lists. + c *Cache + + // Set up via [io.TeeReader] by [Cache]. + io.Reader +} + +// Close reads the underlying [io.ReadCloser] to EOF, closes it and measures its +// outcome. It returns a [ChecksumMismatchError] for an unexpected checksum. +func (mr *measuredReader) Close() (err error) { + if mr.hbw == nil || mr.Reader == nil { + return os.ErrInvalid + } + err = mr.hbw.Flush() + mr.c.putWriter(mr.hbw) + mr.hbw, mr.Reader = nil, nil + if err != nil { + _ = mr.r.Close() + return + } + var n int64 + if n, err = io.Copy(mr.h, mr.r); err != nil { + _ = mr.r.Close() + return + } + + if n > 0 { + mr.c.msg.Verbosef( + "%smissed %d bytes on measured reader%s", + mr.c.sgrWarn, n, mr.c.sgrRes, + ) + } + + if err = mr.r.Close(); err != nil { + return + } + + buf := mr.c.getIdentBuf() + mr.h.Sum(buf[:0]) + + if got := Checksum(buf[:]); got != mr.want.Value() { + err = &ChecksumMismatchError{ + Got: got, + Want: mr.want.Value(), + } + } + + mr.c.putIdentBuf(buf) + return +} + +// newMeasuredReader implements [RContext.NewMeasuredReader]. +func (c *Cache) newMeasuredReader( + r io.ReadCloser, + checksum unique.Handle[Checksum], +) io.ReadCloser { + mr := measuredReader{r: r, h: sha512.New384(), want: checksum, c: c} + mr.hbw = c.getWriter(mr.h) + mr.Reader = io.TeeReader(r, mr.hbw) + return &mr +} + +// NewMeasuredReader returns an [io.ReadCloser] implementing behaviour required +// by [FileArtifact]. The resulting [io.ReadCloser] holds a buffer originating +// from [Cache] and must be closed to return this buffer. +func (r *RContext) NewMeasuredReader( + rc io.ReadCloser, + checksum unique.Handle[Checksum], +) io.ReadCloser { + return r.cache.newMeasuredReader(rc, checksum) +} + +// tryChecksum dereferences a symlink to a cure outcome. +func (c *Cache) tryChecksum(pathname *check.Absolute) ( + checksum unique.Handle[Checksum], + err error, +) { + _, err = os.Lstat(pathname.String()) + if err == nil { + var name string + if name, err = os.Readlink(pathname.String()); err != nil { + return + } + buf := c.getIdentBuf() + err = Decode((*Checksum)(buf[:]), filepath.Base(name)) + if err == nil { + checksum = unique.Make(Checksum(buf[:])) + } + c.putIdentBuf(buf) + } + return +} + +// tryLocal attempts to obtain an [Artifact] outcome from the filesystem. +func (c *Cache) tryLocal(id unique.Handle[ID]) (unique.Handle[Checksum], error) { + return c.tryChecksum(c.base.Append( + dirIdentifier, + Encode(id.Value()), + )) +} + +// tryExtern attempts to obtain an [Artifact] outcome from extern. +func (c *Cache) tryExtern(ctx context.Context, id unique.Handle[ID]) ( + unique.Handle[Checksum], + error, +) { + c.externMu.RLock() + defer c.externMu.RUnlock() + + checksum, ok := c.externCache[id] + if !ok { + if c.extern == nil { + return zeroChecksum, nil + } + + v, err := c.extern.Artifact(ctx, id) + if err != nil { + return zeroChecksum, err + } + if v == nil { + return zeroChecksum, nil + } + checksum = unique.Make(*v) + + var got unique.Handle[Checksum] + if _, got, err = c.Cure(c.extern.Checksum(checksum)); err != nil { + return checksum, err + } else if got != checksum { + return zeroChecksum, &ChecksumMismatchError{got.Value(), checksum.Value()} + } + } + return checksum, nil +} + +// cureMany concurrently collects outcome of multiple [Artifact]. +func (c *Cache) cureMany( + inputs []Artifact, + r map[Artifact]cureRes, + shallow bool, +) ([]bool, error) { + var wg sync.WaitGroup + wg.Add(len(inputs)) + var mask []bool + res := make([]cureRes, len(inputs)) + errs := make(InputError) + var errsMu sync.Mutex + if shallow { + mask = make([]bool, len(inputs)) + } + for i, d := range inputs { + if shallow { + if _, ok := d.(FloodArtifact); ok { + mask[i] = true + wg.Done() + continue + } + + if kc, ok := d.(KnownChecksum); ok { + res[i].checksum = unique.Make(kc.Checksum()) + wg.Done() + continue + } + } + pending := pendingArtifactDep{d, &res[i], errs, &errsMu, &wg} + go pending.cure(c) + } + wg.Wait() + + if len(errs) > 0 { + return mask, errs + } + for i, p := range res { + if shallow && mask[i] { + continue + } + r[inputs[i]] = p + } + return mask, nil +} + +// HangingInputError describes an input of an [Artifact] on a sparse cache +// without an outcome available locally or via [External]. +type HangingInputError unique.Handle[ID] + +func (e HangingInputError) Error() string { + return Encode(unique.Handle[ID](e).Value()) + " is unavailable" +} + +const ( + // WNew indicates a cure entering the implementation. + WNew = iota + // WCache indicates an [Artifact] present in the cache. + WCache + // WSubstitute indicates an [Artifact] hitting a content-based input + // substitution. + WSubstitute + // WExternal indicates a cure entering the external cache. + WExternal +) + +// WhenceString returns a printable string for a whence value. +func WhenceString(whence int) string { + switch whence { + case WNew: + return "new" + case WCache: + return "cache" + case WSubstitute: + return "substitute" + case WExternal: + return "external" + + default: + return "invalid whence " + strconv.Itoa(whence) + } +} + +// cure implements Cure without acquiring a read lock on abortMu. cure must not +// be entered during Abort. +func (c *Cache) cure(a Artifact, curesExempt, rebuild bool) ( + pathname *check.Absolute, + checksum unique.Handle[Checksum], + whence int, + err error, +) { + id := c.Ident(a) + if rebuild { + var v ID + _, _ = rand.Read(v[:]) + id = unique.Make(v) + } + + ids := Encode(id.Value()) + pathname = c.base.Append( + dirIdentifier, + ids, + ) + defer func() { + if err != nil { + pathname = nil + checksum = unique.Handle[Checksum]{} + } + }() + + if _, ok := a.(CuresExempt); ok { + curesExempt = true + } + + var ( + ctx context.Context + done chan<- struct{} + ) + ctx, done, checksum, err = c.loadOrStoreIdent(id) + if done == nil { + whence = WCache + return + } else { + defer func() { c.finaliseIdent(done, id, checksum, err) }() + } + + checksum, err = c.tryChecksum(pathname) + if err == nil || !errors.Is(err, os.ErrNotExist) { + whence = WCache + return + } + + var ( + checksums string + substitute unique.Handle[ID] + alternative *check.Absolute + ) + defer func() { + if err == nil && checksums != "" { + linkname := checksumLinknamePrefix + checksums + + err = os.Symlink( + linkname, + pathname.String(), + ) + if err == nil { + err = zeroTimes(pathname.String()) + } + + if err == nil && alternative != nil && substitute != id { + c.substituteMu.Lock() + err = os.Symlink( + linkname, + alternative.String(), + ) + if errors.Is(err, os.ErrExist) { + c.msg.Verbosef( + "creating alternative over %s%s%s for artifact %s%s%s", + c.sgrIdent, Encode(substitute.Value()), c.sgrRes, + c.sgrIdent, ids, c.sgrRes, + ) + err = nil + } + if err == nil { + err = zeroTimes(alternative.String()) + } + if err == nil && checksum != zeroChecksum { + c.substitute[substitute] = checksum + } + c.substituteMu.Unlock() + } + } + }() + + var checksumPathname *check.Absolute + var checksumFi os.FileInfo + if kc, ok := a.(KnownChecksum); ok { + checksum = unique.Make(kc.Checksum()) + checksums = Encode(checksum.Value()) + checksumPathname = c.base.Append( + dirChecksum, + checksums, + ) + + if c.attr.Flags&CAssumeChecksum != 0 { + c.checksumMu.RLock() + checksumFi, err = os.Stat(checksumPathname.String()) + c.checksumMu.RUnlock() + + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return + } + + checksumFi, err = nil, nil + } + } + } + + whence = WNew + if c.msg.IsVerbose() { + rn := reportName(a, id) + c.msg.Verbosef("curing %s%s%s...", c.sgrIdent, rn, c.sgrRes) + defer func() { + if err != nil { + return + } + if checksums != "" { + c.msg.Verbosef( + "cured %s%s%s checksum %s%s%s", + c.sgrIdent, rn, c.sgrRes, + c.sgrIdent, checksums, c.sgrRes) + } else { + c.msg.Verbosef("cured %s%s%s", c.sgrIdent, rn, c.sgrRes) + } + }() + } + + // cure FileArtifact outside type switch to skip TContext initialisation + if f, ok := a.(FileArtifact); ok { + if checksumFi != nil { + whence = WCache + if !checksumFi.Mode().IsRegular() { + // unreachable + err = InvalidFileModeError(checksumFi.Mode()) + } + return + } + + perm := os.FileMode(0400) + if f.IsExecutable() { + perm = 0500 + } + + work := c.base.Append(dirWork, ids) + var w *os.File + if w, err = os.OpenFile( + work.String(), + os.O_CREATE|os.O_EXCL|os.O_WRONLY, + perm, + ); err != nil { + return + } + defer func() { + closeErr := w.Close() + if err == nil { + err = closeErr + } + + removeErr := os.Remove(work.String()) + if err == nil && !errors.Is(removeErr, os.ErrNotExist) { + err = removeErr + } + }() + + var r io.ReadCloser + if err = c.enterCure(a, curesExempt); err != nil { + return + } + r, err = f.Cure(&RContext{common{ctx, c}}) + if err == nil { + if checksumPathname == nil || c.attr.Flags&CValidateKnown != 0 { + h := sha512.New384() + hbw := c.getWriter(h) + _, err = io.Copy(w, io.TeeReader(r, hbw)) + flushErr := hbw.Flush() + c.putWriter(hbw) + if err == nil { + err = flushErr + } + + if err == nil { + buf := c.getIdentBuf() + h.Sum(buf[:0]) + + if checksumPathname == nil { + checksum = unique.Make(Checksum(buf[:])) + checksums = Encode(Checksum(buf[:])) + } else if c.attr.Flags&CValidateKnown != 0 { + if got := Checksum(buf[:]); got != checksum.Value() { + err = &ChecksumMismatchError{ + Got: got, + Want: checksum.Value(), + } + } + } + + c.putIdentBuf(buf) + + if checksumPathname == nil { + checksumPathname = c.base.Append( + dirChecksum, + checksums, + ) + } + } + } else { + _, err = io.Copy(w, r) + } + + closeErr := r.Close() + if err == nil { + err = closeErr + } + } + c.exitCure(a, curesExempt) + if err != nil { + if c.msg.IsVerbose() { + c.msg.Verbosef( + "cure file %s%s%s: %s%v%s", + c.sgrIdent, reportName(f, id), c.sgrRes, + c.sgrErr, err, c.sgrRes, + ) + } + return + } + + c.checksumMu.Lock() + if err = os.Rename( + work.String(), + checksumPathname.String(), + ); err != nil { + c.checksumMu.Unlock() + return + } + timeErr := zeroTimes(checksumPathname.String()) + c.checksumMu.Unlock() + + if err == nil { + err = timeErr + } + return + } + + if checksumFi != nil { + whence = WCache + if !checksumFi.Mode().IsDir() { + // unreachable + err = InvalidFileModeError(checksumFi.Mode()) + } + return + } + + t := TContext{ + c.base.Append(dirWork, ids), + c.base.Append(dirTemp, ids), + ids, nil, nil, nil, nil, + common{ctx, c}, + } + switch ca := a.(type) { + case TrivialArtifact: + defer t.destroy(&err) + if err = c.enterCure(a, curesExempt); err != nil { + return + } + err = ca.Cure(&t) + c.exitCure(a, curesExempt) + if err != nil { + if c.msg.IsVerbose() { + c.msg.Verbosef( + "cure trivial %s%s%s: %s%v%s", + c.sgrIdent, reportName(ca, id), c.sgrRes, + c.sgrErr, err, c.sgrRes, + ) + } + return + } + break + + case FloodArtifact: + var externChecksum unique.Handle[Checksum] + if externChecksum, err = c.tryExtern(ctx, id); err != nil { + if c.msg.IsVerbose() { + c.msg.Verbosef( + "extern %s%s%s: %s%v%s", + c.sgrIdent, reportName(ca, id), c.sgrRes, + c.sgrErr, err, c.sgrRes, + ) + } + return + } + extern := externChecksum != zeroChecksum + shallow := extern && c.attr.Flags&CExternShallow != 0 + + inputs := a.Inputs() + f := FContext{t, make(map[Artifact]cureRes, len(inputs))} + var mask []bool + if mask, err = c.cureMany(inputs, f.inputs, shallow); err != nil { + return + } + + if shallow { + for i, d := range inputs { + if !mask[i] { + continue + } + + if kc, ok := d.(KnownChecksum); ok { + f.inputs[d] = cureRes{checksum: unique.Make(kc.Checksum())} + continue + } + + var sum unique.Handle[Checksum] + did := c.Ident(d) + sum, err = c.tryLocal(did) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return + } + + sum, err = c.tryExtern(ctx, did) + if err != nil { + return + } + if sum == zeroChecksum { + if c.msg.IsVerbose() { + c.msg.Verbosef( + "input %s%s%s not available", + c.sgrIdent, reportName(d, did), c.sgrRes, + ) + } + err = HangingInputError(did) + return + } + } + + f.inputs[d] = cureRes{checksum: sum} + } + } + + sh := sha512.New384() + err = c.encode(sh, a, f.inputs) + if err != nil { + return + } + + buf := c.getIdentBuf() + sh.Sum(buf[wordSize:wordSize]) + substitute = unique.Make(ID(buf[wordSize:])) + substitutes := Encode(substitute.Value()) + c.putIdentBuf(buf) + alternative = c.base.Append( + dirSubstitute, + substitutes, + ) + + if !rebuild && c.attr.Flags&CIgnoreSubstitutes == 0 { + var substituteChecksum unique.Handle[Checksum] + substituteChecksum, err = c.loadSubstitute(substitute) + if err != nil { + return + } + if substituteChecksum != zeroChecksum { + whence = WSubstitute + checksum = substituteChecksum + checksums = Encode(checksum.Value()) + checksumPathname = c.base.Append( + dirChecksum, + checksums, + ) + if _, err = os.Lstat(c.base.Append( + dirStatus, + substitutes, + ).String()); err == nil { + err = os.Symlink(substitutes, c.base.Append( + dirStatus, + ids, + ).String()) + } else if errors.Is(err, os.ErrNotExist) { + err = nil + } + return + } + } + + defer f.destroy(&err) + + if extern { + whence = WExternal + if checksum != zeroChecksum && externChecksum != checksum { + err = &ChecksumMismatchError{externChecksum.Value(), checksum.Value()} + if c.msg.IsVerbose() { + c.msg.Verbosef( + "extern %s%s%s: %s%v%s", + c.sgrIdent, reportName(ca, id), c.sgrRes, + c.sgrErr, err, c.sgrRes, + ) + } + return + } + + var externStatus io.ReadCloser + c.externMu.RLock() + externStatus, err = c.extern.Status(&RContext{common{ctx, c}}, id) + c.externMu.RUnlock() + if err != nil { + return + } + + checksum = externChecksum + checksums = Encode(checksum.Value()) + checksumPathname = c.base.Append( + dirChecksum, + checksums, + ) + + if externStatus != nil { + if err = f.prepareStatus(false); err != nil { + _ = externStatus.Close() + return + } else if _, err = io.Copy(f.status, externStatus); err != nil { + _ = externStatus.Close() + return + } else if err = externStatus.Close(); err != nil { + return + } else if !rebuild { + if err = f.linkSubstitute(ids, substitutes); err != nil { + return + } + } + } + return + } + + if err = c.enterCure(a, curesExempt); err != nil { + return + } + err = ca.Cure(&f) + c.exitCure(a, curesExempt) + + if !rebuild && err == nil { + err = f.linkSubstitute(ids, substitutes) + } + if err != nil { + if c.msg.IsVerbose() { + c.msg.Verbosef( + "cure %s%s%s: %s%v%s", + c.sgrIdent, reportName(ca, id), c.sgrRes, + c.sgrErr, err, c.sgrRes, + ) + } + return + } + break + + default: + err = InvalidArtifactError(id.Value()) + return + } + t.cache = nil + + var fi os.FileInfo + if fi, err = os.Lstat(t.work.String()); err != nil { + if errors.Is(err, os.ErrNotExist) { + err = NoOutputError{} + } + return + } + + if !fi.IsDir() { + if !fi.Mode().IsRegular() { + err = InvalidFileModeError(fi.Mode()) + } else { + err = errors.New("non-file artifact produced regular file") + } + return + } + + var gotChecksum Checksum + if err = SumFS( + &gotChecksum, + dotOverrideFS{os.DirFS(t.work.String()).(dirFS)}, + ".", + ); err != nil { + return + } + + if checksumPathname == nil { + checksum = unique.Make(gotChecksum) + checksums = Encode(gotChecksum) + checksumPathname = c.base.Append( + dirChecksum, + checksums, + ) + } else if gotChecksum != checksum.Value() { + err = &ChecksumMismatchError{ + Got: gotChecksum, + Want: checksum.Value(), + } + if c.msg.IsVerbose() { + c.msg.Verbosef( + "validate %s%s%s: %s%v%s", + c.sgrIdent, reportName(a, id), c.sgrRes, + c.sgrErr, err, c.sgrRes, + ) + } + return + } + + if err = os.Chmod(t.work.String(), 0700); err != nil { + return + } + if err = filepath.WalkDir(t.work.String(), func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + return zeroTimes(path) + }); err != nil { + return + } + c.checksumMu.Lock() + if err = os.Rename( + t.work.String(), + checksumPathname.String(), + ); err != nil { + if !errors.Is(err, os.ErrExist) { + c.checksumMu.Unlock() + return + } + // err is zeroed during deferred cleanup + } else { + err = os.Chmod(checksumPathname.String(), 0500) + } + c.checksumMu.Unlock() + return +} + +// cure cures the pending [Artifact], stores its result and notifies the caller. +func (pending *pendingArtifactDep) cure(c *Cache) { + defer pending.Done() + + var err error + pending.resP.pathname, pending.resP.checksum, _, err = c.cure(pending.a, false, false) + if err == nil { + return + } + + pending.errsMu.Lock() + if errs, ok := err.(InputError); ok { + maps.Copy(pending.errs, errs) + } else { + pending.errs[pending.a] = err + } + pending.errsMu.Unlock() +} + +// OpenStatus attempts to open the status file associated to an [Artifact]. If +// err is nil, the caller must close the resulting reader. +func (c *Cache) OpenStatus(a Artifact) (r io.ReadSeekCloser, err error) { + c.identMu.RLock() + r, err = os.Open(c.base.Append( + dirStatus, + Encode(c.Ident(a).Value())).String(), + ) + c.identMu.RUnlock() + return +} + +// Fault holds the pathname and termination time of an [Artifact] fault entry. +type Fault struct { + *check.Absolute + t uint64 +} + +// Time returns the instant in time where the fault occurred. +func (f Fault) Time() time.Time { return time.Unix(0, int64(f.t)) } + +// Open opens the underlying entry for reading. +func (f Fault) Open() (io.ReadCloser, error) { return os.Open(f.Absolute.String()) } + +// Destroy removes the underlying fault entry. +func (f Fault) Destroy() error { return os.Remove(f.Absolute.String()) } + +// ReadFaults returns fault entries for an [Artifact]. +func (c *Cache) ReadFaults(a Artifact) (faults []Fault, err error) { + prefix := Encode(c.Ident(a).Value()) + "." + var dents []os.DirEntry + if dents, err = os.ReadDir(c.base.Append(dirFault).String()); err != nil { + return + } + + for _, dent := range dents { + name := dent.Name() + if !strings.HasPrefix(name, prefix) { + continue + } + var t uint64 + t, err = strconv.ParseUint(name[len(prefix):], 10, 64) + if err != nil { + return + } + + faults = append(faults, Fault{c.base.Append( + dirFault, + name, + ), t}) + } + + slices.SortFunc(faults, func(a, b Fault) int { + return cmp.Compare(a.t, b.t) + }) + return +} + +// Abort cancels all pending cures and waits for them to clean up, but does not +// close the cache. +func (c *Cache) Abort() { + c.closeMu.Lock() + defer c.closeMu.Unlock() + + if c.closed { + return + } + + c.toplevel.Load().cancel() + c.abortMu.Lock() + defer c.abortMu.Unlock() + + // holding abortMu, identPending stays empty + c.wg.Wait() + c.identMu.Lock() + c.toplevel.Store(newToplevel(c.parent)) + clear(c.identErr) + c.identMu.Unlock() +} + +// Close cancels all pending cures and waits for them to clean up. +func (c *Cache) Close() { + c.closeMu.Lock() + defer c.closeMu.Unlock() + + if c.closed { + return + } + + c.closed = true + c.toplevel.Load().cancel() + c.wg.Wait() + close(c.cures) + c.unlock() + + if c.attr.Notify != nil { + close(c.attr.Notify) + } +} + +// UnsupportedVariantError describes an on-disk cache with an extension variant +// identification string that differs from the value returned by [Extension]. +type UnsupportedVariantError string + +func (e UnsupportedVariantError) Error() string { + return "unsupported variant " + strconv.Quote(string(e)) +} + +var ( + // ErrWouldPromote is returned by [Open] if the [CPromoteVariant] bit is not + // set and the on-disk cache requires variant promotion. + ErrWouldPromote = errors.New("operation would promote unextended cache") +) + +// CacheAttr holds the attributes that will be applied to a new [Cache] opened +// by [Open]. +type CacheAttr struct { + // Concurrent cures of a [FloodArtifact] dependency graph. + Cures int + // Options affecting [Cache] behaviour. + Flags int + // Preferred job count, when applicable. + Jobs int + // Preferred loadavg target, when applicable. + Load int + // Optional cure entry and exit notification. + Notify chan<- bool + + // Omit the [lockedfile] lock. + skipLock bool +} + +// Open returns the address of a newly opened instance of [Cache]. +// +// Concurrent cures of a [FloodArtifact] dependency graph is limited to the +// caller-supplied value, however direct calls to [Cache.Cure] is not subject +// to this limitation. +// +// A cures or jobs value of 0 or lower is equivalent to the value returned by +// [runtime.NumCPU]. +// +// A successful call to Open guarantees exclusive access to the on-filesystem +// cache for the resulting instance of [Cache]. The [Cache.Close] method cancels +// and waits for pending cures on [Cache] before releasing this lock and must be +// called once the [Cache] is no longer needed. +func Open( + ctx context.Context, + msg message.Msg, + base *check.Absolute, + attr *CacheAttr, +) (*Cache, error) { + openMu.Lock() + defer openMu.Unlock() + opened = true + + if extension == "" && len(irArtifact) != int(_kindEnd) { + panic("attempting to open cache with incomplete variant setup") + } + + var a CacheAttr + if attr != nil { + a = *attr + } + + if a.Cures < 1 { + a.Cures = runtime.NumCPU() + } + if a.Jobs < 1 { + a.Jobs = runtime.NumCPU() + } + if a.Load < 1 { + a.Load = runtime.NumCPU() + 2 + } + + for _, name := range []string{ + dirSubstitute, + dirIdentifier, + dirChecksum, + dirStatus, + dirFault, + dirWork, + } { + if err := os.MkdirAll( + base.Append(name).String(), + 0700, + ); err != nil && !errors.Is(err, os.ErrExist) { + return nil, err + } + } + + c := Cache{ + parent: ctx, + + cures: make(chan struct{}, a.Cures), + attr: a, + + msg: msg, + base: base, + + irCache: zeroIRCache(), + + substitute: make(map[unique.Handle[ID]]unique.Handle[Checksum]), + ident: make(map[unique.Handle[ID]]unique.Handle[Checksum]), + identErr: make(map[unique.Handle[ID]]error), + identPending: make(map[unique.Handle[ID]]*pendingCure), + + brPool: sync.Pool{New: func() any { return new(bufio.Reader) }}, + bwPool: sync.Pool{New: func() any { return new(bufio.Writer) }}, + } + c.toplevel.Store(newToplevel(ctx)) + + if !a.skipLock || !testing.Testing() { + if unlock, err := lockedfile.MutexAt( + base.Append(fileLock).String(), + ).Lock(); err != nil { + return nil, err + } else { + c.unlock = unlock + } + } else { + c.unlock = func() {} + } + + for _, name := range []string{ + dirWork, + dirTemp, + } { + dents, err := os.ReadDir(base.Append(name).String()) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + c.unlock() + return nil, err + } + if len(dents) != 0 { + c.unlock() + return nil, fmt.Errorf( + "%s is not empty, scrub likely required", + name, + ) + } + } + + if _, err := os.ReadDir(base.Append( + dirExecScratch, + ).String()); !errors.Is(err, os.ErrNotExist) { + c.unlock() + if err != nil { + return nil, err + } + return nil, errors.New(dirExecScratch + " is present, scrub likely required") + } + + variantPath := base.Append(fileVariant).String() + if p, err := os.ReadFile(variantPath); err != nil { + if !errors.Is(err, os.ErrNotExist) { + c.unlock() + return nil, err + } + // nonexistence implies newly created cache, or a cache predating + // variant identification strings, in which case it is silently promoted + if err = os.WriteFile( + variantPath, + []byte(extension), + 0400, + ); err != nil { + c.unlock() + return nil, err + } + } else if s := string(p); s == "" { + if extension != "" { + if a.Flags&CPromoteVariant == 0 { + c.unlock() + return nil, ErrWouldPromote + } + if err = os.WriteFile( + variantPath, + []byte(extension), + 0400, + ); err != nil { + c.unlock() + return nil, err + } + } + } else if !ValidExtension(s) { + c.unlock() + return nil, ErrInvalidExtension + } else if s != extension { + c.unlock() + return nil, UnsupportedVariantError(s) + } + + if a.Flags&CColourOutput != 0 { + c.sgrRes = "\x1b[0m" + c.sgrIdent = "\x1b[1m" + c.sgrWarn = "\x1b[35m" + c.sgrErr = "\x1b[1;31m" + } + + return &c, nil +} + +// Collected is returned by [Collect.Cure] to indicate a successful collection. +type Collected struct{} + +// Error returns a constant string to satisfy error, but should never be seen +// by the user. +func (Collected) Error() string { return "artifacts successfully collected" } + +// IsCollected returns whether the underlying error contains that of the result +// of curing a [Collect] helper. +func IsCollected(err error) bool { return errors.As(err, new(Collected)) } + +// Collect implements [pkg.FloodArtifact] to concurrently cure multiple +// [pkg.Artifact]. It returns [Collected]. +type Collect []Artifact + +var _ Artifact = new(Collect) + +// Cure returns [Collected]. +func (*Collect) Cure(*FContext) error { return Collected{} } + +// Kind returns the hardcoded [pkg.Kind] value. +func (*Collect) Kind() Kind { return kindCollection } + +// Params is a noop: dependencies are already represented in the header. +func (*Collect) Params(*IContext) {} + +// Inputs returns [Collect] as is. +func (c *Collect) Inputs() []Artifact { return *c } + +// IsExclusive returns false: Cure is a noop. +func (*Collect) IsExclusive() bool { return false } diff --git a/pkg/pkg_test.go b/pkg/pkg_test.go new file mode 100644 index 00000000..c11b6ac4 --- /dev/null +++ b/pkg/pkg_test.go @@ -0,0 +1,2375 @@ +package pkg_test + +import ( + "archive/tar" + "bytes" + "context" + "crypto/sha512" + "encoding/base64" + "errors" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "testing/fstest" + "time" + "unique" + "unsafe" + + "hakurei.app/check" + "hakurei.app/container" + "hakurei.app/fhs" + "hakurei.app/internal/info" + "hakurei.app/internal/landlock" + "hakurei.app/internal/stub" + "hakurei.app/message" + "hakurei.app/pkg" +) + +var skipLock = func() pkg.CacheAttr { + var attr pkg.CacheAttr + *(*bool)(unsafe.Pointer(reflect.ValueOf(&attr). + Elem(). + FieldByName("skipLock"). + UnsafeAddr())) = true + return attr +}() + +var ( + // extension is a string uniquely identifying a set of custom [Artifact] + // implementations registered by calling [Register]. + // + //go:linkname extension hakurei.app/pkg.extension + extension string + + // opened is false if [Open] was never called. + // + //go:linkname opened hakurei.app/pkg.opened + opened bool + + // irArtifact refers to artifact IR interpretation functions and must not be + // written to directly. + // + //go:linkname irArtifact hakurei.app/pkg.irArtifact + irArtifact map[pkg.Kind]pkg.IRReadFunc + + // statusHeader is the header written to all status files in dirStatus. + // + //go:linkname statusHeader hakurei.app/pkg.statusHeader + statusHeader string +) + +// newRContext returns the address of a new [pkg.RContext] unsafely created for +// the specified [testing.TB]. +func newRContext(tb testing.TB, c *pkg.Cache) *pkg.RContext { + var r pkg.RContext + rContextVal := reflect.ValueOf(&r).Elem().FieldByName("ctx") + reflect.NewAt( + rContextVal.Type(), + unsafe.Pointer(rContextVal.UnsafeAddr()), + ).Elem().Set(reflect.ValueOf(tb.Context())) + rCacheVal := reflect.ValueOf(&r).Elem().FieldByName("cache") + reflect.NewAt( + rCacheVal.Type(), + unsafe.Pointer(rCacheVal.UnsafeAddr()), + ).Elem().Set(reflect.ValueOf(c)) + return &r +} + +// overrideIdent overrides the ID method of [Artifact]. +type overrideIdent struct { + id pkg.ID + pkg.TrivialArtifact +} + +func (a overrideIdent) ID() pkg.ID { return a.id } + +// overrideIdentFile overrides the ID method of [FileArtifact]. +type overrideIdentFile struct { + id pkg.ID + pkg.FileArtifact +} + +func (a overrideIdentFile) ID() pkg.ID { return a.id } + +// A knownIdentArtifact implements [pkg.KnownIdent] and [Artifact] +type knownIdentArtifact interface { + pkg.KnownIdent + pkg.TrivialArtifact +} + +// A knownIdentFile implements [pkg.KnownIdent] and [FileArtifact] +type knownIdentFile interface { + pkg.KnownIdent + pkg.FileArtifact +} + +// overrideChecksum overrides the Checksum method of [Artifact]. +type overrideChecksum struct { + checksum pkg.Checksum + knownIdentArtifact +} + +func (a overrideChecksum) Checksum() pkg.Checksum { return a.checksum } + +// overrideChecksumFile overrides the Checksum method of [FileArtifact]. +type overrideChecksumFile struct { + checksum pkg.Checksum + knownIdentFile +} + +func (a overrideChecksumFile) Checksum() pkg.Checksum { return a.checksum } + +// A stubArtifact implements [TrivialArtifact] with hardcoded behaviour. +type stubArtifact struct { + kind pkg.Kind + params []byte + deps []pkg.Artifact + + cure func(t *pkg.TContext) error +} + +func (a *stubArtifact) Kind() pkg.Kind { return a.kind } +func (a *stubArtifact) Params(ctx *pkg.IContext) { ctx.Write(a.params) } +func (a *stubArtifact) Inputs() []pkg.Artifact { return a.deps } +func (a *stubArtifact) Cure(t *pkg.TContext) error { return a.cure(t) } +func (*stubArtifact) IsExclusive() bool { return false } + +// A stubArtifactF implements [FloodArtifact] with hardcoded behaviour. +type stubArtifactF struct { + kind pkg.Kind + params []byte + deps []pkg.Artifact + excl bool + + cure func(f *pkg.FContext) error +} + +func (a *stubArtifactF) Kind() pkg.Kind { return a.kind } +func (a *stubArtifactF) Params(ctx *pkg.IContext) { ctx.Write(a.params) } +func (a *stubArtifactF) Inputs() []pkg.Artifact { return a.deps } +func (a *stubArtifactF) Cure(f *pkg.FContext) error { return a.cure(f) } +func (a *stubArtifactF) IsExclusive() bool { return a.excl } + +// A stubFile implements [FileArtifact] with hardcoded behaviour. +type stubFile struct { + data []byte + err error + + stubArtifact +} + +func (*stubFile) IsExecutable() bool { return false } + +func (a *stubFile) Cure(*pkg.RContext) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(a.data)), a.err +} + +// newStubFile returns an implementation of [pkg.File] with hardcoded behaviour. +func newStubFile( + kind pkg.Kind, + id pkg.ID, + sum *pkg.Checksum, + data []byte, + err error, +) pkg.FileArtifact { + f := overrideIdentFile{id, &stubFile{data, err, stubArtifact{ + kind, + nil, + nil, + func(*pkg.TContext) error { + panic("unreachable") + }, + }}} + if sum == nil { + return f + } else { + return overrideChecksumFile{*sum, f} + } +} + +// stubExtern implements [External] with hardcoded prepared outcomes. +type stubExtern struct { + artifact map[unique.Handle[pkg.ID]]pkg.Checksum + checksum map[unique.Handle[pkg.Checksum]]fstest.MapFS + status map[unique.Handle[pkg.ID]]string +} + +func (e stubExtern) Artifact(_ context.Context, id unique.Handle[pkg.ID]) (*pkg.Checksum, error) { + if checksum, ok := e.artifact[id]; ok { + return &checksum, nil + } + return nil, nil +} + +func (e stubExtern) Checksum(checksum unique.Handle[pkg.Checksum]) pkg.Artifact { + var buf bytes.Buffer + if err := pkg.Write(e.checksum[checksum], ".", &buf); err != nil { + panic(err) + } + return pkg.NewArchive(pkg.NewFile("", buf.Bytes())) +} + +func (e stubExtern) Status(_ *pkg.RContext, id unique.Handle[pkg.ID]) (io.ReadCloser, error) { + if status, ok := e.status[id]; ok { + return io.NopCloser(strings.NewReader(status)), nil + } + return nil, nil +} + +// destroyArtifact removes all traces of an [Artifact] from the on-disk cache. +// Do not use this in a test case without a very good reason to do so. +func destroyArtifact( + t *testing.T, + base *check.Absolute, + c *pkg.Cache, + a pkg.Artifact, +) { + if pathname, checksum, err := c.Cure(a); err != nil { + t.Fatalf("Cure: error = %v", err) + } else if err = os.Remove(pathname.String()); err != nil { + t.Fatal(err) + } else { + p := base.Append( + "checksum", + pkg.Encode(checksum.Value()), + ) + if err = filepath.WalkDir(p.String(), func( + path string, + d fs.DirEntry, + err error, + ) error { + if err != nil { + return err + } + if d.IsDir() { + return os.Chmod(path, 0700) + } + return nil + }); err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if err = os.RemoveAll(p.String()); err != nil { + t.Fatal(err) + } + } +} + +// newDestroyArtifactFunc returns a function that calls destroyArtifact. +func newDestroyArtifactFunc(a pkg.Artifact) func( + t *testing.T, + base *check.Absolute, + c *pkg.Cache, +) { + return func( + t *testing.T, + base *check.Absolute, + c *pkg.Cache, + ) { + destroyArtifact(t, base, c, a) + } +} + +// destroyStatus counts non-substitution status entries and destroys them. +func destroyStatus(t *testing.T, base *check.Absolute, c, s int) { + dents, err := os.ReadDir(base.Append("status").String()) + if err != nil { + t.Fatal(err) + } + + var gotC, gotS int + for _, dent := range dents { + if err = os.Remove(base.Append( + "status", + dent.Name(), + ).String()); err != nil { + t.Fatal(err) + } + + if dent.Type().IsRegular() { + gotC++ + continue + } + if dent.Type()&fs.ModeSymlink == fs.ModeSymlink { + gotS++ + continue + } + t.Errorf("%s: %s", dent.Name(), dent.Type()) + } + + if gotC != c { + t.Errorf("status: c = %d, want %d", gotC, c) + } + if gotS != s { + t.Errorf("status: s = %d, want %d", gotS, s) + } +} + +func TestIdent(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + a pkg.Artifact + want unique.Handle[pkg.ID] + }{ + {"decompress", &stubArtifact{ + pkg.KindDecompress, + []byte{pkg.Gzip, 0, 0, 0, 0, 0, 0, 0}, + []pkg.Artifact{ + overrideIdent{pkg.ID{}, new(stubArtifact)}, + }, + nil, + }, unique.Make[pkg.ID](pkg.MustDecode( + "fkmge-H56Ph2LzNNbEUjTypes8LfOxrF_CEXg07kq6ho_nbTNvzQde9-bjB7gyB_", + ))}, + } + + msg := message.New(log.New(os.Stderr, "ident: ", 0)) + msg.SwapVerbose(true) + var cache *pkg.Cache + if a, err := check.NewAbs(t.TempDir()); err != nil { + t.Fatal(err) + } else if cache, err = pkg.Open(t.Context(), msg, a, nil); err != nil { + t.Fatal(err) + } + t.Cleanup(cache.Close) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := cache.Ident(tc.a); got != tc.want { + t.Errorf("Ident: %s, want %s", + pkg.Encode(got.Value()), + pkg.Encode(tc.want.Value()), + ) + } + }) + } +} + +// An expectsKnown describes an expected file or directory. +type expectsKnown interface { + // hash returns the checksum of the represented data. + hash() (checksum pkg.Checksum) +} + +// An expectsChecksum is a prepared checksum value. +type expectsChecksum pkg.Checksum + +// hash returns e. +func (e expectsChecksum) hash() pkg.Checksum { return e } + +// An expectsFile is the contents of a file expected by the test suite. +type expectsFile []byte + +// hash computes the checksum of e. +func (e expectsFile) hash() (checksum pkg.Checksum) { + h := sha512.New384() + h.Write(e) + h.Sum(checksum[:0]) + return +} + +// An expectsFS describes the state of a filesystem expected by the test suite. +type expectsFS fstest.MapFS + +// hash computes the checksum of e. +func (e expectsFS) hash() (checksum pkg.Checksum) { + if err := pkg.SumFS(&checksum, fstest.MapFS(e), "."); err != nil { + panic(err) + } + return +} + +// expectsFrom generates expectsFS for a filesystem directory. +func expectsFrom(pathname string) string { + var buf strings.Builder + buf.WriteString("expectsFS{\n") + if err := filepath.WalkDir(pathname, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + var rel string + if rel, err = filepath.Rel(pathname, path); err != nil { + return err + } + buf.WriteString("\t" + strconv.Quote(rel) + ": {Mode: ") + + var fi fs.FileInfo + if fi, err = d.Info(); err != nil { + return err + } + mode := fi.Mode() + + switch { + case mode.IsDir(): + buf.WriteString("fs.ModeDir | 0" + + strconv.FormatInt(int64(mode&^fs.ModeDir), 8)) + + case mode&fs.ModeSymlink != 0: + buf.WriteString("fs.ModeSymlink | 0" + + strconv.FormatInt(int64(mode&^fs.ModeSymlink), 8) + + ", Data: []byte(") + var linkname string + if linkname, err = os.Readlink(path); err != nil { + return err + } + buf.WriteString(strconv.Quote(linkname)) + buf.WriteByte(')') + + case mode.IsRegular(): + buf.WriteString("0" + strconv.FormatInt(int64(mode), 8)) + var p []byte + if p, err = os.ReadFile(path); err != nil { + return err + } + + if len(p) > 0 { + buf.WriteString(", Data: []byte(") + buf.WriteString(strconv.Quote(unsafe.String(unsafe.SliceData(p), len(p)))) + buf.WriteByte(')') + } + } + buf.WriteString("},\n") + return nil + }); err != nil { + panic(err) + } + buf.WriteString("}") + return buf.String() +} + +// cacheTestCase is a test case passed to checkWithCache where a new instance +// of [pkg.Cache] is prepared for the test case, and is validated and removed +// on test completion. +type cacheTestCase struct { + name string + flags int + early func(t *testing.T, base *check.Absolute) + f func(t *testing.T, base *check.Absolute, c *pkg.Cache) + want expectsFS +} + +const ( + // checkDestroySubstitutes arranges for substitutes to be destroyed before + // measurement during checkWithCache. + checkDestroySubstitutes = 1 << (iota + 32) +) + +// makeBase returns a [pkg.Cache] base directory created for tb. +func makeBase(tb testing.TB) (base *check.Absolute) { + tb.Helper() + + base = check.MustAbs(tb.TempDir()) + if err := os.Chmod(base.String(), 0700); err != nil { + tb.Fatal(err) + } + tb.Cleanup(func() { + if err := filepath.WalkDir(base.String(), func(path string, d fs.DirEntry, err error) error { + if err != nil { + tb.Error(err) + return nil + } + if !d.IsDir() { + return nil + } + return os.Chmod(path, 0700) + }); err != nil { + tb.Fatal(err) + } + }) + return +} + +// checkWithCache runs a slice of cacheTestCase. +func checkWithCache(t *testing.T, testCases []cacheTestCase) { + t.Helper() + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + t.Parallel() + + base := makeBase(t) + msg := message.New(log.New(os.Stderr, "cache: ", 0)) + msg.SwapVerbose(testing.Verbose()) + + flags := tc.flags | pkg.CSuppressInit + + if info.CanDegrade { + if _, err := landlock.GetABI(); err != nil { + if !errors.Is(err, syscall.ENOSYS) { + t.Fatalf("LandlockGetABI: error = %v", err) + } + flags |= pkg.CHostAbstract + t.Log("Landlock LSM is unavailable, setting CHostAbstract") + } + } + + var scrubFunc func() error // scrub after hashing + if c, err := pkg.Open(t.Context(), msg, base, &pkg.CacheAttr{ + Cures: 1 << 4, + Flags: flags, + }); err != nil { + t.Fatalf("Open: error = %v", err) + } else { + t.Cleanup(c.Close) + if tc.early != nil { + tc.early(t, base) + } + tc.f(t, base, c) + scrubFunc = func() error { + err = c.Scrub(1 << 7) + idents, checksums, cleanErr := c.Clean(false, false) + if len(idents) > 0 { + t.Errorf("destroyed %d idents", len(idents)) + } + if len(checksums) > 0 { + t.Errorf("destroyed %d checksums", len(checksums)) + } + return errors.Join(err, cleanErr) + } + } + + var restoreTemp bool + if _, err := os.Lstat(base.Append("temp").String()); err != nil { + if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + } else { + restoreTemp = true + } + + // destroy lock and variant file to avoid changing cache checksums + for _, s := range []string{ + "lock", + "variant", + } { + pathname := base.Append(s) + if p, err := os.ReadFile(pathname.String()); err != nil { + t.Fatal(err) + } else if len(p) != 0 { + t.Fatalf("file %q: %q", s, string(p)) + } + if err := os.Remove(pathname.String()); err != nil { + t.Fatal(err) + } + } + + // destroy non-deterministic substitutes + if tc.flags&checkDestroySubstitutes != 0 { + substitute := base.Append("substitute") + if err := os.RemoveAll(substitute.String()); err != nil { + t.Fatal(err) + } else if err = os.Mkdir(substitute.String(), 0700); err != nil { + t.Fatal(err) + } + } + + // destroy empty status directory + if err := syscall.Rmdir(base.Append("status").String()); err != nil { + if !errors.Is(err, syscall.ENOTEMPTY) { + t.Fatal(err) + } + } + + // destroy empty fault directory + if err := os.Remove(base.Append("fault").String()); err != nil { + t.Fatal(err) + } + + want := tc.want.hash() + + var checksum pkg.Checksum + if err := pkg.SumDir(&checksum, base); err != nil { + t.Fatalf("SumDir: error = %v", err) + } else if checksum != want { + t.Fatal(expectsFrom(base.String())) + } + + if err := scrubFunc(); err != nil { + t.Fatal("cache contains inconsistencies\n\n" + err.Error()) + } + + if restoreTemp { + if err := os.Mkdir( + base.Append("temp").String(), + 0700, + ); err != nil { + t.Fatal(err) + } + } + + // validate again to make sure scrub did not condemn anything + if err := pkg.SumDir(&checksum, base); err != nil { + t.Fatalf("SumDir: error = %v", err) + } else if checksum != want { + t.Fatalf("(scrubbed) %s", expectsFrom(base.String())) + } + }) + } +} + +// A cureStep contains an [Artifact] to be cured, and the expected outcome. +type cureStep struct { + name string + + a pkg.Artifact + + pathname *check.Absolute + output expectsKnown + whence int + err error +} + +// ignorePathname is passed to cureMany to skip the pathname check. +var ignorePathname = check.MustAbs("/\x00") + +// cureMany cures many artifacts against a [Cache] and checks their outcomes. +func cureMany(t *testing.T, c *pkg.Cache, steps []cureStep) { + t.Helper() + + makeChecksumH := func(checksum pkg.Checksum) unique.Handle[pkg.Checksum] { + if checksum == (pkg.Checksum{}) { + return unique.Handle[pkg.Checksum]{} + } + return unique.Make(checksum) + } + + for _, step := range steps { + t.Log("cure step:", step.name) + if pathname, checksum, whence, err := c.CureWhence(step.a); !reflect.DeepEqual(err, step.err) { + faults, _err := c.ReadFaults(step.a) + if _err != nil { + t.Errorf("ReadFaults: error = %v", _err) + } + + var p []byte + for _, fault := range faults { + p, _err = os.ReadFile(fault.String()) + if _err != nil { + t.Error(_err) + continue + } + t.Log(string(p)) + t.Logf("faulting cure terminated %s ago", time.Since(faults[0].Time())) + } + t.Fatalf("Cure: error = %v, want %v", err, step.err) + } else if step.whence != whence { + t.Fatalf("Cure: whence = %s, want %s", pkg.WhenceString(whence), pkg.WhenceString(step.whence)) + } else if step.pathname != ignorePathname && !pathname.Is(step.pathname) { + t.Fatalf("Cure: pathname = %q, want %q", pathname, step.pathname) + } else if step.output == nil || checksum != makeChecksumH(step.output.hash()) { + if pathname != nil { + if name, _err := filepath.EvalSymlinks(pathname.String()); _err != nil { + t.Fatal(_err) + } else { + t.Fatal(expectsFrom(name)) + } + } else if checksum != (unique.Handle[pkg.Checksum]{}) { + t.Fatalf("Cure: unexpected checksum %s", pkg.Encode(checksum.Value())) + } + } else { + v := any(err) + if err == nil { + v = pathname + } + var checksumVal pkg.Checksum + if checksum != (unique.Handle[pkg.Checksum]{}) { + checksumVal = checksum.Value() + } + t.Log(pkg.Encode(checksumVal)+":", v) + } + } +} + +// newWantScrubError returns the address to a new [ScrubError] for base. +func newWantScrubError(base *check.Absolute) *pkg.ScrubError { + return &pkg.ScrubError{ + ChecksumMismatches: []pkg.ChecksumMismatchError{ + {Got: pkg.MustDecode( + "vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX", + ), Want: pkg.Checksum{0xff, 0}}, + }, + DanglingIdentifiers: []pkg.ID{ + {0xfe, 0}, + {0xfe, 0xfe}, + {0xfe, 0xff}, + }, + Errs: map[unique.Handle[string]][]error{ + base.Append("checksum", "__8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Handle(): { + pkg.InvalidFileModeError(fs.ModeSymlink), + }, + + base.Append("checksum", "invalid").Handle(): { + base64.CorruptInputError(4), + }, + + base.Append("nonexistent").Handle(): { + base64.CorruptInputError(8), + }, + + base.Append("identifier", pkg.Encode(pkg.ID{0xfe, 0xff})).Handle(): { + &os.PathError{ + Op: "readlink", + Path: base.Append( + "identifier", + pkg.Encode(pkg.ID{0xfe, 0xff}), + ).String(), + Err: syscall.EINVAL, + }, + }, + base.Append("identifier", "invalid").Handle(): { + base64.CorruptInputError(4), + }, + }, + } +} + +func TestCache(t *testing.T) { + t.Parallel() + + testdata := expectsFile("" + + "\x00\x00\x00\x00" + + "\xad\x0b\x00" + + "\x04" + + "\xfe\xfe\x00\x00" + + "\xfe\xca\x00\x00") + + testCases := []cacheTestCase{ + {"file", pkg.CValidateKnown | pkg.CAssumeChecksum, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + identifier := (pkg.ID)(bytes.Repeat([]byte{ + 0x75, 0xe6, 0x9d, 0x6d, 0xe7, 0x9f, + }, 8)) + wantPathname := base.Append( + "identifier", + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ) + identifier0 := (pkg.ID)(bytes.Repeat([]byte{ + 0x71, 0xa7, 0xde, 0x6d, 0xa6, 0xde, + }, 8)) + wantPathname0 := base.Append( + "identifier", + "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe", + ) + failingFile := newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 3}, + nil, + nil, struct { + _ []byte + stub.UniqueError + }{UniqueError: 0xbad}, + ) + + cureMany(t, c, []cureStep{ + {"initial file", newStubFile( + pkg.KindHTTPGet, + identifier, + new(testdata.hash()), + testdata, nil, + ), wantPathname, testdata, pkg.WNew, nil}, + + {"identical content", newStubFile( + pkg.KindHTTPGet, + identifier0, + new(testdata.hash()), + testdata, nil, + ), wantPathname0, testdata, pkg.WCache, nil}, + + {"existing entry", newStubFile( + pkg.KindHTTPGet, + identifier, + new(testdata.hash()), + testdata, nil, + ), wantPathname, testdata, pkg.WCache, nil}, + + {"checksum mismatch", newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 0}, + new(pkg.Checksum), + testdata, nil, + ), nil, nil, pkg.WNew, &pkg.ChecksumMismatchError{ + Got: testdata.hash(), + }}, + + {"store without validation", newStubFile( + pkg.KindHTTPGet, + pkg.MustDecode("vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX"), + nil, + []byte{0}, nil, + ), base.Append( + "identifier", + "vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX", + ), expectsChecksum{ + 0xbe, 0xc0, 0x21, 0xb4, 0xf3, 0x68, + 0xe3, 0x06, 0x91, 0x34, 0xe0, 0x12, + 0xc2, 0xb4, 0x30, 0x70, 0x83, 0xd3, + 0xa9, 0xbd, 0xd2, 0x06, 0xe2, 0x4e, + 0x5f, 0x0d, 0x86, 0xe1, 0x3d, 0x66, + 0x36, 0x65, 0x59, 0x33, 0xec, 0x2b, + 0x41, 0x34, 0x65, 0x96, 0x68, 0x17, + 0xa9, 0xc2, 0x08, 0xa1, 0x17, 0x17, + }, pkg.WNew, nil}, + + {"incomplete implementation", struct{ pkg.Artifact }{&stubArtifact{ + kind: pkg.KindExec, + params: []byte("artifact overridden to be incomplete"), + }}, nil, nil, pkg.WNew, pkg.InvalidArtifactError(pkg.MustDecode( + "poVrv3zNINbHd-WUJihwgWQwJpbHURFHfpY7Zux-ztto_bQhhS5PdpbNMjdkXv-V", + ))}, + + {"error passthrough", newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 1}, + nil, + nil, stub.UniqueError(0xcafe), + ), nil, nil, pkg.WNew, stub.UniqueError(0xcafe)}, + + {"error caching", newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 1}, + nil, + nil, nil, + ), nil, nil, pkg.WCache, stub.UniqueError(0xcafe)}, + + {"cache hit bad type", overrideChecksum{testdata.hash(), overrideIdent{pkg.ID{0xff, 2}, &stubArtifact{ + kind: pkg.KindTar, + }}}, nil, nil, pkg.WCache, pkg.InvalidFileModeError( + 0400, + )}, + + {"noncomparable error", &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("artifact with dependency returning noncomparable error"), + deps: []pkg.Artifact{failingFile}, + + cure: func(f *pkg.FContext) error { + panic("attempting to cure impossible artifact") + }, + }, nil, nil, pkg.WNew, pkg.InputError{ + failingFile: struct { + _ []byte + stub.UniqueError + }{UniqueError: 0xbad}, + }}, + }) + + if c0, err := pkg.Open( + t.Context(), + message.New(nil), + base, &skipLock, + ); err != nil { + t.Fatalf("open: error = %v", err) + } else { + t.Cleanup(c.Close) // check doubled cancel + cureMany(t, c0, []cureStep{ + {"cache hit ident", overrideIdent{ + id: identifier, + }, wantPathname, testdata, pkg.WCache, nil}, + + {"cache miss checksum match", newStubFile( + pkg.KindHTTPGet, + testdata.hash(), + nil, + testdata, + nil, + ), base.Append( + "identifier", + pkg.Encode(testdata.hash()), + ), testdata, pkg.WNew, nil}, + }) + + // cure after close + c.Close() + if _, _, err = c.Cure(&stubArtifactF{ + kind: pkg.KindExec, + params: []byte("unreachable artifact cured after cancel"), + deps: []pkg.Artifact{pkg.NewFile("", []byte("unreachable dependency"))}, + }); !reflect.DeepEqual(err, context.Canceled) { + t.Fatalf("(closed) Cure: error = %v", err) + } + } + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX": {Mode: 0400, Data: []byte{0}}, + "checksum/0bSFPu5Tnd-2Jj0Mv6co23PW2t3BmHc7eLFj9TgY3eIBg8zislo7xZYNBqovVLcq": {Mode: 0400, Data: []byte{0, 0, 0, 0, 0xad, 0xb, 0, 4, 0xfe, 0xfe, 0, 0, 0xfe, 0xca, 0, 0}}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX")}, + "identifier/0bSFPu5Tnd-2Jj0Mv6co23PW2t3BmHc7eLFj9TgY3eIBg8zislo7xZYNBqovVLcq": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/0bSFPu5Tnd-2Jj0Mv6co23PW2t3BmHc7eLFj9TgY3eIBg8zislo7xZYNBqovVLcq")}, + "identifier/cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/0bSFPu5Tnd-2Jj0Mv6co23PW2t3BmHc7eLFj9TgY3eIBg8zislo7xZYNBqovVLcq")}, + "identifier/deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/0bSFPu5Tnd-2Jj0Mv6co23PW2t3BmHc7eLFj9TgY3eIBg8zislo7xZYNBqovVLcq")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"directory", pkg.CAssumeChecksum, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + id := pkg.MustDecode( + "HnySzeLQvSBZuTUcvfmLEX_OmH4yJWWH788NxuLuv7kVn8_uPM6Ks4rqFWM2NZJY", + ) + makeSample := func(t *pkg.TContext) error { + work := t.GetWorkDir() + if err := os.Mkdir(work.String(), 0700); err != nil { + return err + } + + if err := os.WriteFile( + work.Append("check").String(), + []byte{0, 0}, + 0400, + ); err != nil { + return err + } + + if err := os.MkdirAll(work.Append( + "lib", + "pkgconfig", + ).String(), 0700); err != nil { + return err + } + + return os.Symlink( + "/proc/nonexistent/libedac.so", + work.Append( + "lib", + "libedac.so", + ).String(), + ) + } + want := expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + + "check": {Mode: 0400, Data: []byte{0, 0}}, + + "lib": {Mode: fs.ModeDir | 0700}, + "lib/libedac.so": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent/libedac.so")}, + + "lib/pkgconfig": {Mode: fs.ModeDir | 0700}, + } + wantPathname := base.Append( + "identifier", + pkg.Encode(id), + ) + + id0 := pkg.MustDecode( + "Zx5ZG9BAwegNT3zQwCySuI2ktCXxNgxirkGLFjW4FW06PtojYVaCdtEw8yuntPLa", + ) + wantPathname0 := base.Append( + "identifier", + pkg.Encode(id0), + ) + + makeGarbage := func(work *check.Absolute, wantErr error) error { + if err := os.Mkdir(work.String(), 0700); err != nil { + return err + } + + mode := fs.FileMode(0) + if wantErr == nil { + mode = 0500 + } + + if err := os.MkdirAll(work.Append( + "lib", + "pkgconfig", + ).String(), 0700); err != nil { + return err + } + + if err := os.WriteFile(work.Append( + "lib", + "check", + ).String(), nil, 0400&mode); err != nil { + return err + } + + if err := os.Chmod(work.Append( + "lib", + "pkgconfig", + ).String(), 0500&mode); err != nil { + return err + } + if err := os.Chmod(work.Append( + "lib", + ).String(), 0500&mode); err != nil { + return err + } + + return wantErr + } + + cureMany(t, c, []cureStep{ + {"initial directory", overrideChecksum{want.hash(), overrideIdent{id, &stubArtifact{ + kind: pkg.KindTar, + cure: makeSample, + }}}, wantPathname, want, pkg.WNew, nil}, + + {"identical identifier", overrideChecksum{want.hash(), overrideIdent{id, &stubArtifact{ + kind: pkg.KindTar, + }}}, wantPathname, want, pkg.WCache, nil}, + + {"identical checksum", overrideIdent{id0, &stubArtifact{ + kind: pkg.KindTar, + cure: makeSample, + }}, wantPathname0, want, pkg.WNew, nil}, + + {"cure fault", overrideIdent{pkg.ID{0xff, 0}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + return makeGarbage(t.GetWorkDir(), stub.UniqueError(0xcafe)) + }, + }}, nil, nil, pkg.WNew, stub.UniqueError(0xcafe)}, + + {"checksum mismatch", overrideChecksum{pkg.Checksum{}, overrideIdent{pkg.ID{0xff, 1}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + return makeGarbage(t.GetWorkDir(), nil) + }, + }}}, nil, nil, pkg.WNew, &pkg.ChecksumMismatchError{ + Got: pkg.MustDecode( + "CUx-3hSbTWPsbMfDhgalG4Ni_GmR9TnVX8F99tY_P5GtkYvczg9RrF5zO0jX9XYT", + ), + }}, + + {"cache hit bad type", newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 2}, + new(want.hash()), + testdata, nil, + ), nil, nil, pkg.WCache, pkg.InvalidFileModeError( + fs.ModeDir | 0500, + )}, + + {"openFile directory", overrideIdent{pkg.ID{0xff, 3}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + r, err := t.Open(overrideChecksumFile{checksum: want.hash()}) + if err != nil { + panic(err) + } + _, err = io.ReadAll(r) + return err + }, + }}, nil, nil, pkg.WNew, &os.PathError{ + Op: "read", + Path: base.Append( + "checksum", + pkg.Encode(want.hash()), + ).String(), + Err: syscall.EISDIR, + }}, + + {"no output", overrideIdent{pkg.ID{0xff, 4}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + return nil + }, + }}, nil, nil, pkg.WNew, pkg.NoOutputError{}}, + + {"file output", overrideIdent{pkg.ID{0xff, 5}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + return os.WriteFile(t.GetWorkDir().String(), []byte{0}, 0400) + }, + }}, nil, nil, pkg.WNew, errors.New("non-file artifact produced regular file")}, + + {"symlink output", overrideIdent{pkg.ID{0xff, 6}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + return os.Symlink( + t.GetWorkDir().String(), + t.GetWorkDir().String(), + ) + }, + }}, nil, nil, pkg.WNew, pkg.InvalidFileModeError( + fs.ModeSymlink | 0777, + )}, + + {"alternative", &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("substitutable artifact"), + deps: []pkg.Artifact{newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 8}, + nil, + []byte("substitutable dependency"), + nil, + )}, + + cure: func(f *pkg.FContext) error { + return makeSample(&f.TContext) + }, + }, base.Append( + "identifier", + "gIH5eti07nBAhVti3ZTn8EIGOBCZWWvNpxg5CmCZlDzIC4mBdomPmQLeEpf1IQg9", + ), want, pkg.WNew, nil}, + + {"substitutable", &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("substitutable artifact"), + deps: []pkg.Artifact{newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 10}, + nil, + []byte("substitutable dependency"), + nil, + )}, + + cure: func(f *pkg.FContext) error { + panic("substitution missed") + }, + }, base.Append( + "identifier", + "VGjpp7NVw3OYLpgOQumfM0KcJgY3suz6torykUczcg4bGT24ySkPn5hy-4dNqqkR", + ), want, pkg.WSubstitute, nil}, + }) + + if c0, err := pkg.Open( + t.Context(), + message.New(nil), + base, &skipLock, + ); err != nil { + t.Fatalf("open: error = %v", err) + } else { + t.Cleanup(c.Close) + cureMany(t, c0, []cureStep{ + {"substitutable", &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("substitutable artifact"), + deps: []pkg.Artifact{newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 0xff, 0xfd, 0xfd}, + nil, + []byte("substitutable dependency"), + nil, + )}, + + cure: func(f *pkg.FContext) error { + panic("substitution missed") + }, + }, base.Append( + "identifier", + "zeZ4JEZ0atAKl6Am67cBID6-KR18nZGgeYfU4qTuYaWhQC4eAn0dyTXdIBH9Bos8", + ), want, pkg.WSubstitute, nil}, + }) + } + + if dents, err := os.ReadDir(base.Append("status").String()); err != nil { + t.Fatal(err) + } else if len(dents) > 0 { + t.Errorf("ReadDir: %v", dents) + } + + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/7lfQ4QwSpV8nw7IDh0JiQ_jqUPrPv3_Vfie034RxsSy-cy4vO8DVvxgpx2LW08oO": {Mode: 0400, Data: []byte("substitutable dependency")}, + "checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b": {Mode: fs.ModeDir | 0500}, + "checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b/check": {Mode: 0400, Data: []byte{0, 0}}, + "checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b/lib": {Mode: fs.ModeDir | 0700}, + "checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b/lib/pkgconfig": {Mode: fs.ModeDir | 0700}, + "checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b/lib/libedac.so": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent/libedac.so")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/HnySzeLQvSBZuTUcvfmLEX_OmH4yJWWH788NxuLuv7kVn8_uPM6Ks4rqFWM2NZJY": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b")}, + "identifier/Zx5ZG9BAwegNT3zQwCySuI2ktCXxNgxirkGLFjW4FW06PtojYVaCdtEw8yuntPLa": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b")}, + "identifier/zeZ4JEZ0atAKl6Am67cBID6-KR18nZGgeYfU4qTuYaWhQC4eAn0dyTXdIBH9Bos8": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b")}, + "identifier/___9_QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/7lfQ4QwSpV8nw7IDh0JiQ_jqUPrPv3_Vfie034RxsSy-cy4vO8DVvxgpx2LW08oO")}, + "identifier/_wgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/7lfQ4QwSpV8nw7IDh0JiQ_jqUPrPv3_Vfie034RxsSy-cy4vO8DVvxgpx2LW08oO")}, + "identifier/_woAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/7lfQ4QwSpV8nw7IDh0JiQ_jqUPrPv3_Vfie034RxsSy-cy4vO8DVvxgpx2LW08oO")}, + "identifier/VGjpp7NVw3OYLpgOQumfM0KcJgY3suz6torykUczcg4bGT24ySkPn5hy-4dNqqkR": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b")}, + "identifier/gIH5eti07nBAhVti3ZTn8EIGOBCZWWvNpxg5CmCZlDzIC4mBdomPmQLeEpf1IQg9": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + "substitute/nWPg893oqn_YNN8kgfZrs_LVLwP90e0mKxqU8UwoazQh-yBPwhoviD7of6YALx-M": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/qRN6in76LndiiOZJheHkwyW8UT1N5-f-bXvHfDvwrMw2fSkOoZdh8pWE1qhLk65b")}, + + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"pending", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + wantErr := stub.UniqueError(0xcafe) + n, ready := make(chan struct{}), make(chan struct{}) + go func() { + if _, _, err := c.Cure(overrideIdent{pkg.ID{0xff}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + close(ready) + <-n + return wantErr + }, + }}); !reflect.DeepEqual(err, wantErr) { + panic(fmt.Sprintf("Cure: error = %v, want %v", err, wantErr)) + } + }() + + <-ready + wCureDone := make(chan struct{}) + go func() { + if _, _, err := c.Cure(overrideIdent{pkg.ID{0xff}, &stubArtifact{ + kind: pkg.KindTar, + }}); !reflect.DeepEqual(err, wantErr) { + panic(fmt.Sprintf("Cure: error = %v, want %v", err, wantErr)) + } + close(wCureDone) + }() + + // check cache activity while a cure is blocking + cureMany(t, c, []cureStep{ + {"error passthrough", newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 1}, + nil, + nil, stub.UniqueError(0xbad), + ), nil, nil, pkg.WNew, stub.UniqueError(0xbad)}, + + {"file output", overrideIdent{pkg.ID{0xff, 2}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + return os.WriteFile( + t.GetWorkDir().String(), + []byte{0}, + 0400, + ) + }, + }}, nil, nil, pkg.WNew, errors.New( + "non-file artifact produced regular file", + )}, + }) + + wantErrScrub := &pkg.ScrubError{ + Errs: map[unique.Handle[string]][]error{ + base.Handle(): {errors.New("scrub began with pending artifacts")}, + }, + } + if err := c.Scrub(1 << 6); !reflect.DeepEqual(err, wantErrScrub) { + t.Fatalf("Scrub: error = %#v, want %#v", err, wantErrScrub) + } + + notify := c.Done(unique.Make(pkg.ID{0xff})) + go close(n) + if notify != nil { + <-notify + } + for c.Done(unique.Make(pkg.ID{0xff})) != nil { + } + <-wCureDone + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + "checksum": {Mode: fs.ModeDir | 0700}, + "identifier": {Mode: fs.ModeDir | 0700}, + "substitute": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"cancel abort block", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + var wg sync.WaitGroup + defer wg.Wait() + + var started sync.WaitGroup + defer started.Wait() + + blockCures := func(d byte, e stub.UniqueError, n int) { + started.Add(n) + for i := range n { + wg.Go(func() { + if _, _, err := c.Cure(overrideIdent{pkg.ID{d, byte(i)}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + started.Done() + <-t.Unwrap().Done() + return e + stub.UniqueError(i) + }, + }}); !reflect.DeepEqual(err, e+stub.UniqueError(i)) { + panic(err) + } + }) + } + started.Wait() + } + + blockCures(0xfd, 0xbad, 16) + c.Abort() + wg.Wait() + + blockCures(0xfd, 0xcafe, 16) + c.Abort() + wg.Wait() + + blockCures(0xff, 0xbad, 1) + if !c.Cancel(unique.Make(pkg.ID{0xff})) { + t.Fatal("missed cancellation") + } + wg.Wait() + + blockCures(0xff, 0xcafe, 1) + if !c.Cancel(unique.Make(pkg.ID{0xff})) { + t.Fatal("missed cancellation") + } + wg.Wait() + + for c.Cancel(unique.Make(pkg.ID{0xff})) { + } + + c.Close() + c.Abort() + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + "checksum": {Mode: fs.ModeDir | 0700}, + "identifier": {Mode: fs.ModeDir | 0700}, + "substitute": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"no assume checksum", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + makeGarbage := func(work *check.Absolute, wantErr error) error { + if err := os.Mkdir(work.String(), 0700); err != nil { + return err + } + + if err := os.WriteFile(work.Append( + "check", + ).String(), nil, 0400); err != nil { + return err + } + + return wantErr + } + + want := expectsChecksum(pkg.MustDecode( + "Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M", + )) + + cureMany(t, c, []cureStep{ + {"create", overrideChecksum{want.hash(), overrideIdent{pkg.ID{0xff, 0}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + return makeGarbage(t.GetWorkDir(), nil) + }, + }}}, base.Append( + "identifier", + pkg.Encode(pkg.ID{0xff, 0}), + ), want, pkg.WNew, nil}, + + {"reject", overrideChecksum{want.hash(), overrideIdent{pkg.ID{0xfe, 1}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + return makeGarbage(t.GetWorkDir(), stub.UniqueError(0xbad)) + }, + }}}, nil, nil, pkg.WNew, stub.UniqueError(0xbad)}, + + {"match", overrideChecksum{want.hash(), overrideIdent{pkg.ID{0xff, 1}, &stubArtifact{ + kind: pkg.KindTar, + cure: func(t *pkg.TContext) error { + return makeGarbage(t.GetWorkDir(), nil) + }, + }}}, base.Append( + "identifier", + pkg.Encode(pkg.ID{0xff, 1}), + ), want, pkg.WNew, nil}, + }) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M": {Mode: fs.ModeDir | 0500}, + "checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M/check": {Mode: 0400, Data: []byte{}}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/_wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M")}, + "identifier/_wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"scrub", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + cureMany(t, c, []cureStep{ + {"bad measured file", newStubFile( + pkg.KindHTTPGet, + pkg.Checksum{0xfe, 0}, + &pkg.Checksum{0xff, 0}, + []byte{0}, nil, + ), base.Append( + "identifier", + pkg.Encode(pkg.Checksum{0xfe, 0}), + ), expectsChecksum{0xff, 0}, pkg.WNew, nil}, + }) + + for _, p := range [][]string{ + {"identifier", "invalid"}, + {"identifier", pkg.Encode(pkg.ID{0xfe, 0xff})}, + {"checksum", "invalid"}, + } { + if err := os.WriteFile( + base.Append(p...).String(), + nil, + 0400, + ); err != nil { + t.Fatal(err) + } + } + + for _, p := range [][]string{ + {"../nonexistent", "checksum", pkg.Encode(pkg.Checksum{0xff, 0xff})}, + {"../nonexistent", "identifier", pkg.Encode(pkg.Checksum{0xfe, 0xfe})}, + } { + if err := os.Symlink( + p[0], + base.Append(p[1:]...).String(), + ); err != nil { + t.Fatal(err) + } + } + + wantErr := newWantScrubError(base) + if err := c.Scrub(1 << 6); !reflect.DeepEqual(err, wantErr) { + t.Fatalf("Scrub: error =\n%s\nwant\n%s", err, wantErr) + } + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + "checksum": {Mode: fs.ModeDir | 0700}, + "identifier": {Mode: fs.ModeDir | 0700}, + "substitute": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"status substitute clean", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + destroyed := &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("destroyed"), + deps: []pkg.Artifact{ + pkg.NewFile("destroyed-input", []byte("destroyed")), + }, + cure: func(f *pkg.FContext) error { + if w, err := f.GetStatusWriter(); err != nil { + return err + } else if _, err = w.Write([]byte("destroyed")); err != nil { + return err + } + + p := f.GetWorkDir() + if err := os.MkdirAll(p.String(), 0755); err != nil { + return err + } + return os.WriteFile(p.Append("result").String(), nil, 0444) + }, + } + substituted := new(*destroyed) + substituted.deps = []pkg.Artifact{ + pkg.NewFile("destroyed-input-0", []byte("destroyed")), + } + substituted.cure = func(*pkg.FContext) error { + panic("substitutable cure reached") + } + + cureMany(t, c, []cureStep{ + {"destroyed", destroyed, base.Append( + "identifier", + pkg.Encode(c.Ident(destroyed).Value()), + ), expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + "result": {Mode: 0444}, + }, pkg.WNew, nil}, + + {"substituted", substituted, base.Append( + "identifier", + pkg.Encode(c.Ident(substituted).Value()), + ), expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + "result": {Mode: 0444}, + }, pkg.WSubstitute, nil}, + }) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE": {Mode: fs.ModeDir | 0500}, + "checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE/result": {Mode: 0444}, + "checksum/wILUy2izpj2sgKJVhGUGIAde1XVuqvp5BpFMIQHanT5Q8R6jK4QPVSrJsjZh-njV": {Mode: 0400, Data: []byte("destroyed")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/CLyyxJWn1yUGFs_0KnJ4ZSU2hH4-elvGFRB36x14LrNrKBJPxvORhL9O4Rc7oeYU": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/wILUy2izpj2sgKJVhGUGIAde1XVuqvp5BpFMIQHanT5Q8R6jK4QPVSrJsjZh-njV")}, + "identifier/arv8a6z4i0x6pUb5Z2T-3iE4ETvyZj-uReHGKsS7uiBgMgKNEOzTGG3LIZb8vp0L": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/wILUy2izpj2sgKJVhGUGIAde1XVuqvp5BpFMIQHanT5Q8R6jK4QPVSrJsjZh-njV")}, + "identifier/heRgWhNJmhm898V68kw0ta76t4rP1o29tYW_TrdyBWXGfo37K3QWA7Z-JkLXAwcn": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE")}, + "identifier/q-VoVBI3IjcWTqwIokpi3y05CP113CHgHslc-XWnhOvDULwzVc3q9Y5xOCm_YyLZ": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE")}, + + "status": {Mode: fs.ModeDir | 0700}, + "status/q-VoVBI3IjcWTqwIokpi3y05CP113CHgHslc-XWnhOvDULwzVc3q9Y5xOCm_YyLZ": {Mode: fs.ModeSymlink | 0777, Data: []byte("dP-6_wIDRRouOaOF-nkBy-IaUbLdYHbOUTrptu7j_qfW01mnHSjYJ0oykUmvUd2x")}, + "status/heRgWhNJmhm898V68kw0ta76t4rP1o29tYW_TrdyBWXGfo37K3QWA7Z-JkLXAwcn": {Mode: 0400, Data: []byte(statusHeader + "destroyed")}, + "status/dP-6_wIDRRouOaOF-nkBy-IaUbLdYHbOUTrptu7j_qfW01mnHSjYJ0oykUmvUd2x": {Mode: 0400, Data: []byte(statusHeader + "destroyed")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + "substitute/dP-6_wIDRRouOaOF-nkBy-IaUbLdYHbOUTrptu7j_qfW01mnHSjYJ0oykUmvUd2x": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/UjZSrgz7_B7XMd9fHU7jM33UZhWlFgX0rz7JZbCBYR28bCS7jr_CAJdcDhi52ruE")}, + + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"extern", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + a := &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("extern"), + } + wantIdent := c.Ident(a) + wantOutput := expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + "result": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent")}, + } + var wantChecksum pkg.Checksum + if err := pkg.SumFS( + &wantChecksum, + fstest.MapFS(wantOutput), + ".", + ); err != nil { + t.Fatal(err) + } + wantChecksumH := unique.Make(wantChecksum) + + _a := &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("extern substitute"), + deps: []pkg.Artifact{pkg.NewFile("", nil)}, + } + _wantIdent := c.Ident(_a) + _wantOutput := expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + } + var _wantChecksum pkg.Checksum + if err := pkg.SumFS( + &_wantChecksum, + fstest.MapFS(_wantOutput), + ".", + ); err != nil { + t.Fatal(err) + } + _wantChecksumH := unique.Make(_wantChecksum) + + kca := pkg.NewExec( + "", "", + new(pkg.Checksum), 0, false, false, + fhs.AbsRoot, nil, fhs.AbsRoot, nil, + ) + kcIdent := c.Ident(kca) + + c.SetExternal(stubExtern{ + artifact: map[unique.Handle[pkg.ID]]pkg.Checksum{ + wantIdent: wantChecksum, + _wantIdent: _wantChecksum, + kcIdent: wantChecksum, + }, + checksum: map[unique.Handle[pkg.Checksum]]fstest.MapFS{ + wantChecksumH: fstest.MapFS(wantOutput), + _wantChecksumH: fstest.MapFS(_wantOutput), + }, + status: map[unique.Handle[pkg.ID]]string{ + wantIdent: "\x00", + kcIdent: "unreachable", + }, + }) + + cureMany(t, c, []cureStep{ + {"extern", a, base.Append( + "identifier", + pkg.Encode(wantIdent.Value()), + ), wantOutput, pkg.WExternal, nil}, + + {"substitute", _a, base.Append( + "identifier", + pkg.Encode(_wantIdent.Value()), + ), _wantOutput, pkg.WExternal, nil}, + + {"mismatch", kca, nil, nil, pkg.WExternal, &pkg.ChecksumMismatchError{ + Got: wantChecksum, + }}, + }) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU": {Mode: fs.ModeDir | 0500}, + "checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb": {Mode: 0400}, + "checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl": {Mode: fs.ModeDir | 0500}, + "checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl/result": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/k8yyhlBV3JpYzzywAnrRWkfp2TmgIDNC6FqMFwrrR2x0C3unBLveDNKgigOjG9MM": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + "identifier/Xys8hPpJsKirZmTTuT7SwQ6661TCp_ZRu8iUjPPD7pC5E3ll7xw4zhNlZ2PVcz_-": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl")}, + "identifier/8sInO_dhPW9kweit7jVuH_vtMkRxlZT7XE4_yEKPZmpGkgVxkmxz0OpXgbMsH6ET": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + "identifier/ofaXbB5gIza_7W4Rzo5XDe_8FwlCPKWn3TBcmGgFAkkTWMl5GXGmOzX89Jd2Mk2A": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb")}, + "identifier/8VL-egZySw-RbnHZtxGWdf5Z2v9S9CihrdFwa54Bc5EYMQcEgWEem7QrdCTWlCb8": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl")}, + + "status": {Mode: fs.ModeDir | 0700}, + "status/8VL-egZySw-RbnHZtxGWdf5Z2v9S9CihrdFwa54Bc5EYMQcEgWEem7QrdCTWlCb8": {Mode: 0400, Data: []byte("\x00")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + "substitute/socGRnhdsJlJ4Z01ZwHFZdLzFH0rWvoaJrylcUKtvhBdGsE7VoYl-vdUuvfK8B4I": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"extern shallow", pkg.CExternShallow, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + a := &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("extern"), + } + wantIdent := c.Ident(a) + wantOutput := expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + "result": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent")}, + } + var wantChecksum pkg.Checksum + if err := pkg.SumFS( + &wantChecksum, + fstest.MapFS(wantOutput), + ".", + ); err != nil { + t.Fatal(err) + } + wantChecksumH := unique.Make(wantChecksum) + + _a := &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("extern substitute"), + deps: []pkg.Artifact{pkg.NewFile("", nil)}, + } + _wantIdent := c.Ident(_a) + _wantOutput := expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + } + var _wantChecksum pkg.Checksum + if err := pkg.SumFS( + &_wantChecksum, + fstest.MapFS(_wantOutput), + ".", + ); err != nil { + t.Fatal(err) + } + _wantChecksumH := unique.Make(_wantChecksum) + + kca := pkg.NewExec( + "", "", + new(pkg.Checksum), 0, false, false, + fhs.AbsRoot, nil, fhs.AbsRoot, nil, + ) + kcIdent := c.Ident(kca) + + fia := &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("flood input"), + deps: []pkg.Artifact{_a}, + } + fiaIdent := c.Ident(fia) + + flia := &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("flood input local"), + deps: []pkg.Artifact{_a}, + } + fliaIdent := c.Ident(flia) + + h := &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("hanging input"), + } + + fhia := &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("flood input local"), + deps: []pkg.Artifact{h}, + } + fhiaIdent := c.Ident(fhia) + + c.SetExternal(stubExtern{ + artifact: map[unique.Handle[pkg.ID]]pkg.Checksum{ + wantIdent: wantChecksum, + _wantIdent: _wantChecksum, + kcIdent: wantChecksum, + fiaIdent: wantChecksum, + fliaIdent: wantChecksum, + fhiaIdent: wantChecksum, + }, + checksum: map[unique.Handle[pkg.Checksum]]fstest.MapFS{ + wantChecksumH: fstest.MapFS(wantOutput), + _wantChecksumH: fstest.MapFS(_wantOutput), + }, + status: map[unique.Handle[pkg.ID]]string{ + wantIdent: "\x00", + kcIdent: "unreachable", + }, + }) + + cureMany(t, c, []cureStep{ + {"extern", a, base.Append( + "identifier", + pkg.Encode(wantIdent.Value()), + ), wantOutput, pkg.WExternal, nil}, + + {"flood shallow extern", fia, base.Append( + "identifier", + pkg.Encode(fiaIdent.Value()), + ), wantOutput, pkg.WExternal, nil}, + + {"substitute", _a, base.Append( + "identifier", + pkg.Encode(_wantIdent.Value()), + ), _wantOutput, pkg.WExternal, nil}, + + {"flood shallow local", flia, base.Append( + "identifier", + pkg.Encode(fliaIdent.Value()), + ), wantOutput, pkg.WExternal, nil}, + + {"flood shallow hanging", fhia, nil, nil, + pkg.WNew, pkg.HangingInputError(c.Ident(h))}, + + {"mismatch", kca, nil, nil, pkg.WExternal, &pkg.ChecksumMismatchError{ + Got: wantChecksum, + }}, + }) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU": {Mode: fs.ModeDir | 0500}, + "checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl": {Mode: fs.ModeDir | 0500}, + "checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl/result": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/3fYJjSiWhklt7TK9MPVLGk5_8AIU8ostTcFPXULJf_qeAVG6pmC-Cqqu4ZhEXRxn": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl")}, + "identifier/8VL-egZySw-RbnHZtxGWdf5Z2v9S9CihrdFwa54Bc5EYMQcEgWEem7QrdCTWlCb8": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl")}, + "identifier/8sInO_dhPW9kweit7jVuH_vtMkRxlZT7XE4_yEKPZmpGkgVxkmxz0OpXgbMsH6ET": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + "identifier/Xys8hPpJsKirZmTTuT7SwQ6661TCp_ZRu8iUjPPD7pC5E3ll7xw4zhNlZ2PVcz_-": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl")}, + "identifier/k8yyhlBV3JpYzzywAnrRWkfp2TmgIDNC6FqMFwrrR2x0C3unBLveDNKgigOjG9MM": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + "identifier/mXlNz_v77nW7jstn_rJn4RL7dCQlkakM0y-HjO6tkDubtzaj-wCmW1hIr2Gk64_O": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl")}, + + "status": {Mode: fs.ModeDir | 0700}, + "status/8VL-egZySw-RbnHZtxGWdf5Z2v9S9CihrdFwa54Bc5EYMQcEgWEem7QrdCTWlCb8": {Mode: 0400, Data: []byte("\x00")}, + + "substitute": {Mode: fs.ModeDir | 0700}, + "substitute/socGRnhdsJlJ4Z01ZwHFZdLzFH0rWvoaJrylcUKtvhBdGsE7VoYl-vdUuvfK8B4I": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU")}, + "substitute/MccYezQ4pshkeBV4w_aAEIwvZdKHL3OAgAkR3ouZ43EF94Ur1HBHaN7njPfJAj50": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl")}, + "substitute/VV5W0_DiA1iylaqbKFThp1ZpV_y1ccVS1XyqDnDaXoHQ-EEyPI4XFrQHcHOF2hWG": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/fHkl_RuHOoc4rso__nV-qreikovd6Yhrq5mpBlkf5hmPGaxDlik2bYOQ4dhUQjtl")}, + + "work": {Mode: fs.ModeDir | 0700}, + }}, + } + checkWithCache(t, testCases) +} + +func TestErrors(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + err error + want string + }{ + {"InvalidLookupError", pkg.InvalidLookupError{ + 0xff, 0xf0, + }, "attempting to look up non-input artifact __AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}, + + {"InvalidArtifactError", pkg.InvalidArtifactError{ + 0xff, 0xfd, + }, "artifact __0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA cannot be cured"}, + + {"ChecksumMismatchError", &pkg.ChecksumMismatchError{ + Want: (pkg.Checksum)(bytes.Repeat([]byte{ + 0x75, 0xe6, 0x9d, 0x6d, 0xe7, 0x9f, + }, 8)), + }, "got AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + " instead of deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}, + + {"ResponseStatusError", pkg.ResponseStatusError( + http.StatusNotAcceptable, + ), "the requested URL returned non-OK status: Not Acceptable"}, + + {"DisallowedTypeflagError", pkg.DisallowedTypeflagError( + tar.TypeChar, + ), "disallowed typeflag '3'"}, + + {"InvalidFileModeError", pkg.InvalidFileModeError( + fs.ModeSymlink | 0777, + ), "artifact did not produce a regular file or directory"}, + + {"NoOutputError", pkg.NoOutputError{ + // empty struct + }, "artifact cured successfully but did not produce any output"}, + + {"IRKindError", &pkg.IRKindError{ + Got: pkg.IRKindEnd, + Want: pkg.IRKindIdent, + Ancillary: 0xcafebabe, + }, "got terminator IR value (0xcafebabe) instead of ident"}, + {"IRKindError invalid", &pkg.IRKindError{ + Got: 0xbeef, + Want: pkg.IRKindIdent, + Ancillary: 0xcafe, + }, "got invalid kind 48879 IR value (0xcafe) instead of ident"}, + + {"UnsupportedVariantError", pkg.UnsupportedVariantError( + "rosa", + ), `unsupported variant "rosa"`}, + + {"UnsupportedArchError zero", pkg.UnsupportedArchError(""), + "invalid architecture name"}, + {"UnsupportedArchError", pkg.UnsupportedArchError("riscv64"), + "unsupported architecture riscv64"}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.err.Error(); got != tc.want { + t.Errorf("Error: %q, want %q", got, tc.want) + } + }) + } +} + +func TestScrubError(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + err pkg.ScrubError + want string + unwrap []error + }{ + {"sample", *newWantScrubError( + fhs.AbsVarLib.Append("cure"), + ), `checksum mismatches: +got vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX instead of _wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + +dangling identifiers: +_gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +_v4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +_v8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + +errors during scrub: + /var/lib/cure/checksum/__8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: + artifact did not produce a regular file or directory + /var/lib/cure/checksum/invalid: + illegal base64 data at input byte 4 + /var/lib/cure/identifier/_v8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: + readlink /var/lib/cure/identifier/_v8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: invalid argument + /var/lib/cure/identifier/invalid: + illegal base64 data at input byte 4 + /var/lib/cure/nonexistent: + illegal base64 data at input byte 8 +`, []error{ + &pkg.ChecksumMismatchError{Got: pkg.MustDecode( + "vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX", + ), Want: pkg.Checksum{0xff, 0}}, + + pkg.InvalidFileModeError(fs.ModeSymlink), + base64.CorruptInputError(4), + + &os.PathError{ + Op: "readlink", + Path: fhs.AbsVarLib.Append("cure").Append( + "identifier", + pkg.Encode(pkg.ID{0xfe, 0xff}), + ).String(), + Err: syscall.EINVAL, + }, + + base64.CorruptInputError(4), + base64.CorruptInputError(8), + }}, + + {"full", pkg.ScrubError{ + ChecksumMismatches: []pkg.ChecksumMismatchError{ + {Want: pkg.MustDecode("CH3AiUrCCcVOjOYLaMKKK1Da78989JtfHeIsxMzWOQFiN4mrCLDYpoDxLWqJWCUN")}, + }, + DanglingIdentifiers: []pkg.ID{ + (pkg.ID)(bytes.Repeat([]byte{0x75, 0xe6, 0x9d, 0x6d, 0xe7, 0x9f}, 8)), + (pkg.ID)(bytes.Repeat([]byte{0x71, 0xa7, 0xde, 0x6d, 0xa6, 0xde}, 8)), + }, + Errs: map[unique.Handle[string]][]error{ + unique.Make("/proc/nonexistent"): { + stub.UniqueError(0xcafe), + stub.UniqueError(0xbad), + stub.UniqueError(0xff), + }, + }, + }, `checksum mismatches: +got AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA instead of CH3AiUrCCcVOjOYLaMKKK1Da78989JtfHeIsxMzWOQFiN4mrCLDYpoDxLWqJWCUN + +dangling identifiers: +deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef +cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe + +errors during scrub: + /proc/nonexistent: + unique error 51966 injected by the test suite + unique error 2989 injected by the test suite + unique error 255 injected by the test suite +`, []error{ + &pkg.ChecksumMismatchError{Want: pkg.MustDecode("CH3AiUrCCcVOjOYLaMKKK1Da78989JtfHeIsxMzWOQFiN4mrCLDYpoDxLWqJWCUN")}, + stub.UniqueError(0xcafe), + stub.UniqueError(0xbad), + stub.UniqueError(0xff), + }}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.err.Error(); got != tc.want { + t.Errorf("Error:\n\n%s\n\nwant\n\n%s", got, tc.want) + } + + if unwrap := tc.err.Unwrap(); !reflect.DeepEqual(unwrap, tc.unwrap) { + t.Errorf("Unwrap: %#v, want %#v", unwrap, tc.unwrap) + } + }) + } +} + +func TestInputError(t *testing.T) { + t.Parallel() + + makeIdent := func(ident ...byte) pkg.Artifact { + var a overrideIdent + copy(a.id[:], ident) + // does not compare equal + a.TrivialArtifact = new(stubArtifact) + return a + } + + testCases := []struct { + name string + err pkg.InputError + want string + unwrap []error + }{ + {"simple", pkg.InputError{ + makeIdent(0xff, 9): stub.UniqueError(0xbad09), + makeIdent(0xff, 0): stub.UniqueError(0xbad00), + makeIdent(0xff, 0xf): stub.UniqueError(0xbad0f), + makeIdent(0xff, 1): stub.UniqueError(0xbad01), + }, `errors curing inputs: + _wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765184 injected by the test suite + _wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765185 injected by the test suite + _wkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765193 injected by the test suite + _w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765199 injected by the test suite`, []error{ + stub.UniqueError(0xbad00), + stub.UniqueError(0xbad01), + stub.UniqueError(0xbad09), + stub.UniqueError(0xbad0f), + }}, + + {"dedup", pkg.InputError{ + makeIdent(0xff, 9): stub.UniqueError(0xbad09), + makeIdent(0xff, 9): stub.UniqueError(0xbad09), + makeIdent(0xff, 9): stub.UniqueError(0xbad09), + makeIdent(0xff, 0): stub.UniqueError(0xbad00), + makeIdent(0xff, 0): stub.UniqueError(0xbad00), + makeIdent(0xff, 1): stub.UniqueError(0xbad01), + }, `errors curing inputs: + _wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765184 injected by the test suite + _wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765185 injected by the test suite + _wkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765193 injected by the test suite`, []error{ + stub.UniqueError(0xbad00), + stub.UniqueError(0xbad01), + stub.UniqueError(0xbad09), + }}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.err.Error(); got != tc.want { + t.Errorf("Error:\n%s\nwant\n%s", got, tc.want) + } + + if unwrap := tc.err.Unwrap(); !reflect.DeepEqual(unwrap, tc.unwrap) { + t.Errorf("Unwrap: %#v, want %#v", unwrap, tc.unwrap) + } + }) + } +} + +// earlyFailureF is a [FloodArtifact] with a large dependency graph resulting in +// a large [DependencyCureError]. +type earlyFailureF int + +func (earlyFailureF) Kind() pkg.Kind { return pkg.KindExec } +func (earlyFailureF) Params(*pkg.IContext) {} +func (earlyFailureF) IsExclusive() bool { return false } + +func (a earlyFailureF) Inputs() []pkg.Artifact { + deps := make([]pkg.Artifact, a) + for i := range deps { + deps[i] = a - 1 + } + return deps +} + +func (a earlyFailureF) Cure(*pkg.FContext) error { + if a != 0 { + panic("unexpected cure on " + strconv.Itoa(int(a))) + } + return stub.UniqueError(0xcafe) +} + +func BenchmarkEarlyDCE(b *testing.B) { + msg := message.New(log.New(os.Stderr, "dce: ", 0)) + msg.SwapVerbose(testing.Verbose()) + + for b.Loop() { + b.StopTimer() + c, err := pkg.Open(b.Context(), msg, check.MustAbs(b.TempDir()), nil) + if err != nil { + b.Fatal(err) + } + b.StartTimer() + _, _, err = c.Cure(earlyFailureF(8)) + b.StopTimer() + if !errors.Is(err, stub.UniqueError(0xcafe)) { + b.Fatalf("Cure: error = %v", err) + } + c.Close() + b.StartTimer() + } +} + +func TestDependencyCureErrorEarly(t *testing.T) { + t.Parallel() + + checkWithCache(t, []cacheTestCase{ + {"early", 0, nil, func(t *testing.T, _ *check.Absolute, c *pkg.Cache) { + _, _, err := c.Cure(earlyFailureF(8)) + if !errors.Is(err, stub.UniqueError(0xcafe)) { + t.Fatalf("Cure: error = %v", err) + } + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + "checksum": {Mode: fs.ModeDir | 0700}, + "identifier": {Mode: fs.ModeDir | 0700}, + "substitute": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + }) +} + +func TestOpen(t *testing.T) { + t.Parallel() + + t.Run("nonexistent", func(t *testing.T) { + t.Parallel() + + wantErr := &os.PathError{ + Op: "mkdir", + Path: container.Nonexistent, + Err: syscall.ENOENT, + } + if _, err := pkg.Open( + t.Context(), + message.New(nil), + check.MustAbs(container.Nonexistent), + nil, + ); !reflect.DeepEqual(err, wantErr) { + t.Errorf("Open: error = %#v, want %#v", err, wantErr) + } + }) + + t.Run("permission", func(t *testing.T) { + t.Parallel() + + tempDir := check.MustAbs(t.TempDir()) + if err := os.Chmod(tempDir.String(), 0); err != nil { + t.Fatal(err) + } else { + t.Cleanup(func() { + if err = os.Chmod(tempDir.String(), 0700); err != nil { + t.Fatal(err) + } + }) + } + + wantErr := &os.PathError{ + Op: "mkdir", + Path: tempDir.Append("cache").String(), + Err: syscall.EACCES, + } + if _, err := pkg.Open( + t.Context(), + message.New(nil), + tempDir.Append("cache"), + nil, + ); !reflect.DeepEqual(err, wantErr) { + t.Errorf("Open: error = %#v, want %#v", err, wantErr) + } + }) + + t.Run("dirty", func(t *testing.T) { + t.Parallel() + + tempDir := check.MustAbs(t.TempDir()) + if err := os.MkdirAll(tempDir.Append( + "cache", + "work", + "dirty", + ).String(), 0755); err != nil { + t.Fatal(err) + } + + wantErr := errors.New("work is not empty, scrub likely required") + if _, err := pkg.Open( + t.Context(), + message.New(nil), + tempDir.Append("cache"), + nil, + ); !reflect.DeepEqual(err, wantErr) { + t.Errorf("Open: error = %#v, want %#v", err, wantErr) + } + }) + + t.Run("scratch", func(t *testing.T) { + t.Parallel() + + tempDir := check.MustAbs(t.TempDir()) + if err := os.MkdirAll(tempDir.Append( + "cache", + "scratch", + ).String(), 0755); err != nil { + t.Fatal(err) + } + + wantErr := errors.New("scratch is present, scrub likely required") + if _, err := pkg.Open( + t.Context(), + message.New(nil), + tempDir.Append("cache"), nil, + ); !reflect.DeepEqual(err, wantErr) { + t.Errorf("Open: error = %#v, want %#v", err, wantErr) + } + }) +} + +func TestExtensionRegister(t *testing.T) { + extensionOld := extension + openedOld := opened + t.Cleanup(func() { extension = extensionOld; opened = openedOld }) + extension = "" + opened = false + + t.Run("set", func(t *testing.T) { + t.Cleanup(func() { extension = "" }) + + const want = "rosa" + pkg.SetExtension(want) + if got := pkg.Extension(); got != want { + t.Fatalf("Extension: %q, want %q", got, want) + } + }) + + t.Run("twice", func(t *testing.T) { + t.Cleanup(func() { extension = "" }) + + defer func() { + const wantPanic = "attempting to set extension twice" + if r := recover(); r != wantPanic { + t.Errorf("panic: %#v, want %q", r, wantPanic) + } + }() + pkg.SetExtension("rosa") + pkg.SetExtension("rosa") + }) + + t.Run("invalid", func(t *testing.T) { + defer func() { + var wantPanic = pkg.ErrInvalidExtension + if r := recover(); r != wantPanic { + t.Errorf("panic: %#v, want %#v", r, wantPanic) + } + }() + pkg.SetExtension(" ") + }) + + t.Run("opened", func(t *testing.T) { + t.Cleanup(func() { opened = false }) + + if _, err := pkg.Open( + t.Context(), + message.New(log.Default()), + check.MustAbs(container.Nonexistent), + nil, + ); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Open: error = %v", err) + } + + t.Run("variant", func(t *testing.T) { + defer func() { + const wantPanic = "attempting to set extension after open" + if r := recover(); r != wantPanic { + t.Errorf("panic: %#v, want %q", r, wantPanic) + } + }() + pkg.SetExtension("rosa") + }) + + t.Run("register", func(t *testing.T) { + defer func() { + const wantPanic = "attempting to register after open" + if r := recover(); r != wantPanic { + t.Errorf("panic: %#v, want %q", r, wantPanic) + } + }() + pkg.Register(pkg.KindCustomOffset, nil) + }) + }) + + t.Run("incomplete", func(t *testing.T) { + t.Cleanup(func() { delete(irArtifact, pkg.KindCustomOffset) }) + + defer func() { + const wantPanic = "attempting to open cache with incomplete variant setup" + if r := recover(); r != wantPanic { + t.Errorf("panic: %#v, want %q", r, wantPanic) + } + }() + pkg.Register(pkg.KindCustomOffset, nil) + + t.Cleanup(func() { opened = false }) + _, _ = pkg.Open(nil, nil, nil, nil) + panic("unreachable") + }) + + t.Run("create", func(t *testing.T) { + t.Cleanup(func() { extension = "" }) + const want = "rosa" + pkg.SetExtension(want) + + base := check.MustAbs(t.TempDir()) + t.Cleanup(func() { opened = false }) + if c, err := pkg.Open(t.Context(), nil, base, nil); err != nil { + t.Fatal(err) + } else { + c.Close() + } + + if got, err := os.ReadFile(base.Append("variant").String()); err != nil { + t.Fatal(err) + } else if string(got) != want { + t.Fatalf("variant: %q", string(got)) + } + }) + + t.Run("access", func(t *testing.T) { + base := check.MustAbs(t.TempDir()) + t.Cleanup(func() { opened = false }) + + if err := os.WriteFile(base.Append("variant").String(), nil, 0); err != nil { + t.Fatal(err) + } + + wantErr := &os.PathError{ + Op: "open", + Path: base.Append("variant").String(), + Err: syscall.EACCES, + } + if _, err := pkg.Open( + t.Context(), nil, + base, nil, + ); !reflect.DeepEqual(err, wantErr) { + t.Fatalf("Open: error = %v, want %v", err, wantErr) + } + }) + + t.Run("promote", func(t *testing.T) { + t.Cleanup(func() { extension = "" }) + const want = "rosa" + pkg.SetExtension(want) + + base := check.MustAbs(t.TempDir()) + t.Cleanup(func() { opened = false }) + + variantPath := base.Append("variant") + if err := os.WriteFile(variantPath.String(), nil, 0600); err != nil { + t.Fatal(err) + } + + if _, err := pkg.Open( + t.Context(), nil, + base, + nil, + ); !reflect.DeepEqual(err, pkg.ErrWouldPromote) { + t.Fatalf("Open: error = %v", err) + } + + if p, err := os.ReadFile(variantPath.String()); err != nil { + t.Fatal(err) + } else if len(p) != 0 { + t.Fatalf("variant: %q", string(p)) + } + + if c, err := pkg.Open( + t.Context(), nil, + base, &pkg.CacheAttr{Flags: pkg.CPromoteVariant}, + ); err != nil { + t.Fatalf("Open: error = %v", err) + } else { + c.Close() + } + + if p, err := os.ReadFile(variantPath.String()); err != nil { + t.Fatal(err) + } else if string(p) != want { + t.Fatalf("variant: %q, want %q", string(p), want) + } + }) + + t.Run("open invalid", func(t *testing.T) { + base := check.MustAbs(t.TempDir()) + t.Cleanup(func() { opened = false }) + + variantPath := base.Append("variant") + if err := os.WriteFile(variantPath.String(), make([]byte, 129), 0400); err != nil { + t.Fatal(err) + } + + if _, err := pkg.Open( + t.Context(), nil, + base, nil, + ); !reflect.DeepEqual(err, pkg.ErrInvalidExtension) { + t.Fatalf("Open: error = %v", err) + } + }) + + t.Run("unsupported", func(t *testing.T) { + base := check.MustAbs(t.TempDir()) + t.Cleanup(func() { opened = false }) + + variantPath := base.Append("variant") + if err := os.WriteFile(variantPath.String(), []byte("rosa"), 0400); err != nil { + t.Fatal(err) + } + + if _, err := pkg.Open( + t.Context(), nil, + base, nil, + ); !reflect.DeepEqual(err, pkg.UnsupportedVariantError("rosa")) { + t.Fatalf("Open: error = %v", err) + } + }) +} diff --git a/pkg/tar.go b/pkg/tar.go new file mode 100644 index 00000000..9a8c4b76 --- /dev/null +++ b/pkg/tar.go @@ -0,0 +1,226 @@ +package pkg + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +// A tarArtifact is an [Artifact] unpacking a tarball backed by a [FileArtifact]. +type tarArtifact struct { + // Caller-supplied backing tarball. + f Artifact +} + +var _ CuresExempt = new(tarArtifact) + +// tarArtifactNamed embeds tarArtifact for a [fmt.Stringer] tarball. +type tarArtifactNamed struct { + tarArtifact + // Copied from tarArtifact.f. + name string +} + +var _ fmt.Stringer = new(tarArtifactNamed) + +// String returns the name of the underlying [Artifact] prefixed with unpack. +func (a *tarArtifactNamed) String() string { return "unpack-" + a.name } + +// NewTar returns a new [Artifact] unpacking the tar stream produced by the +// backing [Artifact]. The source [Artifact] must be a [FileArtifact]. +func NewTar(a Artifact) Artifact { + ta := tarArtifact{a} + if s, ok := a.(fmt.Stringer); ok { + if name := s.String(); name != "" { + return &tarArtifactNamed{ta, name} + } + } + return &ta +} + +// Kind returns the hardcoded [Kind] constant. +func (a *tarArtifact) Kind() Kind { return KindTar } + +// Params is a noop. +func (a *tarArtifact) Params(*IContext) {} + +func init() { + register(KindTar, func(r *IRReader) Artifact { + a := NewTar(r.Next()) + if _, ok := r.Finalise(); ok { + panic(ErrUnexpectedChecksum) + } + return a + }) +} + +// Inputs returns a slice containing the backing file. +func (a *tarArtifact) Inputs() []Artifact { + return []Artifact{a.f} +} + +// IsExclusive returns false: decompressor and tar reader are fully sequential. +func (a *tarArtifact) IsExclusive() bool { return false } + +// A DisallowedTypeflagError describes a disallowed typeflag encountered while +// unpacking a tarball. +type DisallowedTypeflagError byte + +func (e DisallowedTypeflagError) Error() string { + return "disallowed typeflag '" + string(e) + "'" +} + +// Cure cures the [Artifact], producing a directory located at work. +func (a *tarArtifact) Cure(t *TContext) (err error) { + var r io.ReadCloser + if r, err = t.Open(a.f); err != nil { + return + } + + defer func() { + closeErr := r.Close() + if err == nil { + err = closeErr + } + }() + + type dirTargetPerm struct { + path string + mode fs.FileMode + } + var madeDirectories []dirTargetPerm + + if err = os.MkdirAll(t.GetTempDir().String(), 0700); err != nil { + return + } + var root *os.Root + if root, err = os.OpenRoot(t.GetTempDir().String()); err != nil { + return + } + defer func() { + closeErr := root.Close() + if err == nil { + err = closeErr + } + }() + + var header *tar.Header + tr := tar.NewReader(r) + for header, err = tr.Next(); err == nil; header, err = tr.Next() { + typeflag := header.Typeflag + if typeflag == 0 { + if len(header.Name) > 0 && header.Name[len(header.Name)-1] == '/' { + typeflag = tar.TypeDir + } else { + typeflag = tar.TypeReg + } + } + + if typeflag >= '0' && typeflag <= '9' && typeflag != tar.TypeDir { + if err = root.MkdirAll(filepath.Dir(header.Name), 0700); err != nil { + return + } + } + + switch typeflag { + case tar.TypeReg: + var f *os.File + if f, err = root.OpenFile( + header.Name, + os.O_CREATE|os.O_EXCL|os.O_WRONLY, + header.FileInfo().Mode()&0500, + ); err != nil { + return + } + if _, err = io.Copy(f, tr); err != nil { + _ = f.Close() + return + } else if err = f.Close(); err != nil { + return + } + break + + case tar.TypeLink: + if err = root.Link( + header.Linkname, + header.Name, + ); err != nil { + return + } + break + + case tar.TypeSymlink: + if err = root.Symlink( + header.Linkname, + header.Name, + ); err != nil { + return + } + break + + case tar.TypeDir: + madeDirectories = append(madeDirectories, dirTargetPerm{ + path: header.Name, + mode: header.FileInfo().Mode(), + }) + if err = root.MkdirAll(header.Name, 0700); err != nil { + return + } + break + + case tar.TypeChar: + t.GetMessage().Verbosef( + "%sskipping character device %d, %d %q%s", + t.cache.sgrWarn, header.Devmajor, header.Devminor, header.Name, t.cache.sgrRes, + ) + continue + + case tar.TypeXGlobalHeader: + continue // ignore + + default: + return DisallowedTypeflagError(typeflag) + } + } + if errors.Is(err, io.EOF) { + err = nil + } + if err == nil { + for _, e := range madeDirectories { + if err = root.Chmod(e.path, e.mode&0500); err != nil { + return + } + } + } else { + return + } + + temp := t.GetTempDir() + if err = os.Chmod(temp.String(), 0700); err != nil { + return + } + + var entries []os.DirEntry + if entries, err = os.ReadDir(temp.String()); err != nil { + return + } + + if len(entries) == 1 && entries[0].IsDir() { + p := temp.Append(entries[0].Name()) + if err = os.Chmod(p.String(), 0700); err != nil { + return + } + err = os.Rename(p.String(), t.GetWorkDir().String()) + } else { + err = os.Rename(temp.String(), t.GetWorkDir().String()) + } + return +} + +// CuresExempt exempts the cheap [KindTar] implementation often at the end of a +// [FileArtifact] pipeline. +func (*tarArtifact) CuresExempt() {} diff --git a/pkg/tar_test.go b/pkg/tar_test.go new file mode 100644 index 00000000..5462714f --- /dev/null +++ b/pkg/tar_test.go @@ -0,0 +1,225 @@ +package pkg_test + +import ( + "archive/tar" + "bytes" + "crypto/sha512" + "errors" + "io/fs" + "net/http" + "os" + "testing" + "testing/fstest" + + "hakurei.app/check" + "hakurei.app/internal/stub" + "hakurei.app/pkg" +) + +func TestTar(t *testing.T) { + t.Parallel() + + want := expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + + "checksum": {Mode: fs.ModeDir | 0500}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP": {Mode: fs.ModeDir | 0500}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/check": {Mode: 0400, Data: []byte{0, 0}}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/lib": {Mode: fs.ModeDir | 0500}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/lib/pkgconfig": {Mode: fs.ModeDir | 0500}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/lib/libedac.so": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent/libedac.so")}, + + "identifier": {Mode: fs.ModeDir | 0500}, + "identifier/HnySzeLQvSBZuTUcvfmLEX_OmH4yJWWH788NxuLuv7kVn8_uPM6Ks4rqFWM2NZJY": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP")}, + "identifier/Zx5ZG9BAwegNT3zQwCySuI2ktCXxNgxirkGLFjW4FW06PtojYVaCdtEw8yuntPLa": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP")}, + + "work": {Mode: fs.ModeDir | 0500}, + } + wantEncode := pkg.Encode(want.hash()) + + wantExpand := expectsFS{ + ".": {Mode: fs.ModeDir | 0500}, + + "libedac.so": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent/libedac.so")}, + } + wantExpandEncode := pkg.Encode(wantExpand.hash()) + + checkWithCache(t, []cacheTestCase{ + {"http", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + checkTarHTTP(t, base, c, fstest.MapFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP": {Mode: fs.ModeDir | 0700}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/check": {Mode: 0400, Data: []byte{0, 0}}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/lib": {Mode: fs.ModeDir | 0700}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/lib/pkgconfig": {Mode: fs.ModeDir | 0700}, + "checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/lib/libedac.so": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent/libedac.so")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/HnySzeLQvSBZuTUcvfmLEX_OmH4yJWWH788NxuLuv7kVn8_uPM6Ks4rqFWM2NZJY": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP")}, + "identifier/Zx5ZG9BAwegNT3zQwCySuI2ktCXxNgxirkGLFjW4FW06PtojYVaCdtEw8yuntPLa": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP")}, + + "work": {Mode: fs.ModeDir | 0700}, + }, want) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/" + wantEncode: {Mode: fs.ModeDir | 0500}, + "checksum/" + wantEncode + "/checksum": {Mode: fs.ModeDir | 0500}, + "checksum/" + wantEncode + "/checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP": {Mode: fs.ModeDir | 0500}, + "checksum/" + wantEncode + "/checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/check": {Mode: 0400, Data: []byte{0, 0}}, + "checksum/" + wantEncode + "/checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/lib": {Mode: fs.ModeDir | 0500}, + "checksum/" + wantEncode + "/checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/lib/libedac.so": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent/libedac.so")}, + "checksum/" + wantEncode + "/checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP/lib/pkgconfig": {Mode: fs.ModeDir | 0500}, + "checksum/" + wantEncode + "/identifier": {Mode: fs.ModeDir | 0500}, + "checksum/" + wantEncode + "/identifier/HnySzeLQvSBZuTUcvfmLEX_OmH4yJWWH788NxuLuv7kVn8_uPM6Ks4rqFWM2NZJY": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP")}, + "checksum/" + wantEncode + "/identifier/Zx5ZG9BAwegNT3zQwCySuI2ktCXxNgxirkGLFjW4FW06PtojYVaCdtEw8yuntPLa": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/1TL00Qb8dcqayX7wTO8WNaraHvY6b-KCsctLDTrb64QBCmxj_-byK1HdIUwMaFEP")}, + "checksum/" + wantEncode + "/work": {Mode: fs.ModeDir | 0500}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/snWp53xxNdx6cV5KSJqzyVWzLlTsFd8udOisIu8Rgxly09mGcxufYCoQ5CQrHDyZ": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantEncode)}, + "identifier/v9DUj2R4YK_3ae0m9VuUYI-HJysDDCENu1k10thxioycRzFH1Qejw5bBGukm4IdL": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantEncode)}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "temp": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + + {"http expand", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) { + checkTarHTTP(t, base, c, fstest.MapFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "lib": {Mode: fs.ModeDir | 0700}, + "lib/libedac.so": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent/libedac.so")}, + }, wantExpand) + }, expectsFS{ + ".": {Mode: fs.ModeDir | 0700}, + + "checksum": {Mode: fs.ModeDir | 0700}, + "checksum/" + wantExpandEncode: {Mode: fs.ModeDir | 0500}, + "checksum/" + wantExpandEncode + "/libedac.so": {Mode: fs.ModeSymlink | 0777, Data: []byte("/proc/nonexistent/libedac.so")}, + + "identifier": {Mode: fs.ModeDir | 0700}, + "identifier/1SuvgGltcsgqps9tWqjULG8RCdpx_6KVWXdsMd5xPKvdtysOxqpE-3bLu4j0WpGk": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantExpandEncode)}, + "identifier/snWp53xxNdx6cV5KSJqzyVWzLlTsFd8udOisIu8Rgxly09mGcxufYCoQ5CQrHDyZ": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantExpandEncode)}, + + "substitute": {Mode: fs.ModeDir | 0700}, + + "temp": {Mode: fs.ModeDir | 0700}, + "work": {Mode: fs.ModeDir | 0700}, + }}, + }) +} + +func checkTarHTTP( + t *testing.T, + base *check.Absolute, + c *pkg.Cache, + testdataFsys fs.FS, + want expectsKnown, +) { + var testdata string + { + var buf bytes.Buffer + w := tar.NewWriter(&buf) + if err := w.AddFS(testdataFsys); err != nil { + t.Fatalf("AddFS: error = %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close: error = %v", err) + } + testdata = buf.String() + } + + testdataChecksum := func() pkg.Checksum { + h := sha512.New384() + h.Write([]byte(testdata)) + return (pkg.Checksum)(h.Sum(nil)) + }() + + var transport http.Transport + client := http.Client{Transport: &transport} + transport.RegisterProtocol("file", http.NewFileTransportFS(fstest.MapFS{ + "testdata": {Data: []byte(testdata), Mode: 0400}, + })) + + tarDir := stubArtifact{ + kind: pkg.KindExec, + params: []byte("directory containing a single regular file"), + cure: func(t *pkg.TContext) error { + work := t.GetWorkDir() + if err := os.MkdirAll(work.String(), 0700); err != nil { + return err + } + return os.WriteFile( + work.Append("sample.tar.gz").String(), + []byte(testdata), + 0400, + ) + }, + } + tarDirMulti := stubArtifact{ + kind: pkg.KindExec, + params: []byte("directory containing a multiple entries"), + cure: func(t *pkg.TContext) error { + work := t.GetWorkDir() + if err := os.MkdirAll(work.Append( + "garbage", + ).String(), 0700); err != nil { + return err + } + return os.WriteFile( + work.Append("sample.tar.gz").String(), + []byte(testdata), + 0400, + ) + }, + } + tarDirType := stubArtifact{ + kind: pkg.KindExec, + params: []byte("directory containing a symbolic link"), + cure: func(t *pkg.TContext) error { + work := t.GetWorkDir() + if err := os.MkdirAll(work.String(), 0700); err != nil { + return err + } + return os.Symlink( + work.String(), + work.Append("sample.tar.gz").String(), + ) + }, + } + // destroy these to avoid including it in flatten test case + defer newDestroyArtifactFunc(&tarDir)(t, base, c) + defer newDestroyArtifactFunc(&tarDirMulti)(t, base, c) + defer newDestroyArtifactFunc(&tarDirType)(t, base, c) + + cureMany(t, c, []cureStep{ + {"file", pkg.NewTar(pkg.NewHTTPGet( + &client, + "file:///testdata", + testdataChecksum, + )), ignorePathname, want, pkg.WNew, nil}, + + {"directory", pkg.NewTar(&tarDir), ignorePathname, want, pkg.WNew, nil}, + + {"multiple entries", pkg.NewTar(&tarDirMulti), nil, nil, pkg.WNew, errors.New( + "input directory does not contain a single regular file", + )}, + + {"bad type", pkg.NewTar(&tarDirType), nil, nil, pkg.WNew, errors.New( + "input directory does not contain a single regular file", + )}, + + {"error passthrough", pkg.NewTar(&stubArtifact{ + kind: pkg.KindExec, + params: []byte("doomed artifact"), + cure: func(t *pkg.TContext) error { + return stub.UniqueError(0xcafe) + }, + }), nil, nil, pkg.WNew, stub.UniqueError(0xcafe)}, + }) +} |
