diff --git a/internal/pkg/ir.go b/internal/pkg/ir.go index 41e75845..c93abc9e 100644 --- a/internal/pkg/ir.go +++ b/internal/pkg/ir.go @@ -432,6 +432,12 @@ func (e InvalidKindError) Error() string { // 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") } diff --git a/internal/pkg/pkg.go b/internal/pkg/pkg.go index e94cf9c4..767c35a7 100644 --- a/internal/pkg/pkg.go +++ b/internal/pkg/pkg.go @@ -18,6 +18,7 @@ import ( "path/filepath" "runtime" "slices" + "strconv" "strings" "sync" "sync/atomic" @@ -70,6 +71,64 @@ func MustDecode(s string) (checksum Checksum) { 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 @@ -102,19 +161,27 @@ type TContext struct { common } -// statusHeader is the header written to all status files in dirStatus. -var statusHeader = func() string { +// 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() error { @@ -427,6 +494,9 @@ const ( // KindFile is the kind of [Artifact] returned by [NewFile]. KindFile + // _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 @@ -441,6 +511,9 @@ const ( // fileLock is the file name appended to Cache.base for guaranteeing // exclusive access to the cache directory. fileLock = "lock" + // fileVariant is the file name appended to Cache.base holding the variant + // identification string set by a prior call to [SetExtension]. + fileVariant = "variant" // dirIdentifier is the directory name appended to Cache.base for storing // artifacts named after their [ID]. @@ -540,6 +613,10 @@ const ( // 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 ) // toplevel holds [context.WithCancel] over caller-supplied context, where all @@ -1930,6 +2007,20 @@ func (c *Cache) Close() { c.unlock() } +// 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") +) + // Open returns the address of a newly opened instance of [Cache]. // // Concurrent cures of a [FloodArtifact] dependency graph is limited to the @@ -1961,6 +2052,14 @@ func open( base *check.Absolute, lock bool, ) (*Cache, error) { + openMu.Lock() + defer openMu.Unlock() + opened = true + + if extension == "" && len(irArtifact) != int(_kindEnd) { + panic("attempting to open cache with incomplete variant setup") + } + if cures < 1 { cures = runtime.NumCPU() } @@ -1974,8 +2073,10 @@ func open( dirStatus, dirWork, } { - if err := os.MkdirAll(base.Append(name).String(), 0700); err != nil && - !errors.Is(err, os.ErrExist) { + if err := os.MkdirAll( + base.Append(name).String(), + 0700, + ); err != nil && !errors.Is(err, os.ErrExist) { return nil, err } } @@ -2013,6 +2114,45 @@ func open( c.unlock = func() {} } + 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 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) + } + return &c, nil } diff --git a/internal/pkg/pkg_test.go b/internal/pkg/pkg_test.go index f3011902..f0145e47 100644 --- a/internal/pkg/pkg_test.go +++ b/internal/pkg/pkg_test.go @@ -41,6 +41,25 @@ func unsafeOpen( lock bool, ) (*pkg.Cache, error) +var ( + // extension is a string uniquely identifying a set of custom [Artifact] + // implementations registered by calling [Register]. + // + //go:linkname extension hakurei.app/internal/pkg.extension + extension string + + // opened is false if [Open] was never called. + // + //go:linkname opened hakurei.app/internal/pkg.opened + opened bool + + // irArtifact refers to artifact IR interpretation functions and must not be + // written to directly. + // + //go:linkname irArtifact hakurei.app/internal/pkg.irArtifact + irArtifact map[pkg.Kind]pkg.IRReadFunc +) + // 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 { @@ -342,9 +361,20 @@ func checkWithCache(t *testing.T, testCases []cacheTestCase) { restoreTemp = true } - // destroy lock file to avoid changing cache checksums - if err := os.Remove(base.Append("lock").String()); err != nil { - t.Fatal(err) + // 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 status files @@ -1101,6 +1131,10 @@ func TestErrors(t *testing.T) { Want: pkg.IRKindIdent, Ancillary: 0xcafe, }, "got invalid kind 48879 IR value (0xcafe) instead of ident"}, + + {"UnsupportedVariantError", pkg.UnsupportedVariantError( + "rosa", + ), `unsupported variant "rosa"`}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -1309,6 +1343,8 @@ func (a earlyFailureF) Cure(*pkg.FContext) error { } 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)) @@ -1319,7 +1355,7 @@ func TestDependencyCureErrorEarly(t *testing.T) { }) } -func TestNew(t *testing.T) { +func TestOpen(t *testing.T) { t.Parallel() t.Run("nonexistent", func(t *testing.T) { @@ -1367,3 +1403,219 @@ func TestNew(t *testing.T) { } }) } + +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()), + 0, 0, 0, + check.MustAbs(container.Nonexistent), + ); !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, 0, 0, 0, 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, + 0, 0, 0, + base, + ); 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, + 0, 0, 0, + base, + ); !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, + 0, 0, 0, + base, + ); !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, + pkg.CPromoteVariant, 0, 0, + base, + ); 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, + 0, 0, 0, + base, + ); !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, + 0, 0, 0, + base, + ); !reflect.DeepEqual(err, pkg.UnsupportedVariantError("rosa")) { + t.Fatalf("Open: error = %v", err) + } + }) +} diff --git a/internal/rosa/rosa.go b/internal/rosa/rosa.go index 5179f7a3..d2e8b619 100644 --- a/internal/rosa/rosa.go +++ b/internal/rosa/rosa.go @@ -14,6 +14,12 @@ import ( "hakurei.app/internal/pkg" ) +// Extension is the variant identification string of custom artifact +// implementations registered by package rosa. +const Extension = "rosa" + +func init() { pkg.SetExtension(Extension) } + const ( // kindEtc is the kind of [pkg.Artifact] of cureEtc. kindEtc = iota + pkg.KindCustomOffset