diff options
| author | Ophestra <cat@gensokyo.uk> | 2026-08-29 18:58:06 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2026-08-29 18:58:06 +0900 |
| commit | c2d900ec74e03e9c782cfe7b7ce06ff62cda6601 (patch) | |
| tree | fb13320bfdc62d810aa59769e8809dc2f707919e /pkg/pkg.go | |
| parent | bd4f29909e0750a4660eaf48e3169777a265b0ab (diff) | |
Closes #43.
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'pkg/pkg.go')
| -rw-r--r-- | pkg/pkg.go | 2952 |
1 files changed, 2952 insertions, 0 deletions
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 } |
