23 Commits

Author SHA1 Message Date
46c5ce4936 internal/outcome/shim: check full behaviour
All checks were successful
Test / Create distribution (push) Successful in 24s
Test / Hakurei (push) Successful in 42s
Test / Sandbox (push) Successful in 38s
Test / Hakurei (race detector) (push) Successful in 42s
Test / Sandbox (race detector) (push) Successful in 38s
Test / Hpkg (push) Successful in 39s
Test / Flake checks (push) Successful in 1m21s
This took significant effort to stub out, and achieves full coverage after c5aefe5e9d.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-30 05:20:49 +09:00
36f8064905 internal/outcome/process: output via msg
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m13s
Test / Hakurei (push) Successful in 3m9s
Test / Hpkg (push) Successful in 3m57s
Test / Sandbox (race detector) (push) Successful in 4m8s
Test / Hakurei (race detector) (push) Successful in 4m54s
Test / Flake checks (push) Successful in 1m27s
This makes it possible to instrument output behaviour through stub.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-30 03:41:38 +09:00
eeb9f98e5b internal/outcome/shim: move signal constants
All checks were successful
Test / Create distribution (push) Successful in 36s
Test / Sandbox (push) Successful in 2m12s
Test / Hakurei (push) Successful in 3m17s
Test / Hpkg (push) Successful in 4m11s
Test / Sandbox (race detector) (push) Successful in 4m16s
Test / Hakurei (race detector) (push) Successful in 5m1s
Test / Flake checks (push) Successful in 1m30s
The magic numbers hurt readability.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-30 01:20:51 +09:00
3f9f331501 internal/outcome/shim: remove noop resume
All checks were successful
Test / Sandbox (race detector) (push) Successful in 4m7s
Test / Hakurei (race detector) (push) Successful in 4m54s
Test / Flake checks (push) Successful in 1m27s
Test / Create distribution (push) Successful in 32s
Test / Sandbox (push) Successful in 2m12s
Test / Hakurei (push) Successful in 3m11s
Test / Hpkg (push) Successful in 3m59s
The shim does not suspend output to begin with. These are leftovers from when container startup code suspends output.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-29 23:31:39 +09:00
2563391086 internal/outcome/shim: params check early
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 2m16s
Test / Hakurei (push) Successful in 3m18s
Test / Hpkg (push) Successful in 4m0s
Test / Hakurei (race detector) (push) Successful in 4m56s
Test / Sandbox (race detector) (push) Successful in 4m11s
Test / Flake checks (push) Successful in 1m29s
This is unreachable, but keeping it here as a failsafe until more test cases are added.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-29 23:10:12 +09:00
a0b4e47acc internal/outcome: rename from app
All checks were successful
Test / Sandbox (race detector) (push) Successful in 4m7s
Test / Hakurei (race detector) (push) Successful in 4m55s
Test / Flake checks (push) Successful in 1m27s
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m11s
Test / Hakurei (push) Successful in 3m9s
Test / Hpkg (push) Successful in 4m1s
This is less ambiguous, and more accurately describes the purpose of the package.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-29 04:33:13 +09:00
a52f7038e5 internal/env: relocate from app
All checks were successful
Test / Create distribution (push) Successful in 32s
Test / Sandbox (push) Successful in 2m8s
Test / Hakurei (push) Successful in 3m10s
Test / Hpkg (push) Successful in 4m1s
Test / Sandbox (race detector) (push) Successful in 4m7s
Test / Hakurei (race detector) (push) Successful in 4m53s
Test / Flake checks (push) Successful in 1m27s
This package is much cleaner to stub independently, and makes no sense to lump into app.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-29 04:11:49 +09:00
274686d10d internal/validate: relocate from app
All checks were successful
Test / Create distribution (push) Successful in 37s
Test / Sandbox (push) Successful in 2m23s
Test / Hakurei (push) Successful in 3m9s
Test / Hpkg (push) Successful in 4m7s
Test / Sandbox (race detector) (push) Successful in 4m11s
Test / Hakurei (race detector) (push) Successful in 5m1s
Test / Flake checks (push) Successful in 1m30s
These are free of the dispatcher from internal/app. This change relocates them into their own package.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-29 03:40:09 +09:00
65342d588f internal/app/state: improve store internals
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m15s
Test / Hakurei (push) Successful in 3m8s
Test / Hpkg (push) Successful in 4m1s
Test / Sandbox (race detector) (push) Successful in 4m6s
Test / Hakurei (race detector) (push) Successful in 4m50s
Test / Flake checks (push) Successful in 1m27s
This fully exposes the store internals for #19 and are final preparations for removing the legacy store interface.

This change also fixes a potential deadlock in the handle initialisation mkdir failure path. This however is never reachable in hakurei as the store is never accessed concurrently.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-29 03:21:00 +09:00
5e5826459e internal/app/state: improve handles internals
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m8s
Test / Hakurei (push) Successful in 3m10s
Test / Hpkg (push) Successful in 3m56s
Test / Sandbox (race detector) (push) Successful in 4m7s
Test / Hakurei (race detector) (push) Successful in 4m53s
Test / Flake checks (push) Successful in 1m31s
This replaces the Store interface with something better reflecting the underlying data format for #19. An implementation of Store is provided on top of the new code to ease transition.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-28 22:00:54 +09:00
4a463b7f03 internal/app/state: use absolute pathnames
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m9s
Test / Hakurei (push) Successful in 3m9s
Test / Hpkg (push) Successful in 3m56s
Test / Sandbox (race detector) (push) Successful in 4m6s
Test / Hakurei (race detector) (push) Successful in 4m48s
Test / Flake checks (push) Successful in 1m26s
This is less error-prone and fits better into internal/app which already uses check.Absolute for all pathnames.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-26 03:41:19 +09:00
dacd9550e0 internal/app/state: acquire big lock for toplevel operations
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m20s
Test / Hakurei (push) Successful in 3m5s
Test / Hpkg (push) Successful in 3m54s
Test / Sandbox (race detector) (push) Successful in 4m3s
Test / Hakurei (race detector) (push) Successful in 4m50s
Test / Flake checks (push) Successful in 1m24s
This avoids getting into an inconsistent state for simultaneous calls to List and Do on a previously unknown identity.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-26 03:27:56 +09:00
546b00429f treewide: update doc comments
All checks were successful
Test / Sandbox (race detector) (push) Successful in 4m7s
Test / Flake checks (push) Successful in 1m29s
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m22s
Test / Hakurei (push) Successful in 3m10s
Test / Hpkg (push) Successful in 3m58s
Test / Hakurei (race detector) (push) Successful in 4m57s
Some internal/app/state types were relocated to hst as part of the API. This change updates doc comments referring to them.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-26 03:00:04 +09:00
86f4219062 internal/app/state/data: check full entry behaviour
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 2m19s
Test / Hakurei (push) Successful in 3m5s
Test / Hpkg (push) Successful in 4m9s
Test / Sandbox (race detector) (push) Successful in 4m13s
Test / Hakurei (race detector) (push) Successful in 4m55s
Test / Flake checks (push) Successful in 1m29s
This eventually gets relocated to internal/app.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-26 01:49:14 +09:00
fe2929d5f7 internal/app/state: include et header
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 2m17s
Test / Hakurei (push) Successful in 3m5s
Test / Hpkg (push) Successful in 3m55s
Test / Sandbox (race detector) (push) Successful in 4m2s
Test / Hakurei (race detector) (push) Successful in 4m49s
Test / Flake checks (push) Successful in 1m22s
This is the initial step of implementing #19.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-25 22:01:26 +09:00
470e545d27 internal/app/state: use internal/lockedfile
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m15s
Test / Hakurei (push) Successful in 3m11s
Test / Hpkg (push) Successful in 4m0s
Test / Sandbox (race detector) (push) Successful in 4m4s
Test / Hakurei (race detector) (push) Successful in 4m52s
Test / Flake checks (push) Successful in 1m30s
This is a pretty solid implementation backed by robust tests, with a much cleaner interface.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-25 21:29:24 +09:00
8d3381821f internal/app/state: export correct backend value
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m17s
Test / Hakurei (push) Successful in 3m7s
Test / Sandbox (race detector) (push) Successful in 3m52s
Test / Hpkg (push) Successful in 3m59s
Test / Hakurei (race detector) (push) Successful in 4m46s
Test / Flake checks (push) Successful in 1m25s
This references the underlying multiBackend due to a typo, making the whole dance with c a noop.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-25 21:11:05 +09:00
e9d00b9071 container/executable: handle nil msg
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m10s
Test / Hakurei (push) Successful in 3m4s
Test / Sandbox (race detector) (push) Successful in 3m59s
Test / Hpkg (push) Successful in 4m2s
Test / Hakurei (race detector) (push) Successful in 4m45s
Test / Flake checks (push) Successful in 1m37s
This is useful in some tests.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-25 21:08:54 +09:00
4f41afee0f internal/app/state: fixed size et-only header
All checks were successful
Test / Create distribution (push) Successful in 46s
Test / Sandbox (push) Successful in 2m29s
Test / Hakurei (push) Successful in 3m26s
Test / Sandbox (race detector) (push) Successful in 4m15s
Test / Hpkg (push) Successful in 4m14s
Test / Hakurei (race detector) (push) Successful in 5m3s
Test / Flake checks (push) Successful in 1m21s
This header improves the robustness of the format and significantly reduces cleanup overhead.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-25 19:15:06 +09:00
7de593e816 cmd/hakurei: short identifier from lower half
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 39s
Test / Sandbox (race detector) (push) Successful in 40s
Test / Hakurei (push) Successful in 2m14s
Test / Hakurei (race detector) (push) Successful in 2m57s
Test / Hpkg (push) Successful in 3m12s
Test / Flake checks (push) Successful in 1m25s
The upper half is now a nanosecond timestamp. Lower half is still random bytes, so use lower half for short identifier.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-24 00:47:39 +09:00
2442eda8d9 hst/instance: embed config struct
All checks were successful
Test / Create distribution (push) Successful in 34s
Test / Sandbox (push) Successful in 41s
Test / Sandbox (race detector) (push) Successful in 40s
Test / Hakurei (push) Successful in 2m20s
Test / Hakurei (race detector) (push) Successful in 2m59s
Test / Hpkg (push) Successful in 3m20s
Test / Flake checks (push) Successful in 1m28s
This makes the resulting json easier to parse since it can now be deserialised into the config struct.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-24 00:42:16 +09:00
05488bfb8f hst/instance: store priv side pid
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / Sandbox (push) Successful in 2m14s
Test / Hakurei (push) Successful in 3m8s
Test / Sandbox (race detector) (push) Successful in 3m58s
Test / Hpkg (push) Successful in 4m1s
Test / Hakurei (race detector) (push) Successful in 4m44s
Test / Flake checks (push) Successful in 1m29s
This can receive signals, so is more useful to the caller.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-23 23:19:55 +09:00
dd94818f20 hst/instance: define instance state
All checks were successful
Test / Create distribution (push) Successful in 34s
Test / Sandbox (push) Successful in 2m13s
Test / Hakurei (push) Successful in 3m6s
Test / Hpkg (push) Successful in 4m2s
Test / Sandbox (race detector) (push) Successful in 4m5s
Test / Hakurei (race detector) (push) Successful in 4m51s
Test / Flake checks (push) Successful in 1m30s
This is now part of the hst API. This change also improves identifier generation and serialisation.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-10-23 22:59:02 +09:00
85 changed files with 4646 additions and 1488 deletions

View File

@@ -18,8 +18,9 @@ import (
"hakurei.app/container/fhs"
"hakurei.app/hst"
"hakurei.app/internal"
"hakurei.app/internal/app"
"hakurei.app/internal/app/state"
"hakurei.app/internal/env"
"hakurei.app/internal/outcome"
"hakurei.app/internal/state"
"hakurei.app/message"
"hakurei.app/system/dbus"
)
@@ -50,7 +51,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
Flag(&flagVerbose, "v", command.BoolFlag(false), "Increase log verbosity").
Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output in JSON when applicable")
c.Command("shim", command.UsageInternal, func([]string) error { app.Shim(msg); return errSuccess })
c.Command("shim", command.UsageInternal, func([]string) error { outcome.Shim(msg); return errSuccess })
c.Command("app", "Load and start container from configuration file", func(args []string) error {
if len(args) < 1 {
@@ -63,7 +64,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
config.Container.Args = append(config.Container.Args, args[1:]...)
}
app.Main(ctx, msg, config)
outcome.Main(ctx, msg, config)
panic("unreachable")
})
@@ -95,7 +96,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
passwd *user.User
passwdOnce sync.Once
passwdFunc = func() {
us := strconv.Itoa(app.HsuUid(new(app.Hsu).MustID(msg), flagIdentity))
us := strconv.Itoa(outcome.HsuUid(new(outcome.Hsu).MustID(msg), flagIdentity))
if u, err := user.LookupId(us); err != nil {
msg.Verbosef("cannot look up uid %s", us)
passwd = &user.User{
@@ -257,7 +258,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
}
}
app.Main(ctx, msg, config)
outcome.Main(ctx, msg, config)
panic("unreachable")
}).
Flag(&flagDBusConfigSession, "dbus-config", command.StringFlag("builtin"),
@@ -301,7 +302,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
case 1: // instance
name := args[0]
config, entry := tryShort(msg, name)
config, entry := tryIdentifier(msg, name)
if config == nil {
config = tryPath(msg, name)
}
@@ -320,8 +321,8 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
var flagShort bool
c.NewCommand("ps", "List active instances", func(args []string) error {
var sc hst.Paths
app.CopyPaths().Copy(&sc, new(app.Hsu).MustID(nil))
printPs(os.Stdout, time.Now().UTC(), state.NewMulti(msg, sc.RunDirPath.String()), flagShort, flagJSON)
env.CopyPaths().Copy(&sc, new(outcome.Hsu).MustID(nil))
printPs(os.Stdout, time.Now().UTC(), state.NewMulti(msg, sc.RunDirPath), flagShort, flagJSON)
return errSuccess
}).Flag(&flagShort, "short", command.BoolFlag(false), "Print instance id")
}

View File

@@ -1,6 +1,7 @@
package main
import (
"encoding/hex"
"errors"
"io"
"log"
@@ -10,11 +11,15 @@ import (
"syscall"
"hakurei.app/hst"
"hakurei.app/internal/app"
"hakurei.app/internal/app/state"
"hakurei.app/internal/env"
"hakurei.app/internal/outcome"
"hakurei.app/internal/state"
"hakurei.app/message"
)
// tryPath attempts to read [hst.Config] from multiple sources.
// tryPath reads from [os.Stdin] if name has value "-".
// Otherwise, name is passed to tryFd, and if that returns nil, name is passed to [os.Open].
func tryPath(msg message.Msg, name string) (config *hst.Config) {
var r io.ReadCloser
config = new(hst.Config)
@@ -42,6 +47,7 @@ func tryPath(msg message.Msg, name string) (config *hst.Config) {
return
}
// tryFd returns a [io.ReadCloser] if name represents an integer corresponding to a valid file descriptor.
func tryFd(msg message.Msg, name string) io.ReadCloser {
if v, err := strconv.Atoi(name); err != nil {
if !errors.Is(err, strconv.ErrSyntax) {
@@ -61,10 +67,48 @@ func tryFd(msg message.Msg, name string) io.ReadCloser {
}
}
func tryShort(msg message.Msg, name string) (config *hst.Config, entry *state.State) {
likePrefix := false
if len(name) <= 32 {
likePrefix = true
// shortLengthMin is the minimum length a short form identifier can have and still be interpreted as an identifier.
const shortLengthMin = 1 << 3
// shortIdentifier returns an eight character short representation of [hst.ID] from its random bytes.
func shortIdentifier(id *hst.ID) string {
return shortIdentifierString(id.String())
}
// shortIdentifierString implements shortIdentifier on an arbitrary string.
func shortIdentifierString(s string) string {
return s[len(hst.ID{}) : len(hst.ID{})+shortLengthMin]
}
// tryIdentifier attempts to match [hst.State] from a [hex] representation of [hst.ID] or a prefix of its lower half.
func tryIdentifier(msg message.Msg, name string) (config *hst.Config, entry *hst.State) {
return tryIdentifierEntries(msg, name, func() map[hst.ID]*hst.State {
var sc hst.Paths
env.CopyPaths().Copy(&sc, new(outcome.Hsu).MustID(nil))
s := state.NewMulti(msg, sc.RunDirPath)
if entries, err := state.Join(s); err != nil {
msg.GetLogger().Printf("cannot join store: %v", err) // not fatal
return nil
} else {
return entries
}
})
}
// tryIdentifierEntries implements tryIdentifier with a custom entries pair getter.
func tryIdentifierEntries(
msg message.Msg,
name string,
getEntries func() map[hst.ID]*hst.State,
) (config *hst.Config, entry *hst.State) {
const (
likeShort = 1 << iota
likeFull
)
var likely uintptr
if len(name) >= shortLengthMin && len(name) <= len(hst.ID{}) { // half the hex representation
// cannot safely decode here due to unknown alignment
for _, c := range name {
if c >= '0' && c <= '9' {
continue
@@ -72,35 +116,50 @@ func tryShort(msg message.Msg, name string) (config *hst.Config, entry *state.St
if c >= 'a' && c <= 'f' {
continue
}
likePrefix = false
break
return
}
likely |= likeShort
} else if len(name) == hex.EncodedLen(len(hst.ID{})) {
likely |= likeFull
}
// try to match from state store
if likePrefix && len(name) >= 8 {
msg.Verbose("argument looks like prefix")
if likely == 0 {
return
}
entries := getEntries()
if entries == nil {
return
}
var sc hst.Paths
app.CopyPaths().Copy(&sc, new(app.Hsu).MustID(nil))
s := state.NewMulti(msg, sc.RunDirPath.String())
if entries, err := state.Join(s); err != nil {
log.Printf("cannot join store: %v", err)
// drop to fetch from file
} else {
for id := range entries {
v := id.String()
if strings.HasPrefix(v, name) {
// match, use config from this state entry
entry = entries[id]
config = entry.Config
break
}
msg.Verbosef("instance %s skipped", v)
switch {
case likely&likeShort != 0:
msg.Verbose("argument looks like short identifier")
for id := range entries {
v := id.String()
if strings.HasPrefix(v[len(hst.ID{}):], name) {
// match, use config from this state entry
entry = entries[id]
config = entry.Config
break
}
}
}
return
msg.Verbosef("instance %s skipped", v)
}
return
case likely&likeFull != 0:
var likelyID hst.ID
if likelyID.UnmarshalText([]byte(name)) != nil {
return
}
msg.Verbose("argument looks like identifier")
if ent, ok := entries[likelyID]; ok {
entry = ent
config = ent.Config
}
return
default:
panic("unreachable")
}
}

101
cmd/hakurei/parse_test.go Normal file
View File

@@ -0,0 +1,101 @@
package main
import (
"reflect"
"testing"
"hakurei.app/hst"
"hakurei.app/message"
)
func TestShortIdentifier(t *testing.T) {
t.Parallel()
id := hst.ID{
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10,
}
const want = "fedcba98"
if got := shortIdentifier(&id); got != want {
t.Errorf("shortIdentifier: %q, want %q", got, want)
}
}
func TestTryIdentifier(t *testing.T) {
t.Parallel()
msg := message.NewMsg(nil)
id := hst.ID{
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10,
}
testCases := []struct {
name string
s string
entries map[hst.ID]*hst.State
want *hst.State
}{
{"likely entries fault", "ffffffff", nil, nil},
{"likely short too short", "ff", nil, nil},
{"likely short too long", "fffffffffffffffff", nil, nil},
{"likely short invalid lower", "fffffff\x00", nil, nil},
{"likely short invalid higher", "0000000\xff", nil, nil},
{"short no match", "fedcba98", map[hst.ID]*hst.State{hst.ID{}: nil}, nil},
{"short match", "fedcba98", map[hst.ID]*hst.State{
hst.ID{}: nil,
id: {
ID: id,
PID: 0xcafebabe,
ShimPID: 0xdeadbeef,
Config: hst.Template(),
},
}, &hst.State{
ID: id,
PID: 0xcafebabe,
ShimPID: 0xdeadbeef,
Config: hst.Template(),
}},
{"short match longer", "fedcba98765", map[hst.ID]*hst.State{
hst.ID{}: nil,
id: {
ID: id,
PID: 0xcafebabe,
ShimPID: 0xdeadbeef,
Config: hst.Template(),
},
}, &hst.State{
ID: id,
PID: 0xcafebabe,
ShimPID: 0xdeadbeef,
Config: hst.Template(),
}},
{"likely long invalid", "0123456789abcdeffedcba987654321\x00", map[hst.ID]*hst.State{}, nil},
{"long no match", "0123456789abcdeffedcba9876543210", map[hst.ID]*hst.State{hst.ID{}: nil}, nil},
{"long match", "0123456789abcdeffedcba9876543210", map[hst.ID]*hst.State{
hst.ID{}: nil,
id: {
ID: id,
PID: 0xcafebabe,
ShimPID: 0xdeadbeef,
Config: hst.Template(),
},
}, &hst.State{
ID: id,
PID: 0xcafebabe,
ShimPID: 0xdeadbeef,
Config: hst.Template(),
}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, got := tryIdentifierEntries(msg, tc.s, func() map[hst.ID]*hst.State { return tc.entries })
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("tryIdentifier: %#v, want %#v", got, tc.want)
}
})
}
}

View File

@@ -12,8 +12,9 @@ import (
"hakurei.app/hst"
"hakurei.app/internal"
"hakurei.app/internal/app"
"hakurei.app/internal/app/state"
"hakurei.app/internal/env"
"hakurei.app/internal/outcome"
"hakurei.app/internal/state"
"hakurei.app/message"
)
@@ -22,8 +23,8 @@ func printShowSystem(output io.Writer, short, flagJSON bool) {
t := newPrinter(output)
defer t.MustFlush()
info := &hst.Info{Version: internal.Version(), User: new(app.Hsu).MustID(nil)}
app.CopyPaths().Copy(&info.Paths, info.User)
info := &hst.Info{Version: internal.Version(), User: new(outcome.Hsu).MustID(nil)}
env.CopyPaths().Copy(&info.Paths, info.User)
if flagJSON {
encodeJSON(log.Fatal, output, short, info)
@@ -38,10 +39,10 @@ func printShowSystem(output io.Writer, short, flagJSON bool) {
t.Printf("RunDirPath:\t%s\n", info.RunDirPath)
}
// printShowInstance writes a representation of [state.State] or [hst.Config] to output.
// printShowInstance writes a representation of [hst.State] or [hst.Config] to output.
func printShowInstance(
output io.Writer, now time.Time,
instance *state.State, config *hst.Config,
instance *hst.State, config *hst.Config,
short, flagJSON bool) (valid bool) {
valid = true
@@ -66,7 +67,7 @@ func printShowInstance(
if instance != nil {
t.Printf("State\n")
t.Printf(" Instance:\t%s (%d)\n", instance.ID.String(), instance.PID)
t.Printf(" Instance:\t%s (%d -> %d)\n", instance.ID.String(), instance.PID, instance.ShimPID)
t.Printf(" Uptime:\t%s\n", now.Sub(instance.Time).Round(time.Second).String())
t.Printf("\n")
}
@@ -168,18 +169,15 @@ func printShowInstance(
// printPs writes a representation of active instances to output.
func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON bool) {
var entries state.Entries
var entries map[hst.ID]*hst.State
if e, err := state.Join(s); err != nil {
log.Fatalf("cannot join store: %v", err)
} else {
entries = e
}
if err := s.Close(); err != nil {
log.Printf("cannot close store: %v", err)
}
if !short && flagJSON {
es := make(map[string]*state.State, len(entries))
es := make(map[string]*hst.State, len(entries))
for id, instance := range entries {
es[id.String()] = instance
}
@@ -215,7 +213,7 @@ func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON boo
encodeJSON(log.Fatal, output, short, v)
} else {
for _, e := range exp {
mustPrintln(output, e.s[:8])
mustPrintln(output, shortIdentifierString(e.s))
}
}
return
@@ -237,19 +235,19 @@ func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON boo
as = strconv.Itoa(e.Config.Identity)
id := e.Config.ID
if id == "" {
id = "app.hakurei." + e.s[:8]
id = "app.hakurei." + shortIdentifierString(e.s)
}
as += " (" + id + ")"
}
t.Printf("\t%s\t%d\t%s\t%s\n",
e.s[:8], e.PID, as, now.Sub(e.Time).Round(time.Second).String())
shortIdentifierString(e.s), e.PID, as, now.Sub(e.Time).Round(time.Second).String())
}
}
// expandedStateEntry stores [state.State] alongside a string representation of its [state.ID].
// expandedStateEntry stores [hst.State] alongside a string representation of its [hst.ID].
type expandedStateEntry struct {
s string
*state.State
*hst.State
}
// newPrinter returns a configured, wrapped [tabwriter.Writer].

View File

@@ -6,21 +6,22 @@ import (
"time"
"hakurei.app/hst"
"hakurei.app/internal/app/state"
"hakurei.app/internal/state"
)
var (
testID = state.ID{
testID = hst.ID{
0x8e, 0x2c, 0x76, 0xb0,
0x66, 0xda, 0xbe, 0x57,
0x4c, 0xf0, 0x73, 0xbd,
0xb4, 0x6e, 0xb5, 0xc1,
}
testState = &state.State{
ID: testID,
PID: 0xDEADBEEF,
Config: hst.Template(),
Time: testAppTime,
testState = &hst.State{
ID: testID,
PID: 0xcafebabe,
ShimPID: 0xdeadbeef,
Config: hst.Template(),
Time: testAppTime,
}
testTime = time.Unix(3752, 1).UTC()
testAppTime = time.Unix(0, 9).UTC()
@@ -31,7 +32,7 @@ func TestPrintShowInstance(t *testing.T) {
testCases := []struct {
name string
instance *state.State
instance *hst.State
config *hst.Config
short, json bool
want string
@@ -131,7 +132,7 @@ Session bus
`, false},
{"instance", testState, hst.Template(), false, false, `State
Instance: 8e2c76b066dabe574cf073bdb46eb5c1 (3735928559)
Instance: 8e2c76b066dabe574cf073bdb46eb5c1 (3405691582 -> 3735928559)
Uptime: 1h2m32s
App
@@ -173,7 +174,7 @@ System bus
{"instance pd", testState, new(hst.Config), false, false, `Error: configuration missing container state!
State
Instance: 8e2c76b066dabe574cf073bdb46eb5c1 (3735928559)
Instance: 8e2c76b066dabe574cf073bdb46eb5c1 (3405691582 -> 3735928559)
Uptime: 1h2m32s
App
@@ -185,173 +186,155 @@ App
{"json nil", nil, nil, false, true, `null
`, true},
{"json instance", testState, nil, false, true, `{
"instance": [
142,
44,
118,
176,
102,
218,
190,
87,
76,
240,
115,
189,
180,
110,
181,
193
"instance": "8e2c76b066dabe574cf073bdb46eb5c1",
"pid": 3405691582,
"shim_pid": 3735928559,
"id": "org.chromium.Chromium",
"enablements": {
"wayland": true,
"dbus": true,
"pulse": true
},
"session_bus": {
"see": null,
"talk": [
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager"
],
"own": [
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"
],
"call": {
"org.freedesktop.portal.*": "*"
},
"broadcast": {
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"
},
"filter": true
},
"system_bus": {
"see": null,
"talk": [
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower"
],
"own": null,
"call": null,
"broadcast": null,
"filter": true
},
"extra_perms": [
{
"ensure": true,
"path": "/var/lib/hakurei/u0",
"x": true
},
{
"path": "/var/lib/hakurei/u0/org.chromium.Chromium",
"r": true,
"w": true,
"x": true
}
],
"pid": 3735928559,
"config": {
"id": "org.chromium.Chromium",
"enablements": {
"wayland": true,
"dbus": true,
"pulse": true
"identity": 9,
"groups": [
"video",
"dialout",
"plugdev"
],
"container": {
"hostname": "localhost",
"wait_delay": -1,
"env": {
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT"
},
"session_bus": {
"see": null,
"talk": [
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager"
],
"own": [
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"
],
"call": {
"org.freedesktop.portal.*": "*"
},
"broadcast": {
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"
},
"filter": true
},
"system_bus": {
"see": null,
"talk": [
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower"
],
"own": null,
"call": null,
"broadcast": null,
"filter": true
},
"extra_perms": [
"filesystem": [
{
"ensure": true,
"path": "/var/lib/hakurei/u0",
"x": true
"type": "bind",
"dst": "/",
"src": "/var/lib/hakurei/base/org.debian",
"write": true,
"special": true
},
{
"path": "/var/lib/hakurei/u0/org.chromium.Chromium",
"r": true,
"w": true,
"x": true
"type": "bind",
"dst": "/etc/",
"src": "/etc/",
"special": true
},
{
"type": "ephemeral",
"dst": "/tmp/",
"write": true,
"perm": 493
},
{
"type": "overlay",
"dst": "/nix/store",
"lower": [
"/var/lib/hakurei/base/org.nixos/ro-store"
],
"upper": "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper",
"work": "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work"
},
{
"type": "link",
"dst": "/run/current-system",
"linkname": "/run/current-system",
"dereference": true
},
{
"type": "link",
"dst": "/run/opengl-driver",
"linkname": "/run/opengl-driver",
"dereference": true
},
{
"type": "bind",
"dst": "/data/data/org.chromium.Chromium",
"src": "/var/lib/hakurei/u0/org.chromium.Chromium",
"write": true,
"ensure": true
},
{
"type": "bind",
"src": "/dev/dri",
"dev": true,
"optional": true
}
],
"identity": 9,
"groups": [
"video",
"dialout",
"plugdev"
"username": "chronos",
"shell": "/run/current-system/sw/bin/zsh",
"home": "/data/data/org.chromium.Chromium",
"path": "/run/current-system/sw/bin/chromium",
"args": [
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland"
],
"container": {
"hostname": "localhost",
"wait_delay": -1,
"env": {
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT"
},
"filesystem": [
{
"type": "bind",
"dst": "/",
"src": "/var/lib/hakurei/base/org.debian",
"write": true,
"special": true
},
{
"type": "bind",
"dst": "/etc/",
"src": "/etc/",
"special": true
},
{
"type": "ephemeral",
"dst": "/tmp/",
"write": true,
"perm": 493
},
{
"type": "overlay",
"dst": "/nix/store",
"lower": [
"/var/lib/hakurei/base/org.nixos/ro-store"
],
"upper": "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper",
"work": "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work"
},
{
"type": "link",
"dst": "/run/current-system",
"linkname": "/run/current-system",
"dereference": true
},
{
"type": "link",
"dst": "/run/opengl-driver",
"linkname": "/run/opengl-driver",
"dereference": true
},
{
"type": "bind",
"dst": "/data/data/org.chromium.Chromium",
"src": "/var/lib/hakurei/u0/org.chromium.Chromium",
"write": true,
"ensure": true
},
{
"type": "bind",
"src": "/dev/dri",
"dev": true,
"optional": true
}
],
"username": "chronos",
"shell": "/run/current-system/sw/bin/zsh",
"home": "/data/data/org.chromium.Chromium",
"path": "/run/current-system/sw/bin/chromium",
"args": [
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland"
],
"seccomp_compat": true,
"devel": true,
"userns": true,
"host_net": true,
"host_abstract": true,
"tty": true,
"multiarch": true,
"map_real_uid": true,
"device": true,
"share_runtime": true,
"share_tmpdir": true
}
"seccomp_compat": true,
"devel": true,
"userns": true,
"host_net": true,
"host_abstract": true,
"tty": true,
"multiarch": true,
"map_real_uid": true,
"device": true,
"share_runtime": true,
"share_tmpdir": true
},
"time": "1970-01-01T00:00:00.000000009Z"
}
@@ -530,198 +513,180 @@ func TestPrintPs(t *testing.T) {
testCases := []struct {
name string
entries state.Entries
entries map[hst.ID]*hst.State
short, json bool
want string
}{
{"no entries", make(state.Entries), false, false, " Instance PID Application Uptime\n"},
{"no entries short", make(state.Entries), true, false, ""},
{"nil instance", state.Entries{testID: nil}, false, false, " Instance PID Application Uptime\n"},
{"state corruption", state.Entries{state.ID{}: testState}, false, false, " Instance PID Application Uptime\n"},
{"no entries", make(map[hst.ID]*hst.State), false, false, " Instance PID Application Uptime\n"},
{"no entries short", make(map[hst.ID]*hst.State), true, false, ""},
{"nil instance", map[hst.ID]*hst.State{testID: nil}, false, false, " Instance PID Application Uptime\n"},
{"state corruption", map[hst.ID]*hst.State{hst.ID{}: testState}, false, false, " Instance PID Application Uptime\n"},
{"valid pd", state.Entries{testID: &state.State{ID: testID, PID: 1 << 8, Config: new(hst.Config), Time: testAppTime}}, false, false, ` Instance PID Application Uptime
8e2c76b0 256 0 (app.hakurei.8e2c76b0) 1h2m32s
{"valid pd", map[hst.ID]*hst.State{testID: {ID: testID, PID: 1 << 8, Config: new(hst.Config), Time: testAppTime}}, false, false, ` Instance PID Application Uptime
4cf073bd 256 0 (app.hakurei.4cf073bd) 1h2m32s
`},
{"valid", state.Entries{testID: testState}, false, false, ` Instance PID Application Uptime
8e2c76b0 3735928559 9 (org.chromium.Chromium) 1h2m32s
{"valid", map[hst.ID]*hst.State{testID: testState}, false, false, ` Instance PID Application Uptime
4cf073bd 3405691582 9 (org.chromium.Chromium) 1h2m32s
`},
{"valid short", state.Entries{testID: testState}, true, false, "8e2c76b0\n"},
{"valid json", state.Entries{testID: testState}, false, true, `{
{"valid short", map[hst.ID]*hst.State{testID: testState}, true, false, "4cf073bd\n"},
{"valid json", map[hst.ID]*hst.State{testID: testState}, false, true, `{
"8e2c76b066dabe574cf073bdb46eb5c1": {
"instance": [
142,
44,
118,
176,
102,
218,
190,
87,
76,
240,
115,
189,
180,
110,
181,
193
"instance": "8e2c76b066dabe574cf073bdb46eb5c1",
"pid": 3405691582,
"shim_pid": 3735928559,
"id": "org.chromium.Chromium",
"enablements": {
"wayland": true,
"dbus": true,
"pulse": true
},
"session_bus": {
"see": null,
"talk": [
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager"
],
"own": [
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"
],
"call": {
"org.freedesktop.portal.*": "*"
},
"broadcast": {
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"
},
"filter": true
},
"system_bus": {
"see": null,
"talk": [
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower"
],
"own": null,
"call": null,
"broadcast": null,
"filter": true
},
"extra_perms": [
{
"ensure": true,
"path": "/var/lib/hakurei/u0",
"x": true
},
{
"path": "/var/lib/hakurei/u0/org.chromium.Chromium",
"r": true,
"w": true,
"x": true
}
],
"pid": 3735928559,
"config": {
"id": "org.chromium.Chromium",
"enablements": {
"wayland": true,
"dbus": true,
"pulse": true
"identity": 9,
"groups": [
"video",
"dialout",
"plugdev"
],
"container": {
"hostname": "localhost",
"wait_delay": -1,
"env": {
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT"
},
"session_bus": {
"see": null,
"talk": [
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager"
],
"own": [
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"
],
"call": {
"org.freedesktop.portal.*": "*"
},
"broadcast": {
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"
},
"filter": true
},
"system_bus": {
"see": null,
"talk": [
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower"
],
"own": null,
"call": null,
"broadcast": null,
"filter": true
},
"extra_perms": [
"filesystem": [
{
"ensure": true,
"path": "/var/lib/hakurei/u0",
"x": true
"type": "bind",
"dst": "/",
"src": "/var/lib/hakurei/base/org.debian",
"write": true,
"special": true
},
{
"path": "/var/lib/hakurei/u0/org.chromium.Chromium",
"r": true,
"w": true,
"x": true
"type": "bind",
"dst": "/etc/",
"src": "/etc/",
"special": true
},
{
"type": "ephemeral",
"dst": "/tmp/",
"write": true,
"perm": 493
},
{
"type": "overlay",
"dst": "/nix/store",
"lower": [
"/var/lib/hakurei/base/org.nixos/ro-store"
],
"upper": "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper",
"work": "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work"
},
{
"type": "link",
"dst": "/run/current-system",
"linkname": "/run/current-system",
"dereference": true
},
{
"type": "link",
"dst": "/run/opengl-driver",
"linkname": "/run/opengl-driver",
"dereference": true
},
{
"type": "bind",
"dst": "/data/data/org.chromium.Chromium",
"src": "/var/lib/hakurei/u0/org.chromium.Chromium",
"write": true,
"ensure": true
},
{
"type": "bind",
"src": "/dev/dri",
"dev": true,
"optional": true
}
],
"identity": 9,
"groups": [
"video",
"dialout",
"plugdev"
"username": "chronos",
"shell": "/run/current-system/sw/bin/zsh",
"home": "/data/data/org.chromium.Chromium",
"path": "/run/current-system/sw/bin/chromium",
"args": [
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland"
],
"container": {
"hostname": "localhost",
"wait_delay": -1,
"env": {
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT"
},
"filesystem": [
{
"type": "bind",
"dst": "/",
"src": "/var/lib/hakurei/base/org.debian",
"write": true,
"special": true
},
{
"type": "bind",
"dst": "/etc/",
"src": "/etc/",
"special": true
},
{
"type": "ephemeral",
"dst": "/tmp/",
"write": true,
"perm": 493
},
{
"type": "overlay",
"dst": "/nix/store",
"lower": [
"/var/lib/hakurei/base/org.nixos/ro-store"
],
"upper": "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper",
"work": "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work"
},
{
"type": "link",
"dst": "/run/current-system",
"linkname": "/run/current-system",
"dereference": true
},
{
"type": "link",
"dst": "/run/opengl-driver",
"linkname": "/run/opengl-driver",
"dereference": true
},
{
"type": "bind",
"dst": "/data/data/org.chromium.Chromium",
"src": "/var/lib/hakurei/u0/org.chromium.Chromium",
"write": true,
"ensure": true
},
{
"type": "bind",
"src": "/dev/dri",
"dev": true,
"optional": true
}
],
"username": "chronos",
"shell": "/run/current-system/sw/bin/zsh",
"home": "/data/data/org.chromium.Chromium",
"path": "/run/current-system/sw/bin/chromium",
"args": [
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland"
],
"seccomp_compat": true,
"devel": true,
"userns": true,
"host_net": true,
"host_abstract": true,
"tty": true,
"multiarch": true,
"map_real_uid": true,
"device": true,
"share_runtime": true,
"share_tmpdir": true
}
"seccomp_compat": true,
"devel": true,
"userns": true,
"host_net": true,
"host_abstract": true,
"tty": true,
"multiarch": true,
"map_real_uid": true,
"device": true,
"share_runtime": true,
"share_tmpdir": true
},
"time": "1970-01-01T00:00:00.000000009Z"
}
}
`},
{"valid short json", state.Entries{testID: testState}, true, true, `["8e2c76b066dabe574cf073bdb46eb5c1"]
{"valid short json", map[hst.ID]*hst.State{testID: testState}, true, true, `["8e2c76b066dabe574cf073bdb46eb5c1"]
`},
}
@@ -741,9 +706,9 @@ func TestPrintPs(t *testing.T) {
}
// stubStore implements [state.Store] and returns test samples via [state.Joiner].
type stubStore state.Entries
type stubStore map[hst.ID]*hst.State
func (s stubStore) Join() (state.Entries, error) { return state.Entries(s), nil }
func (s stubStore) Join() (map[hst.ID]*hst.State, error) { return s, nil }
func (s stubStore) Do(int, func(c state.Cursor)) (bool, error) { panic("unreachable") }
func (s stubStore) List() ([]int, error) { panic("unreachable") }
func (s stubStore) Close() error { return nil }

View File

@@ -60,13 +60,11 @@ def check_state(name, enablements):
raise Exception(f"unexpected state length {len(instances)}")
instance = next(iter(instances.values()))
config = instance['config']
if len(instance['container']['args']) != 1 or not (instance['container']['args'][0].startswith("/nix/store/")) or f"hakurei-{name}-" not in (instance['container']['args'][0]):
raise Exception(f"unexpected args {instance['container']['args']}")
if len(config['container']['args']) != 1 or not (config['container']['args'][0].startswith("/nix/store/")) or f"hakurei-{name}-" not in (config['container']['args'][0]):
raise Exception(f"unexpected args {config['container']['args']}")
if config['enablements'] != enablements:
raise Exception(f"unexpected enablements {config['enablements']}")
if instance['enablements'] != enablements:
raise Exception(f"unexpected enablements {instance['enablements']}")
start_all()

View File

@@ -1,6 +1,8 @@
package container
import (
"fmt"
"log"
"os"
"sync"
@@ -14,8 +16,13 @@ var (
func copyExecutable(msg message.Msg) {
if name, err := os.Executable(); err != nil {
msg.BeforeExit()
msg.GetLogger().Fatalf("cannot read executable path: %v", err)
m := fmt.Sprintf("cannot read executable path: %v", err)
if msg != nil {
msg.BeforeExit()
msg.GetLogger().Fatal(m)
} else {
log.Fatal(m)
}
} else {
executable = name
}

View File

@@ -16,7 +16,7 @@ type AppError struct {
// A user-facing description of where the error occurred.
Step string `json:"step"`
// The underlying error value.
Err error
Err error `json:"err"`
// An arbitrary error message, overriding the return value of Message if not empty.
Msg string `json:"message,omitempty"`
}

87
hst/instance.go Normal file
View File

@@ -0,0 +1,87 @@
package hst
import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"time"
)
// An ID is a unique identifier held by a running hakurei container.
type ID [16]byte
// ErrIdentifierLength is returned when encountering a [hex] representation of [ID] with unexpected length.
var ErrIdentifierLength = errors.New("identifier string has unexpected length")
// IdentifierDecodeError is returned by [ID.UnmarshalText] to provide relevant error descriptions.
type IdentifierDecodeError struct{ Err error }
func (e IdentifierDecodeError) Unwrap() error { return e.Err }
func (e IdentifierDecodeError) Error() string {
var invalidByteError hex.InvalidByteError
switch {
case errors.As(e.Err, &invalidByteError):
return fmt.Sprintf("got invalid byte %#U in identifier", rune(invalidByteError))
case errors.Is(e.Err, hex.ErrLength):
return "odd length identifier hex string"
default:
return e.Err.Error()
}
}
// String returns the [hex] string representation of [ID].
func (a *ID) String() string { return hex.EncodeToString(a[:]) }
// CreationTime returns the point in time [ID] was created.
func (a *ID) CreationTime() time.Time {
return time.Unix(0, int64(binary.BigEndian.Uint64(a[:8]))).UTC()
}
// NewInstanceID creates a new unique [ID].
func NewInstanceID(id *ID) error { return newInstanceID(id, uint64(time.Now().UnixNano())) }
// newInstanceID creates a new unique [ID] with the specified timestamp.
func newInstanceID(id *ID, p uint64) error {
binary.BigEndian.PutUint64(id[:8], p)
_, err := rand.Read(id[8:])
return err
}
// MarshalText encodes the [hex] representation of [ID].
func (a *ID) MarshalText() (text []byte, err error) {
text = make([]byte, hex.EncodedLen(len(a)))
hex.Encode(text, a[:])
return
}
// UnmarshalText decodes a [hex] representation of [ID].
func (a *ID) UnmarshalText(text []byte) error {
dl := hex.DecodedLen(len(text))
if dl != len(a) {
return IdentifierDecodeError{ErrIdentifierLength}
}
_, err := hex.Decode(a[:], text)
if err == nil {
return nil
}
return IdentifierDecodeError{err}
}
// A State describes a running hakurei container.
type State struct {
// Unique instance id, created by [NewInstanceID].
ID ID `json:"instance"`
// Monitoring process pid. Runs as the priv user.
PID int `json:"pid"`
// Shim process pid. Runs as the target user.
ShimPID int `json:"shim_pid"`
// Configuration used to start the container.
*Config
// Point in time the shim process was created.
Time time.Time `json:"time"`
}

113
hst/instance_test.go Normal file
View File

@@ -0,0 +1,113 @@
package hst_test
import (
"encoding/hex"
"errors"
"reflect"
"testing"
"time"
_ "unsafe"
"hakurei.app/hst"
)
//go:linkname newInstanceID hakurei.app/hst.newInstanceID
func newInstanceID(id *hst.ID, p uint64) error
func TestIdentifierDecodeError(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
err error
want string
}{
{"invalid byte", hst.IdentifierDecodeError{Err: hex.InvalidByteError(0)},
"got invalid byte U+0000 in identifier"},
{"odd length", hst.IdentifierDecodeError{Err: hex.ErrLength},
"odd length identifier hex string"},
{"passthrough", hst.IdentifierDecodeError{Err: hst.ErrIdentifierLength},
hst.ErrIdentifierLength.Error()},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.err.Error(); got != tc.want {
t.Errorf("Error: %q, want %q", got, tc.want)
}
})
}
t.Run("unwrap", func(t *testing.T) {
t.Parallel()
err := hst.IdentifierDecodeError{Err: hst.ErrIdentifierLength}
if !errors.Is(err, hst.ErrIdentifierLength) {
t.Errorf("Is unexpected false")
}
})
}
func TestID(t *testing.T) {
t.Parallel()
var randomID hst.ID
if err := hst.NewInstanceID(&randomID); err != nil {
t.Fatalf("NewInstanceID: error = %v", err)
}
testCases := []struct {
name string
data string
want hst.ID
err error
}{
{"bad length", "meow", hst.ID{},
hst.IdentifierDecodeError{Err: hst.ErrIdentifierLength}},
{"invalid byte", "02bc7f8936b2af6\x00\x00e2535cd71ef0bb7", hst.ID{},
hst.IdentifierDecodeError{Err: hex.InvalidByteError(0)}},
{"zero", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", hst.ID{}, nil},
{"random", randomID.String(), randomID, nil},
{"sample", "ba21c9bd33d9d37917288281a2a0d239", hst.ID{
0xba, 0x21, 0xc9, 0xbd,
0x33, 0xd9, 0xd3, 0x79,
0x17, 0x28, 0x82, 0x81,
0xa2, 0xa0, 0xd2, 0x39}, nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var got hst.ID
if err := got.UnmarshalText([]byte(tc.data)); !reflect.DeepEqual(err, tc.err) {
t.Errorf("UnmarshalText: error = %#v, want %#v", err, tc.err)
}
if tc.err == nil {
if gotString := got.String(); gotString != tc.data {
t.Errorf("String: %q, want %q", gotString, tc.data)
}
if gotData, _ := got.MarshalText(); string(gotData) != tc.data {
t.Errorf("MarshalText: %q, want %q", string(gotData), tc.data)
}
}
})
}
t.Run("time", func(t *testing.T) {
t.Parallel()
var id hst.ID
now := time.Now()
if err := newInstanceID(&id, uint64(now.UnixNano())); err != nil {
t.Fatalf("newInstanceID: error = %v", err)
}
got := id.CreationTime()
if !got.Equal(now) {
t.Fatalf("CreationTime(%q): %s, want %s", id.String(), got, now)
}
})
}

View File

@@ -1,59 +0,0 @@
package app
import (
"strconv"
"hakurei.app/container/check"
"hakurei.app/hst"
)
// EnvPaths holds paths copied from the environment and is used to create [hst.Paths].
type EnvPaths struct {
// TempDir is returned by [os.TempDir].
TempDir *check.Absolute
// RuntimePath is copied from $XDG_RUNTIME_DIR.
RuntimePath *check.Absolute
}
// Copy expands [EnvPaths] into [hst.Paths].
func (env *EnvPaths) Copy(v *hst.Paths, userid int) {
if env == nil || env.TempDir == nil || v == nil {
panic("attempting to use an invalid EnvPaths")
}
v.TempDir = env.TempDir
v.SharePath = env.TempDir.Append("hakurei." + strconv.Itoa(userid))
if env.RuntimePath == nil {
// fall back to path in share since hakurei has no hard XDG dependency
v.RunDirPath = v.SharePath.Append("run")
v.RuntimePath = v.RunDirPath.Append("compat")
} else {
v.RuntimePath = env.RuntimePath
v.RunDirPath = env.RuntimePath.Append("hakurei")
}
}
// CopyPaths returns a populated [EnvPaths].
func CopyPaths() *EnvPaths { return copyPaths(direct{}) }
// copyPaths returns a populated [EnvPaths].
func copyPaths(k syscallDispatcher) *EnvPaths {
const xdgRuntimeDir = "XDG_RUNTIME_DIR"
var env EnvPaths
if tempDir, err := check.NewAbs(k.tempdir()); err != nil {
k.fatalf("invalid TMPDIR: %v", err)
panic("unreachable")
} else {
env.TempDir = tempDir
}
r, _ := k.lookupEnv(xdgRuntimeDir)
if a, err := check.NewAbs(r); err == nil {
env.RuntimePath = a
}
return &env
}

View File

@@ -1,15 +0,0 @@
package app
import (
"path/filepath"
"strings"
)
func deepContainsH(basepath, targpath string) (bool, error) {
const upper = ".." + string(filepath.Separator)
rel, err := filepath.Rel(basepath, targpath)
return err == nil &&
rel != ".." &&
!strings.HasPrefix(rel, upper), err
}

View File

@@ -1,3 +0,0 @@
#include <signal.h>
void hakurei_shim_setup_cont_signal(pid_t ppid, int fd);

View File

@@ -1,155 +0,0 @@
package app
import (
"bytes"
"context"
"log"
"os"
"syscall"
"testing"
"hakurei.app/container"
"hakurei.app/container/comp"
"hakurei.app/container/fhs"
"hakurei.app/container/seccomp"
"hakurei.app/container/stub"
"hakurei.app/hst"
)
func TestShimEntrypoint(t *testing.T) {
t.Parallel()
shimPreset := seccomp.Preset(comp.PresetStrict, seccomp.AllowMultiarch)
templateParams := &container.Params{
Dir: m("/data/data/org.chromium.Chromium"),
Env: []string{
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus",
"DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket",
"GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT",
"HOME=/data/data/org.chromium.Chromium",
"PULSE_COOKIE=/.hakurei/pulse-cookie",
"PULSE_SERVER=unix:/run/user/1000/pulse/native",
"SHELL=/run/current-system/sw/bin/zsh",
"TERM=xterm-256color",
"USER=chronos",
"WAYLAND_DISPLAY=wayland-0",
"XDG_RUNTIME_DIR=/run/user/1000",
"XDG_SESSION_CLASS=user",
"XDG_SESSION_TYPE=wayland",
},
// spParamsOp
Hostname: "localhost",
RetainSession: true,
HostNet: true,
HostAbstract: true,
ForwardCancel: true,
Path: m("/run/current-system/sw/bin/chromium"),
Args: []string{
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland",
},
SeccompFlags: seccomp.AllowMultiarch,
Uid: 1000,
Gid: 100,
Ops: new(container.Ops).
// resolveRoot
Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable).
// spParamsOp
Proc(fhs.AbsProc).
Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755).
Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice).
Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777).
// spRuntimeOp
Tmpfs(fhs.AbsRunUser, 1<<12, 0755).
Bind(m("/tmp/hakurei.10/runtime/9999"), m("/run/user/1000"), comp.BindWritable).
// spTmpdirOp
Bind(m("/tmp/hakurei.10/tmpdir/9999"), fhs.AbsTmp, comp.BindWritable).
// spAccountOp
Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")).
Place(m("/etc/group"), []byte("hakurei:x:100:\n")).
// spWaylandOp
Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/wayland"), m("/run/user/1000/wayland-0"), 0).
// spPulseOp
Bind(m("/run/user/1000/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pulse"), m("/run/user/1000/pulse/native"), 0).
Place(m("/.hakurei/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)).
// spDBusOp
Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bus"), m("/run/user/1000/bus"), 0).
Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/system_bus_socket"), m("/var/run/dbus/system_bus_socket"), 0).
// spFilesystemOp
Etc(fhs.AbsEtc, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").
Tmpfs(fhs.AbsTmp, 0, 0755).
Overlay(m("/nix/store"),
fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"),
fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/work"),
fhs.AbsVarLib.Append("hakurei/base/org.nixos/ro-store")).
Link(m("/run/current-system"), "/run/current-system", true).
Link(m("/run/opengl-driver"), "/run/opengl-driver", true).
Bind(fhs.AbsVarLib.Append("hakurei/u0/org.chromium.Chromium"),
m("/data/data/org.chromium.Chromium"),
comp.BindWritable|comp.BindEnsure).
Bind(fhs.AbsDev.Append("dri"), fhs.AbsDev.Append("dri"),
comp.BindOptional|comp.BindWritable|comp.BindDevice).
Remount(fhs.AbsRoot, syscall.MS_RDONLY),
}
checkSimple(t, "shimEntrypoint", []simpleTestCase{
{"success", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", outcomeState{
Shim: &shimParams{PrivPID: 0xbad, WaitDelay: 0xf, Verbose: true, Ops: []outcomeOp{
&spParamsOp{"xterm-256color", true},
&spRuntimeOp{sessionTypeWayland},
spTmpdirOp{},
spAccountOp{},
&spWaylandOp{},
&spPulseOp{(*[256]byte)(bytes.Repeat([]byte{0}, pulseCookieSizeMax)), pulseCookieSizeMax},
&spDBusOp{true},
&spFilesystemOp{},
}},
ID: &checkExpectInstanceId,
Identity: hst.IdentityMax,
UserID: 10,
Container: hst.Template().Container,
Mapuid: 1000,
Mapgid: 100,
EnvPaths: &EnvPaths{TempDir: fhs.AbsTmp, RuntimePath: fhs.AbsRunUser.Append("1000")},
}, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, nil, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, nil),
call("containerServe", stub.ExpectArgs{templateParams}, nil, nil),
call("seccompLoad", stub.ExpectArgs{shimPreset, seccomp.AllowMultiarch}, nil, nil),
call("containerWait", stub.ExpectArgs{templateParams}, nil, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, []byte{2}, nil),
call("verbose", stub.ExpectArgs{[]any{"sa_sigaction got invalid siginfo"}}, nil, nil),
call("rcRead", stub.ExpectArgs{}, []byte{3}, nil),
call("verbose", stub.ExpectArgs{[]any{"got SIGCONT from unexpected process"}}, nil, nil),
call("rcRead", stub.ExpectArgs{}, nil, nil), // stub terminates this goroutine
}}}}, nil},
})
}

View File

@@ -1,48 +0,0 @@
package state
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
)
type ID [16]byte
var (
ErrInvalidLength = errors.New("string representation must have a length of 32")
)
func (a *ID) String() string {
return hex.EncodeToString(a[:])
}
func NewAppID(id *ID) error {
_, err := rand.Read(id[:])
return err
}
func ParseAppID(id *ID, s string) error {
if len(s) != 32 {
return ErrInvalidLength
}
for i, b := range s {
if b < '0' || b > 'f' {
return fmt.Errorf("invalid char %q at byte %d", b, i)
}
v := uint8(b)
if v > '9' {
v = 10 + v - 'a'
} else {
v -= '0'
}
if i%2 == 0 {
v <<= 4
}
id[i/2] += v
}
return nil
}

View File

@@ -1,63 +0,0 @@
package state_test
import (
"errors"
"testing"
"hakurei.app/internal/app/state"
)
func TestParseAppID(t *testing.T) {
t.Run("bad length", func(t *testing.T) {
if err := state.ParseAppID(new(state.ID), "meow"); !errors.Is(err, state.ErrInvalidLength) {
t.Errorf("ParseAppID: error = %v, wantErr = %v", err, state.ErrInvalidLength)
}
})
t.Run("bad byte", func(t *testing.T) {
wantErr := "invalid char '\\n' at byte 15"
if err := state.ParseAppID(new(state.ID), "02bc7f8936b2af6\n\ne2535cd71ef0bb7"); err == nil || err.Error() != wantErr {
t.Errorf("ParseAppID: error = %v, wantErr = %v", err, wantErr)
}
})
t.Run("fuzz 16 iterations", func(t *testing.T) {
for i := 0; i < 16; i++ {
testParseAppIDWithRandom(t)
}
})
}
func FuzzParseAppID(f *testing.F) {
for i := 0; i < 16; i++ {
id := new(state.ID)
if err := state.NewAppID(id); err != nil {
panic(err.Error())
}
f.Add(id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], id[8], id[9], id[10], id[11], id[12], id[13], id[14], id[15])
}
f.Fuzz(func(t *testing.T, b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 byte) {
testParseAppID(t, &state.ID{b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15})
})
}
func testParseAppIDWithRandom(t *testing.T) {
id := new(state.ID)
if err := state.NewAppID(id); err != nil {
t.Fatalf("cannot generate app ID: %v", err)
}
testParseAppID(t, id)
}
func testParseAppID(t *testing.T, id *state.ID) {
s := id.String()
got := new(state.ID)
if err := state.ParseAppID(got, s); err != nil {
t.Fatalf("cannot parse app ID: %v", err)
}
if *got != *id {
t.Fatalf("ParseAppID(%#v) = \n%#v, want \n%#v", s, got, id)
}
}

View File

@@ -1,289 +0,0 @@
package state
import (
"encoding/gob"
"errors"
"fmt"
"io/fs"
"os"
"path"
"strconv"
"sync"
"syscall"
"hakurei.app/hst"
"hakurei.app/message"
)
// fine-grained locking and access
type multiStore struct {
base string
// initialised backends
backends *sync.Map
msg message.Msg
mu sync.RWMutex
}
func (s *multiStore) Do(identity int, f func(c Cursor)) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// load or initialise new backend
b := new(multiBackend)
b.mu.Lock()
if v, ok := s.backends.LoadOrStore(identity, b); ok {
b = v.(*multiBackend)
} else {
b.path = path.Join(s.base, strconv.Itoa(identity))
// ensure directory
if err := os.MkdirAll(b.path, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
s.backends.CompareAndDelete(identity, b)
return false, &hst.AppError{Step: "create store segment directory", Err: err}
}
// open locker file
if l, err := os.OpenFile(b.path+".lock", os.O_RDWR|os.O_CREATE, 0600); err != nil {
s.backends.CompareAndDelete(identity, b)
return false, &hst.AppError{Step: "open store segment lock file", Err: err}
} else {
b.lockfile = l
}
b.mu.Unlock()
}
// lock backend
if err := b.lockFile(); err != nil {
return false, &hst.AppError{Step: "lock store segment", Err: err}
}
// expose backend methods without exporting the pointer
c := new(struct{ *multiBackend })
c.multiBackend = b
f(b)
// disable access to the backend on a best-effort basis
c.multiBackend = nil
// unlock backend
if err := b.unlockFile(); err != nil {
return true, &hst.AppError{Step: "unlock store segment", Err: err}
}
return true, nil
}
func (s *multiStore) List() ([]int, error) {
var entries []os.DirEntry
// read base directory to get all identities
if v, err := os.ReadDir(s.base); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, &hst.AppError{Step: "read store directory", Err: err}
} else {
entries = v
}
aidsBuf := make([]int, 0, len(entries))
for _, e := range entries {
// skip non-directories
if !e.IsDir() {
s.msg.Verbosef("skipped non-directory entry %q", e.Name())
continue
}
// skip non-numerical names
if v, err := strconv.Atoi(e.Name()); err != nil {
s.msg.Verbosef("skipped non-aid entry %q", e.Name())
continue
} else {
if v < hst.IdentityMin || v > hst.IdentityMax {
s.msg.Verbosef("skipped out of bounds entry %q", e.Name())
continue
}
aidsBuf = append(aidsBuf, v)
}
}
return append([]int(nil), aidsBuf...), nil
}
func (s *multiStore) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
var errs []error
s.backends.Range(func(_, value any) bool {
b := value.(*multiBackend)
errs = append(errs, b.close())
return true
})
return errors.Join(errs...)
}
type multiBackend struct {
path string
// created/opened by prepare
lockfile *os.File
mu sync.RWMutex
}
func (b *multiBackend) filename(id *ID) string { return path.Join(b.path, id.String()) }
func (b *multiBackend) lockFileAct(lt int) (err error) {
op := "LockAct"
switch lt {
case syscall.LOCK_EX:
op = "Lock"
case syscall.LOCK_UN:
op = "Unlock"
}
for {
err = syscall.Flock(int(b.lockfile.Fd()), lt)
if !errors.Is(err, syscall.EINTR) {
break
}
}
if err != nil {
return &fs.PathError{
Op: op,
Path: b.lockfile.Name(),
Err: err,
}
}
return nil
}
func (b *multiBackend) lockFile() error { return b.lockFileAct(syscall.LOCK_EX) }
func (b *multiBackend) unlockFile() error { return b.lockFileAct(syscall.LOCK_UN) }
// reads all launchers in simpleBackend
// file contents are ignored if decode is false
func (b *multiBackend) load(decode bool) (Entries, error) {
b.mu.RLock()
defer b.mu.RUnlock()
// read directory contents, should only contain files named after ids
var entries []os.DirEntry
if pl, err := os.ReadDir(b.path); err != nil {
return nil, &hst.AppError{Step: "read store segment directory", Err: err}
} else {
entries = pl
}
// allocate as if every entry is valid
// since that should be the case assuming no external interference happens
r := make(Entries, len(entries))
for _, e := range entries {
if e.IsDir() {
return nil, fmt.Errorf("unexpected directory %q in store", e.Name())
}
var id ID
if err := ParseAppID(&id, e.Name()); err != nil {
return nil, &hst.AppError{Step: "parse state key", Err: err}
}
// run in a function to better handle file closing
if err := func() error {
// open state file for reading
if f, err := os.Open(path.Join(b.path, e.Name())); err != nil {
return &hst.AppError{Step: "open state file", Err: err}
} else {
var s State
r[id] = &s
// append regardless, but only parse if required, implements Len
if decode {
if err = gob.NewDecoder(f).Decode(&s); err != nil {
_ = f.Close()
return &hst.AppError{Step: "decode state data", Err: err}
} else if s.ID != id {
_ = f.Close()
return fmt.Errorf("state entry %s has unexpected id %s", id, &s.ID)
} else if err = f.Close(); err != nil {
return &hst.AppError{Step: "close state file", Err: err}
}
if s.Config == nil {
return ErrNoConfig
}
}
return nil
}
}(); err != nil {
return nil, err
}
}
return r, nil
}
// Save writes process state to filesystem
func (b *multiBackend) Save(state *State) error {
b.mu.Lock()
defer b.mu.Unlock()
if state.Config == nil {
return ErrNoConfig
}
statePath := b.filename(&state.ID)
if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil {
return &hst.AppError{Step: "create state file", Err: err}
} else if err = gob.NewEncoder(f).Encode(state); err != nil {
_ = f.Close()
return &hst.AppError{Step: "encode state data", Err: err}
} else if err = f.Close(); err != nil {
return &hst.AppError{Step: "close state file", Err: err}
}
return nil
}
func (b *multiBackend) Destroy(id ID) error {
b.mu.Lock()
defer b.mu.Unlock()
if err := os.Remove(b.filename(&id)); err != nil {
return &hst.AppError{Step: "destroy state entry", Err: err}
}
return nil
}
func (b *multiBackend) Load() (Entries, error) { return b.load(true) }
func (b *multiBackend) Len() (int, error) {
// rn consists of only nil entries but has the correct length
rn, err := b.load(false)
if err != nil {
return -1, &hst.AppError{Step: "count state entries", Err: err}
}
return len(rn), nil
}
func (b *multiBackend) close() error {
b.mu.Lock()
defer b.mu.Unlock()
err := b.lockfile.Close()
if err == nil || errors.Is(err, os.ErrInvalid) || errors.Is(err, os.ErrClosed) {
return nil
}
return &hst.AppError{Step: "close lock file", Err: err}
}
// NewMulti returns an instance of the multi-file store.
func NewMulti(msg message.Msg, runDir string) Store {
return &multiStore{
msg: msg,
base: path.Join(runDir, "state"),
backends: new(sync.Map),
}
}

View File

@@ -1,13 +0,0 @@
package state_test
import (
"log"
"testing"
"hakurei.app/internal/app/state"
"hakurei.app/message"
)
func TestMulti(t *testing.T) {
testStore(t, state.NewMulti(message.NewMsg(log.New(log.Writer(), "multi: ", 0)), t.TempDir()))
}

View File

@@ -1,49 +0,0 @@
// Package state provides cross-process state tracking for hakurei container instances.
package state
import (
"errors"
"time"
"hakurei.app/hst"
)
// ErrNoConfig is returned by [Cursor] when used with a nil [hst.Config].
var ErrNoConfig = errors.New("state does not contain config")
type Entries map[ID]*State
type Store interface {
// Do calls f exactly once and ensures store exclusivity until f returns.
// Returns whether f is called and any errors during the locking process.
// Cursor provided to f becomes invalid as soon as f returns.
Do(identity int, f func(c Cursor)) (ok bool, err error)
// List queries the store and returns a list of identities known to the store.
// Note that some or all returned identities might not have any active apps.
List() (identities []int, err error)
// Close releases any resources held by Store.
Close() error
}
// Cursor provides access to the store of an identity.
type Cursor interface {
Save(state *State) error
Destroy(id ID) error
Load() (Entries, error)
Len() (int, error)
}
// State is the on-disk state of a container instance.
type State struct {
// Unique instance id, generated by internal/app.
ID ID `json:"instance"`
// Shim process pid. This runs as the target user.
PID int `json:"pid"`
// Configuration value used to start the container.
Config *hst.Config `json:"config"`
// Exact point in time that the shim process was created.
Time time.Time `json:"time"`
}

View File

@@ -1,132 +0,0 @@
package state_test
import (
"math/rand/v2"
"reflect"
"slices"
"testing"
"time"
"hakurei.app/hst"
"hakurei.app/internal/app/state"
)
func testStore(t *testing.T, s state.Store) {
t.Run("list empty store", func(t *testing.T) {
if identities, err := s.List(); err != nil {
t.Fatalf("List: error = %v", err)
} else if len(identities) != 0 {
t.Fatalf("List: identities = %#v", identities)
}
})
const (
insertEntryChecked = iota
insertEntryNoCheck
insertEntryOtherApp
tl
)
var tc [tl]state.State
for i := 0; i < tl; i++ {
makeState(t, &tc[i])
}
do := func(identity int, f func(c state.Cursor)) {
if ok, err := s.Do(identity, f); err != nil {
t.Fatalf("Do: ok = %v, error = %v", ok, err)
}
}
insert := func(i, identity int) {
do(identity, func(c state.Cursor) {
if err := c.Save(&tc[i]); err != nil {
t.Fatalf("Save: error = %v", err)
}
})
}
check := func(i, identity int) {
do(identity, func(c state.Cursor) {
if entries, err := c.Load(); err != nil {
t.Fatalf("Load: error = %v", err)
} else if got, ok := entries[tc[i].ID]; !ok {
t.Fatalf("Load: entry %s missing", &tc[i].ID)
} else {
got.Time = tc[i].Time
if !reflect.DeepEqual(got, &tc[i]) {
t.Fatalf("Load: entry %s got %#v, want %#v", &tc[i].ID, got, &tc[i])
}
}
})
}
t.Run("insert entry checked", func(t *testing.T) {
insert(insertEntryChecked, 0)
check(insertEntryChecked, 0)
})
t.Run("insert entry unchecked", func(t *testing.T) {
insert(insertEntryNoCheck, 0)
})
t.Run("insert entry different identity", func(t *testing.T) {
insert(insertEntryOtherApp, 1)
check(insertEntryOtherApp, 1)
})
t.Run("check previous insertion", func(t *testing.T) {
check(insertEntryNoCheck, 0)
})
t.Run("list identities", func(t *testing.T) {
if identities, err := s.List(); err != nil {
t.Fatalf("List: error = %v", err)
} else {
slices.Sort(identities)
want := []int{0, 1}
if !slices.Equal(identities, want) {
t.Fatalf("List() = %#v, want %#v", identities, want)
}
}
})
t.Run("join store", func(t *testing.T) {
if entries, err := state.Join(s); err != nil {
t.Fatalf("Join: error = %v", err)
} else if len(entries) != 3 {
t.Fatalf("Join(s) = %#v", entries)
}
})
t.Run("clear identity 1", func(t *testing.T) {
do(1, func(c state.Cursor) {
if err := c.Destroy(tc[insertEntryOtherApp].ID); err != nil {
t.Fatalf("Destroy: error = %v", err)
}
})
do(1, func(c state.Cursor) {
if l, err := c.Len(); err != nil {
t.Fatalf("Len: error = %v", err)
} else if l != 0 {
t.Fatalf("Len: %d, want 0", l)
}
})
})
t.Run("close store", func(t *testing.T) {
if err := s.Close(); err != nil {
t.Fatalf("Close: error = %v", err)
}
})
}
func makeState(t *testing.T, s *state.State) {
if err := state.NewAppID(&s.ID); err != nil {
t.Fatalf("cannot create dummy state: %v", err)
}
s.PID = rand.Int()
s.Config = hst.Template()
s.Time = time.Now()
}

View File

@@ -1,8 +0,0 @@
package app
//#include <unistd.h>
import "C"
const _SC_LOGIN_NAME_MAX = C._SC_LOGIN_NAME_MAX
func sysconf(name C.int) int { return int(C.sysconf(name)) }

View File

@@ -1,28 +0,0 @@
package app
import (
"strings"
"testing"
)
func TestIsValidUsername(t *testing.T) {
t.Parallel()
t.Run("long", func(t *testing.T) {
if isValidUsername(strings.Repeat("a", sysconf(_SC_LOGIN_NAME_MAX))) {
t.Errorf("isValidUsername unexpected true")
}
})
t.Run("regexp", func(t *testing.T) {
if isValidUsername("0") {
t.Errorf("isValidUsername unexpected true")
}
})
t.Run("valid", func(t *testing.T) {
if !isValidUsername("alice") {
t.Errorf("isValidUsername unexpected false")
}
})
}

66
internal/env/env.go vendored Normal file
View File

@@ -0,0 +1,66 @@
// Package env provides the [Paths] struct for efficiently building paths from the environment.
package env
import (
"log"
"os"
"strconv"
"hakurei.app/container/check"
"hakurei.app/hst"
)
// Paths holds paths copied from the environment and is used to create [hst.Paths].
type Paths struct {
// TempDir is returned by [os.TempDir].
TempDir *check.Absolute
// RuntimePath is copied from $XDG_RUNTIME_DIR.
RuntimePath *check.Absolute
}
// Copy expands [Paths] into [hst.Paths].
func (env *Paths) Copy(v *hst.Paths, userid int) {
if env == nil || env.TempDir == nil || v == nil {
panic("attempting to use an invalid Paths")
}
v.TempDir = env.TempDir
v.SharePath = env.TempDir.Append("hakurei." + strconv.Itoa(userid))
if env.RuntimePath == nil {
// fall back to path in share since hakurei has no hard XDG dependency
v.RunDirPath = v.SharePath.Append("run")
v.RuntimePath = v.RunDirPath.Append("compat")
} else {
v.RuntimePath = env.RuntimePath
v.RunDirPath = env.RuntimePath.Append("hakurei")
}
}
// CopyPaths returns a populated [Paths].
func CopyPaths() *Paths { return CopyPathsFunc(log.Fatalf, os.TempDir, os.Getenv) }
// CopyPathsFunc returns a populated [Paths],
// using the provided [log.Fatalf], [os.TempDir], [os.Getenv] functions.
func CopyPathsFunc(
fatalf func(format string, v ...any),
tempdir func() string,
getenv func(key string) string,
) *Paths {
const xdgRuntimeDir = "XDG_RUNTIME_DIR"
var env Paths
if tempDir, err := check.NewAbs(tempdir()); err != nil {
fatalf("invalid TMPDIR: %v", err)
panic("unreachable")
} else {
env.TempDir = tempDir
}
if a, err := check.NewAbs(getenv(xdgRuntimeDir)); err == nil {
env.RuntimePath = a
}
return &env
}

View File

@@ -1,4 +1,4 @@
package app
package env_test
import (
"fmt"
@@ -10,26 +10,27 @@ import (
"hakurei.app/container/fhs"
"hakurei.app/container/stub"
"hakurei.app/hst"
"hakurei.app/internal/env"
)
func TestEnvPaths(t *testing.T) {
func TestPaths(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
env *EnvPaths
env *env.Paths
want hst.Paths
wantPanic string
}{
{"nil", nil, hst.Paths{}, "attempting to use an invalid EnvPaths"},
{"zero", new(EnvPaths), hst.Paths{}, "attempting to use an invalid EnvPaths"},
{"nil", nil, hst.Paths{}, "attempting to use an invalid Paths"},
{"zero", new(env.Paths), hst.Paths{}, "attempting to use an invalid Paths"},
{"nil tempdir", &EnvPaths{
{"nil tempdir", &env.Paths{
RuntimePath: fhs.AbsTmp,
}, hst.Paths{}, "attempting to use an invalid EnvPaths"},
}, hst.Paths{}, "attempting to use an invalid Paths"},
{"nil runtime", &EnvPaths{
{"nil runtime", &env.Paths{
TempDir: fhs.AbsTmp,
}, hst.Paths{
TempDir: fhs.AbsTmp,
@@ -38,7 +39,7 @@ func TestEnvPaths(t *testing.T) {
RunDirPath: fhs.AbsTmp.Append("hakurei.3735928559/run"),
}, ""},
{"full", &EnvPaths{
{"full", &env.Paths{
TempDir: fhs.AbsTmp,
RuntimePath: fhs.AbsRunUser.Append("1000"),
}, hst.Paths{
@@ -76,16 +77,16 @@ func TestCopyPaths(t *testing.T) {
env map[string]string
tmp string
fatal string
want EnvPaths
want env.Paths
}{
{"invalid tempdir", nil, "\x00",
"invalid TMPDIR: path \"\\x00\" is not absolute", EnvPaths{}},
"invalid TMPDIR: path \"\\x00\" is not absolute", env.Paths{}},
{"empty environment", make(map[string]string), container.Nonexistent,
"", EnvPaths{TempDir: check.MustAbs(container.Nonexistent)}},
"", env.Paths{TempDir: check.MustAbs(container.Nonexistent)}},
{"invalid XDG_RUNTIME_DIR", map[string]string{"XDG_RUNTIME_DIR": "\x00"}, container.Nonexistent,
"", EnvPaths{TempDir: check.MustAbs(container.Nonexistent)}},
"", env.Paths{TempDir: check.MustAbs(container.Nonexistent)}},
{"full", map[string]string{"XDG_RUNTIME_DIR": "/\x00"}, container.Nonexistent,
"", EnvPaths{TempDir: check.MustAbs(container.Nonexistent), RuntimePath: check.MustAbs("/\x00")}},
"", env.Paths{TempDir: check.MustAbs(container.Nonexistent), RuntimePath: check.MustAbs("/\x00")}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
@@ -94,8 +95,16 @@ func TestCopyPaths(t *testing.T) {
defer stub.HandleExit(t)
}
k := copyPathsDispatcher{t: t, env: tc.env, tmp: tc.tmp, expectsFatal: tc.fatal}
got := copyPaths(k)
got := env.CopyPathsFunc(func(format string, v ...any) {
if tc.fatal == "" {
t.Fatalf("unexpected call to fatalf: format = %q, v = %#v", format, v)
}
if got := fmt.Sprintf(format, v...); got != tc.fatal {
t.Fatalf("fatalf: %q, want %q", got, tc.fatal)
}
panic(stub.PanicExit)
}, func() string { return tc.tmp }, func(key string) string { return tc.env[key] })
if tc.fatal != "" {
t.Fatalf("copyPaths: expected fatal %q", tc.fatal)
@@ -107,31 +116,3 @@ func TestCopyPaths(t *testing.T) {
})
}
}
// copyPathsDispatcher implements enough of syscallDispatcher for all copyPaths code paths.
type copyPathsDispatcher struct {
env map[string]string
tmp string
// must be checked at the conclusion of the test
expectsFatal string
t *testing.T
panicDispatcher
}
func (k copyPathsDispatcher) tempdir() string { return k.tmp }
func (k copyPathsDispatcher) lookupEnv(key string) (value string, ok bool) {
value, ok = k.env[key]
return
}
func (k copyPathsDispatcher) fatalf(format string, v ...any) {
if k.expectsFatal == "" {
k.t.Fatalf("unexpected call to fatalf: format = %q, v = %#v", format, v)
}
if got := fmt.Sprintf(format, v...); got != k.expectsFatal {
k.t.Fatalf("fatalf: %q, want %q", got, k.expectsFatal)
}
panic(stub.PanicExit)
}

View File

@@ -0,0 +1,83 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package filelock provides a platform-independent API for advisory file
// locking. Calls to functions in this package on platforms that do not support
// advisory locks will return errors for which IsNotSupported returns true.
package filelock
import (
"errors"
"io/fs"
)
// A File provides the minimal set of methods required to lock an open file.
// File implementations must be usable as map keys.
// The usual implementation is *os.File.
type File interface {
// Name returns the name of the file.
Name() string
// Fd returns a valid file descriptor.
// (If the File is an *os.File, it must not be closed.)
Fd() uintptr
// Stat returns the FileInfo structure describing file.
Stat() (fs.FileInfo, error)
}
// Lock places an advisory write lock on the file, blocking until it can be
// locked.
//
// If Lock returns nil, no other process will be able to place a read or write
// lock on the file until this process exits, closes f, or calls Unlock on it.
//
// If f's descriptor is already read- or write-locked, the behavior of Lock is
// unspecified.
//
// Closing the file may or may not release the lock promptly. Callers should
// ensure that Unlock is always called when Lock succeeds.
func Lock(f File) error {
return lock(f, writeLock)
}
// RLock places an advisory read lock on the file, blocking until it can be locked.
//
// If RLock returns nil, no other process will be able to place a write lock on
// the file until this process exits, closes f, or calls Unlock on it.
//
// If f is already read- or write-locked, the behavior of RLock is unspecified.
//
// Closing the file may or may not release the lock promptly. Callers should
// ensure that Unlock is always called if RLock succeeds.
func RLock(f File) error {
return lock(f, readLock)
}
// Unlock removes an advisory lock placed on f by this process.
//
// The caller must not attempt to unlock a file that is not locked.
func Unlock(f File) error {
return unlock(f)
}
// String returns the name of the function corresponding to lt
// (Lock, RLock, or Unlock).
func (lt lockType) String() string {
switch lt {
case readLock:
return "RLock"
case writeLock:
return "Lock"
default:
return "Unlock"
}
}
// IsNotSupported returns a boolean indicating whether the error is known to
// report that a function is not supported (possibly for a specific input).
// It is satisfied by errors.ErrUnsupported as well as some syscall errors.
func IsNotSupported(err error) bool {
return errors.Is(err, errors.ErrUnsupported)
}

View File

@@ -0,0 +1,210 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build aix || (solaris && !illumos)
// This code implements the filelock API using POSIX 'fcntl' locks, which attach
// to an (inode, process) pair rather than a file descriptor. To avoid unlocking
// files prematurely when the same file is opened through different descriptors,
// we allow only one read-lock at a time.
//
// Most platforms provide some alternative API, such as an 'flock' system call
// or an F_OFD_SETLK command for 'fcntl', that allows for better concurrency and
// does not require per-inode bookkeeping in the application.
package filelock
import (
"errors"
"io"
"io/fs"
"math/rand"
"sync"
"syscall"
"time"
)
type lockType int16
const (
readLock lockType = syscall.F_RDLCK
writeLock lockType = syscall.F_WRLCK
)
type inode = uint64 // type of syscall.Stat_t.Ino
type inodeLock struct {
owner File
queue []<-chan File
}
var (
mu sync.Mutex
inodes = map[File]inode{}
locks = map[inode]inodeLock{}
)
func lock(f File, lt lockType) (err error) {
// POSIX locks apply per inode and process, and the lock for an inode is
// released when *any* descriptor for that inode is closed. So we need to
// synchronize access to each inode internally, and must serialize lock and
// unlock calls that refer to the same inode through different descriptors.
fi, err := f.Stat()
if err != nil {
return err
}
ino := fi.Sys().(*syscall.Stat_t).Ino
mu.Lock()
if i, dup := inodes[f]; dup && i != ino {
mu.Unlock()
return &fs.PathError{
Op: lt.String(),
Path: f.Name(),
Err: errors.New("inode for file changed since last Lock or RLock"),
}
}
inodes[f] = ino
var wait chan File
l := locks[ino]
if l.owner == f {
// This file already owns the lock, but the call may change its lock type.
} else if l.owner == nil {
// No owner: it's ours now.
l.owner = f
} else {
// Already owned: add a channel to wait on.
wait = make(chan File)
l.queue = append(l.queue, wait)
}
locks[ino] = l
mu.Unlock()
if wait != nil {
wait <- f
}
// Spurious EDEADLK errors arise on platforms that compute deadlock graphs at
// the process, rather than thread, level. Consider processes P and Q, with
// threads P.1, P.2, and Q.3. The following trace is NOT a deadlock, but will be
// reported as a deadlock on systems that consider only process granularity:
//
// P.1 locks file A.
// Q.3 locks file B.
// Q.3 blocks on file A.
// P.2 blocks on file B. (This is erroneously reported as a deadlock.)
// P.1 unlocks file A.
// Q.3 unblocks and locks file A.
// Q.3 unlocks files A and B.
// P.2 unblocks and locks file B.
// P.2 unlocks file B.
//
// These spurious errors were observed in practice on AIX and Solaris in
// cmd/go: see https://golang.org/issue/32817.
//
// We work around this bug by treating EDEADLK as always spurious. If there
// really is a lock-ordering bug between the interacting processes, it will
// become a livelock instead, but that's not appreciably worse than if we had
// a proper flock implementation (which generally does not even attempt to
// diagnose deadlocks).
//
// In the above example, that changes the trace to:
//
// P.1 locks file A.
// Q.3 locks file B.
// Q.3 blocks on file A.
// P.2 spuriously fails to lock file B and goes to sleep.
// P.1 unlocks file A.
// Q.3 unblocks and locks file A.
// Q.3 unlocks files A and B.
// P.2 wakes up and locks file B.
// P.2 unlocks file B.
//
// We know that the retry loop will not introduce a *spurious* livelock
// because, according to the POSIX specification, EDEADLK is only to be
// returned when “the lock is blocked by a lock from another process”.
// If that process is blocked on some lock that we are holding, then the
// resulting livelock is due to a real deadlock (and would manifest as such
// when using, for example, the flock implementation of this package).
// If the other process is *not* blocked on some other lock that we are
// holding, then it will eventually release the requested lock.
nextSleep := 1 * time.Millisecond
const maxSleep = 500 * time.Millisecond
for {
err = setlkw(f.Fd(), lt)
if err != syscall.EDEADLK {
break
}
time.Sleep(nextSleep)
nextSleep += nextSleep
if nextSleep > maxSleep {
nextSleep = maxSleep
}
// Apply 10% jitter to avoid synchronizing collisions when we finally unblock.
nextSleep += time.Duration((0.1*rand.Float64() - 0.05) * float64(nextSleep))
}
if err != nil {
unlock(f)
return &fs.PathError{
Op: lt.String(),
Path: f.Name(),
Err: err,
}
}
return nil
}
func unlock(f File) error {
var owner File
mu.Lock()
ino, ok := inodes[f]
if ok {
owner = locks[ino].owner
}
mu.Unlock()
if owner != f {
panic("unlock called on a file that is not locked")
}
err := setlkw(f.Fd(), syscall.F_UNLCK)
mu.Lock()
l := locks[ino]
if len(l.queue) == 0 {
// No waiters: remove the map entry.
delete(locks, ino)
} else {
// The first waiter is sending us their file now.
// Receive it and update the queue.
l.owner = <-l.queue[0]
l.queue = l.queue[1:]
locks[ino] = l
}
delete(inodes, f)
mu.Unlock()
return err
}
// setlkw calls FcntlFlock with F_SETLKW for the entire file indicated by fd.
func setlkw(fd uintptr, lt lockType) error {
for {
err := syscall.FcntlFlock(fd, syscall.F_SETLKW, &syscall.Flock_t{
Type: int16(lt),
Whence: io.SeekStart,
Start: 0,
Len: 0, // All bytes.
})
if err != syscall.EINTR {
return err
}
}
}

View File

@@ -0,0 +1,35 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !unix && !windows
package filelock
import (
"errors"
"io/fs"
)
type lockType int8
const (
readLock = iota + 1
writeLock
)
func lock(f File, lt lockType) error {
return &fs.PathError{
Op: lt.String(),
Path: f.Name(),
Err: errors.ErrUnsupported,
}
}
func unlock(f File) error {
return &fs.PathError{
Op: "Unlock",
Path: f.Name(),
Err: errors.ErrUnsupported,
}
}

View File

@@ -0,0 +1,209 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !js && !plan9 && !wasip1
package filelock_test
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"hakurei.app/container"
"hakurei.app/internal/lockedfile/internal/filelock"
"hakurei.app/internal/lockedfile/internal/testexec"
)
func lock(t *testing.T, f *os.File) {
t.Helper()
err := filelock.Lock(f)
t.Logf("Lock(fd %d) = %v", f.Fd(), err)
if err != nil {
t.Fail()
}
}
func rLock(t *testing.T, f *os.File) {
t.Helper()
err := filelock.RLock(f)
t.Logf("RLock(fd %d) = %v", f.Fd(), err)
if err != nil {
t.Fail()
}
}
func unlock(t *testing.T, f *os.File) {
t.Helper()
err := filelock.Unlock(f)
t.Logf("Unlock(fd %d) = %v", f.Fd(), err)
if err != nil {
t.Fail()
}
}
func mustTempFile(t *testing.T) (f *os.File, remove func()) {
t.Helper()
base := filepath.Base(t.Name())
f, err := os.CreateTemp("", base)
if err != nil {
t.Fatalf(`os.CreateTemp("", %q) = %v`, base, err)
}
t.Logf("fd %d = %s", f.Fd(), f.Name())
return f, func() {
f.Close()
os.Remove(f.Name())
}
}
func mustOpen(t *testing.T, name string) *os.File {
t.Helper()
f, err := os.OpenFile(name, os.O_RDWR, 0)
if err != nil {
t.Fatalf("os.OpenFile(%q) = %v", name, err)
}
t.Logf("fd %d = os.OpenFile(%q)", f.Fd(), name)
return f
}
const (
quiescent = 10 * time.Millisecond
probablyStillBlocked = 10 * time.Second
)
func mustBlock(t *testing.T, op string, f *os.File) (wait func(*testing.T)) {
t.Helper()
desc := fmt.Sprintf("%s(fd %d)", op, f.Fd())
done := make(chan struct{})
go func() {
t.Helper()
switch op {
case "Lock":
lock(t, f)
case "RLock":
rLock(t, f)
default:
panic("invalid op: " + op)
}
close(done)
}()
select {
case <-done:
t.Fatalf("%s unexpectedly did not block", desc)
return nil
case <-time.After(quiescent):
t.Logf("%s is blocked (as expected)", desc)
return func(t *testing.T) {
t.Helper()
select {
case <-time.After(probablyStillBlocked):
t.Fatalf("%s is unexpectedly still blocked", desc)
case <-done:
}
}
}
}
func TestLockExcludesLock(t *testing.T) {
t.Parallel()
f, remove := mustTempFile(t)
defer remove()
other := mustOpen(t, f.Name())
defer other.Close()
lock(t, f)
lockOther := mustBlock(t, "Lock", other)
unlock(t, f)
lockOther(t)
unlock(t, other)
}
func TestLockExcludesRLock(t *testing.T) {
t.Parallel()
f, remove := mustTempFile(t)
defer remove()
other := mustOpen(t, f.Name())
defer other.Close()
lock(t, f)
rLockOther := mustBlock(t, "RLock", other)
unlock(t, f)
rLockOther(t)
unlock(t, other)
}
func TestRLockExcludesOnlyLock(t *testing.T) {
t.Parallel()
f, remove := mustTempFile(t)
defer remove()
rLock(t, f)
f2 := mustOpen(t, f.Name())
defer f2.Close()
doUnlockTF := false
switch runtime.GOOS {
case "aix", "solaris":
// When using POSIX locks (as on Solaris), we can't safely read-lock the
// same inode through two different descriptors at the same time: when the
// first descriptor is closed, the second descriptor would still be open but
// silently unlocked. So a second RLock must block instead of proceeding.
lockF2 := mustBlock(t, "RLock", f2)
unlock(t, f)
lockF2(t)
default:
rLock(t, f2)
doUnlockTF = true
}
other := mustOpen(t, f.Name())
defer other.Close()
lockOther := mustBlock(t, "Lock", other)
unlock(t, f2)
if doUnlockTF {
unlock(t, f)
}
lockOther(t)
unlock(t, other)
}
func TestLockNotDroppedByExecCommand(t *testing.T) {
f, remove := mustTempFile(t)
defer remove()
lock(t, f)
other := mustOpen(t, f.Name())
defer other.Close()
// Some kinds of file locks are dropped when a duplicated or forked file
// descriptor is unlocked. Double-check that the approach used by os/exec does
// not accidentally drop locks.
cmd := testexec.CommandContext(t, t.Context(), container.MustExecutable(nil), "-test.run=^$")
if err := cmd.Run(); err != nil {
t.Fatalf("exec failed: %v", err)
}
lockOther := mustBlock(t, "Lock", other)
unlock(t, f)
lockOther(t)
unlock(t, other)
}

View File

@@ -0,0 +1,40 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd
package filelock
import (
"io/fs"
"syscall"
)
type lockType int16
const (
readLock lockType = syscall.LOCK_SH
writeLock lockType = syscall.LOCK_EX
)
func lock(f File, lt lockType) (err error) {
for {
err = syscall.Flock(int(f.Fd()), int(lt))
if err != syscall.EINTR {
break
}
}
if err != nil {
return &fs.PathError{
Op: lt.String(),
Path: f.Name(),
Err: err,
}
}
return nil
}
func unlock(f File) error {
return lock(f, syscall.LOCK_UN)
}

View File

@@ -0,0 +1,57 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build windows
package filelock
import (
"internal/syscall/windows"
"io/fs"
"syscall"
)
type lockType uint32
const (
readLock lockType = 0
writeLock lockType = windows.LOCKFILE_EXCLUSIVE_LOCK
)
const (
reserved = 0
allBytes = ^uint32(0)
)
func lock(f File, lt lockType) error {
// Per https://golang.org/issue/19098, “Programs currently expect the Fd
// method to return a handle that uses ordinary synchronous I/O.”
// However, LockFileEx still requires an OVERLAPPED structure,
// which contains the file offset of the beginning of the lock range.
// We want to lock the entire file, so we leave the offset as zero.
ol := new(syscall.Overlapped)
err := windows.LockFileEx(syscall.Handle(f.Fd()), uint32(lt), reserved, allBytes, allBytes, ol)
if err != nil {
return &fs.PathError{
Op: lt.String(),
Path: f.Name(),
Err: err,
}
}
return nil
}
func unlock(f File) error {
ol := new(syscall.Overlapped)
err := windows.UnlockFileEx(syscall.Handle(f.Fd()), reserved, allBytes, allBytes, ol)
if err != nil {
return &fs.PathError{
Op: "Unlock",
Path: f.Name(),
Err: err,
}
}
return nil
}

View File

@@ -0,0 +1,43 @@
package testexec
import (
"context"
"os/exec"
"syscall"
"testing"
)
// CommandContext is like exec.CommandContext, but:
// - sends SIGQUIT instead of SIGKILL in its Cancel function
// - fails the test if the command does not complete before the context is canceled, and
// - sets a Cleanup function that verifies that the test did not leak a subprocess.
func CommandContext(t testing.TB, ctx context.Context, name string, args ...string) *exec.Cmd {
t.Helper()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Cancel = func() error {
if ctx.Err() == context.DeadlineExceeded {
// The command timed out due to running too close to the test's deadline.
// There is no way the test did that intentionally — it's too close to the
// wire! — so mark it as a test failure. That way, if the test expects the
// command to fail for some other reason, it doesn't have to distinguish
// between that reason and a timeout.
t.Errorf("test timed out while running command: %v", cmd)
} else {
// The command is being terminated due to ctx being canceled, but
// apparently not due to an explicit test deadline that we added.
// Log that information in case it is useful for diagnosing a failure,
// but don't actually fail the test because of it.
t.Logf("%v: terminating command: %v", ctx.Err(), cmd)
}
return cmd.Process.Signal(syscall.SIGQUIT)
}
t.Cleanup(func() {
if cmd.Process != nil && cmd.ProcessState == nil {
t.Errorf("command was started, but test did not wait for it to complete: %v", cmd)
}
})
return cmd
}

View File

@@ -0,0 +1,189 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package lockedfile creates and manipulates files whose contents should only
// change atomically.
package lockedfile
import (
"fmt"
"io"
"io/fs"
"os"
"runtime"
)
// A File is a locked *os.File.
//
// Closing the file releases the lock.
//
// If the program exits while a file is locked, the operating system releases
// the lock but may not do so promptly: callers must ensure that all locked
// files are closed before exiting.
type File struct {
osFile
closed bool
// cleanup panics when the file is no longer referenced and it has not been closed.
cleanup runtime.Cleanup
}
// osFile embeds a *os.File while keeping the pointer itself unexported.
// (When we close a File, it must be the same file descriptor that we opened!)
type osFile struct {
*os.File
}
// OpenFile is like os.OpenFile, but returns a locked file.
// If flag includes os.O_WRONLY or os.O_RDWR, the file is write-locked;
// otherwise, it is read-locked.
func OpenFile(name string, flag int, perm fs.FileMode) (*File, error) {
var (
f = new(File)
err error
)
f.osFile.File, err = openFile(name, flag, perm)
if err != nil {
return nil, err
}
// Although the operating system will drop locks for open files when the go
// command exits, we want to hold locks for as little time as possible, and we
// especially don't want to leave a file locked after we're done with it. Our
// Close method is what releases the locks, so use a cleanup to report
// missing Close calls on a best-effort basis.
f.cleanup = runtime.AddCleanup(f, func(fileName string) {
panic(fmt.Sprintf("lockedfile.File %s became unreachable without a call to Close", fileName))
}, f.Name())
return f, nil
}
// Open is like os.Open, but returns a read-locked file.
func Open(name string) (*File, error) {
return OpenFile(name, os.O_RDONLY, 0)
}
// Create is like os.Create, but returns a write-locked file.
func Create(name string) (*File, error) {
return OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
}
// Edit creates the named file with mode 0666 (before umask),
// but does not truncate existing contents.
//
// If Edit succeeds, methods on the returned File can be used for I/O.
// The associated file descriptor has mode O_RDWR and the file is write-locked.
func Edit(name string) (*File, error) {
return OpenFile(name, os.O_RDWR|os.O_CREATE, 0666)
}
// Close unlocks and closes the underlying file.
//
// Close may be called multiple times; all calls after the first will return a
// non-nil error.
func (f *File) Close() error {
if f.closed {
return &fs.PathError{
Op: "close",
Path: f.Name(),
Err: fs.ErrClosed,
}
}
f.closed = true
err := closeFile(f.osFile.File)
f.cleanup.Stop()
return err
}
// Read opens the named file with a read-lock and returns its contents.
func Read(name string) ([]byte, error) {
f, err := Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
// Write opens the named file (creating it with the given permissions if needed),
// then write-locks it and overwrites it with the given content.
func Write(name string, content io.Reader, perm fs.FileMode) (err error) {
f, err := OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
_, err = io.Copy(f, content)
if closeErr := f.Close(); err == nil {
err = closeErr
}
return err
}
// Transform invokes t with the result of reading the named file, with its lock
// still held.
//
// If t returns a nil error, Transform then writes the returned contents back to
// the file, making a best effort to preserve existing contents on error.
//
// t must not modify the slice passed to it.
func Transform(name string, t func([]byte) ([]byte, error)) (err error) {
f, err := Edit(name)
if err != nil {
return err
}
defer f.Close()
old, err := io.ReadAll(f)
if err != nil {
return err
}
new, err := t(old)
if err != nil {
return err
}
if len(new) > len(old) {
// The overall file size is increasing, so write the tail first: if we're
// about to run out of space on the disk, we would rather detect that
// failure before we have overwritten the original contents.
if _, err := f.WriteAt(new[len(old):], int64(len(old))); err != nil {
// Make a best effort to remove the incomplete tail.
f.Truncate(int64(len(old)))
return err
}
}
// We're about to overwrite the old contents. In case of failure, make a best
// effort to roll back before we close the file.
defer func() {
if err != nil {
if _, err := f.WriteAt(old, 0); err == nil {
f.Truncate(int64(len(old)))
}
}
}()
if len(new) >= len(old) {
if _, err := f.WriteAt(new[:len(old)], 0); err != nil {
return err
}
} else {
if _, err := f.WriteAt(new, 0); err != nil {
return err
}
// The overall file size is decreasing, so shrink the file to its final size
// after writing. We do this after writing (instead of before) so that if
// the write fails, enough filesystem space will likely still be reserved
// to contain the previous contents.
if err := f.Truncate(int64(len(new))); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,65 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !plan9
package lockedfile
import (
"io/fs"
"os"
"hakurei.app/internal/lockedfile/internal/filelock"
)
func openFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
// On BSD systems, we could add the O_SHLOCK or O_EXLOCK flag to the OpenFile
// call instead of locking separately, but we have to support separate locking
// calls for Linux and Windows anyway, so it's simpler to use that approach
// consistently.
f, err := os.OpenFile(name, flag&^os.O_TRUNC, perm)
if err != nil {
return nil, err
}
switch flag & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) {
case os.O_WRONLY, os.O_RDWR:
err = filelock.Lock(f)
default:
err = filelock.RLock(f)
}
if err != nil {
f.Close()
return nil, err
}
if flag&os.O_TRUNC == os.O_TRUNC {
if err := f.Truncate(0); err != nil {
// The documentation for os.O_TRUNC says “if possible, truncate file when
// opened”, but doesn't define “possible” (golang.org/issue/28699).
// We'll treat regular files (and symlinks to regular files) as “possible”
// and ignore errors for the rest.
if fi, statErr := f.Stat(); statErr != nil || fi.Mode().IsRegular() {
filelock.Unlock(f)
f.Close()
return nil, err
}
}
}
return f, nil
}
func closeFile(f *os.File) error {
// Since locking syscalls operate on file descriptors, we must unlock the file
// while the descriptor is still valid — that is, before the file is closed —
// and avoid unlocking files that are already closed.
err := filelock.Unlock(f)
if closeErr := f.Close(); err == nil {
err = closeErr
}
return err
}

View File

@@ -0,0 +1,94 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build plan9
package lockedfile
import (
"io/fs"
"math/rand"
"os"
"strings"
"time"
)
// Opening an exclusive-use file returns an error.
// The expected error strings are:
//
// - "open/create -- file is locked" (cwfs, kfs)
// - "exclusive lock" (fossil)
// - "exclusive use file already open" (ramfs)
var lockedErrStrings = [...]string{
"file is locked",
"exclusive lock",
"exclusive use file already open",
}
// Even though plan9 doesn't support the Lock/RLock/Unlock functions to
// manipulate already-open files, IsLocked is still meaningful: os.OpenFile
// itself may return errors that indicate that a file with the ModeExclusive bit
// set is already open.
func isLocked(err error) bool {
s := err.Error()
for _, frag := range lockedErrStrings {
if strings.Contains(s, frag) {
return true
}
}
return false
}
func openFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
// Plan 9 uses a mode bit instead of explicit lock/unlock syscalls.
//
// Per http://man.cat-v.org/plan_9/5/stat: “Exclusive use files may be open
// for I/O by only one fid at a time across all clients of the server. If a
// second open is attempted, it draws an error.”
//
// So we can try to open a locked file, but if it fails we're on our own to
// figure out when it becomes available. We'll use exponential backoff with
// some jitter and an arbitrary limit of 500ms.
// If the file was unpacked or created by some other program, it might not
// have the ModeExclusive bit set. Set it before we call OpenFile, so that we
// can be confident that a successful OpenFile implies exclusive use.
if fi, err := os.Stat(name); err == nil {
if fi.Mode()&fs.ModeExclusive == 0 {
if err := os.Chmod(name, fi.Mode()|fs.ModeExclusive); err != nil {
return nil, err
}
}
} else if !os.IsNotExist(err) {
return nil, err
}
nextSleep := 1 * time.Millisecond
const maxSleep = 500 * time.Millisecond
for {
f, err := os.OpenFile(name, flag, perm|fs.ModeExclusive)
if err == nil {
return f, nil
}
if !isLocked(err) {
return nil, err
}
time.Sleep(nextSleep)
nextSleep += nextSleep
if nextSleep > maxSleep {
nextSleep = maxSleep
}
// Apply 10% jitter to avoid synchronizing collisions.
nextSleep += time.Duration((0.1*rand.Float64() - 0.05) * float64(nextSleep))
}
}
func closeFile(f *os.File) error {
return f.Close()
}

View File

@@ -0,0 +1,263 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// js and wasip1 do not support inter-process file locking.
//
//go:build !js && !wasip1
package lockedfile_test
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"hakurei.app/container"
"hakurei.app/internal/lockedfile"
"hakurei.app/internal/lockedfile/internal/testexec"
)
const (
quiescent = 10 * time.Millisecond
probablyStillBlocked = 10 * time.Second
)
func mustBlock(t *testing.T, desc string, f func()) (wait func(*testing.T)) {
t.Helper()
done := make(chan struct{})
go func() {
f()
close(done)
}()
timer := time.NewTimer(quiescent)
defer timer.Stop()
select {
case <-done:
t.Fatalf("%s unexpectedly did not block", desc)
case <-timer.C:
}
return func(t *testing.T) {
logTimer := time.NewTimer(quiescent)
defer logTimer.Stop()
select {
case <-logTimer.C:
// We expect the operation to have unblocked by now,
// but maybe it's just slow. Write to the test log
// in case the test times out, but don't fail it.
t.Helper()
t.Logf("%s is unexpectedly still blocked after %v", desc, quiescent)
// Wait for the operation to actually complete, no matter how long it
// takes. If the test has deadlocked, this will cause the test to time out
// and dump goroutines.
<-done
case <-done:
}
}
}
func TestMutexExcludes(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "lock")
mu := lockedfile.MutexAt(path)
t.Logf("mu := MutexAt(_)")
unlock, err := mu.Lock()
if err != nil {
t.Fatalf("mu.Lock: %v", err)
}
t.Logf("unlock, _ := mu.Lock()")
mu2 := lockedfile.MutexAt(mu.Path)
t.Logf("mu2 := MutexAt(mu.Path)")
wait := mustBlock(t, "mu2.Lock()", func() {
unlock2, err := mu2.Lock()
if err != nil {
t.Errorf("mu2.Lock: %v", err)
return
}
t.Logf("unlock2, _ := mu2.Lock()")
t.Logf("unlock2()")
unlock2()
})
t.Logf("unlock()")
unlock()
wait(t)
}
func TestReadWaitsForLock(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "timestamp.txt")
f, err := lockedfile.Create(path)
if err != nil {
t.Fatalf("Create: %v", err)
}
defer f.Close()
const (
part1 = "part 1\n"
part2 = "part 2\n"
)
_, err = f.WriteString(part1)
if err != nil {
t.Fatalf("WriteString: %v", err)
}
t.Logf("WriteString(%q) = <nil>", part1)
wait := mustBlock(t, "Read", func() {
b, err := lockedfile.Read(path)
if err != nil {
t.Errorf("Read: %v", err)
return
}
const want = part1 + part2
got := string(b)
if got == want {
t.Logf("Read(_) = %q", got)
} else {
t.Errorf("Read(_) = %q, _; want %q", got, want)
}
})
_, err = f.WriteString(part2)
if err != nil {
t.Errorf("WriteString: %v", err)
} else {
t.Logf("WriteString(%q) = <nil>", part2)
}
f.Close()
wait(t)
}
func TestCanLockExistingFile(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "existing.txt")
if err := os.WriteFile(path, []byte("ok"), 0777); err != nil {
t.Fatalf("os.WriteFile: %v", err)
}
f, err := lockedfile.Edit(path)
if err != nil {
t.Fatalf("first Edit: %v", err)
}
wait := mustBlock(t, "Edit", func() {
other, err := lockedfile.Edit(path)
if err != nil {
t.Errorf("second Edit: %v", err)
}
other.Close()
})
f.Close()
wait(t)
}
// TestSpuriousEDEADLK verifies that the spurious EDEADLK reported in
// https://golang.org/issue/32817 no longer occurs.
func TestSpuriousEDEADLK(t *testing.T) {
// P.1 locks file A.
// Q.3 locks file B.
// Q.3 blocks on file A.
// P.2 blocks on file B. (Spurious EDEADLK occurs here.)
// P.1 unlocks file A.
// Q.3 unblocks and locks file A.
// Q.3 unlocks files A and B.
// P.2 unblocks and locks file B.
// P.2 unlocks file B.
dirVar := t.Name() + "DIR"
if dir := os.Getenv(dirVar); dir != "" {
// Q.3 locks file B.
b, err := lockedfile.Edit(filepath.Join(dir, "B"))
if err != nil {
t.Fatal(err)
}
defer b.Close()
if err := os.WriteFile(filepath.Join(dir, "locked"), []byte("ok"), 0666); err != nil {
t.Fatal(err)
}
// Q.3 blocks on file A.
a, err := lockedfile.Edit(filepath.Join(dir, "A"))
// Q.3 unblocks and locks file A.
if err != nil {
t.Fatal(err)
}
defer a.Close()
// Q.3 unlocks files A and B.
return
}
dir := t.TempDir()
// P.1 locks file A.
a, err := lockedfile.Edit(filepath.Join(dir, "A"))
if err != nil {
t.Fatal(err)
}
cmd := testexec.CommandContext(t, t.Context(), container.MustExecutable(nil), "-test.run=^"+t.Name()+"$")
cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", dirVar, dir))
qDone := make(chan struct{})
waitQ := mustBlock(t, "Edit A and B in subprocess", func() {
out, err := cmd.CombinedOutput()
if err != nil {
t.Errorf("%v:\n%s", err, out)
}
close(qDone)
})
// Wait until process Q has either failed or locked file B.
// Otherwise, P.2 might not block on file B as intended.
locked:
for {
if _, err := os.Stat(filepath.Join(dir, "locked")); !os.IsNotExist(err) {
break locked
}
timer := time.NewTimer(1 * time.Millisecond)
select {
case <-qDone:
timer.Stop()
break locked
case <-timer.C:
}
}
waitP2 := mustBlock(t, "Edit B", func() {
// P.2 blocks on file B. (Spurious EDEADLK occurs here.)
b, err := lockedfile.Edit(filepath.Join(dir, "B"))
// P.2 unblocks and locks file B.
if err != nil {
t.Error(err)
return
}
// P.2 unlocks file B.
b.Close()
})
// P.1 unlocks file A.
a.Close()
waitQ(t)
waitP2(t)
}

View File

@@ -0,0 +1,67 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package lockedfile
import (
"fmt"
"os"
"sync"
)
// A Mutex provides mutual exclusion within and across processes by locking a
// well-known file. Such a file generally guards some other part of the
// filesystem: for example, a Mutex file in a directory might guard access to
// the entire tree rooted in that directory.
//
// Mutex does not implement sync.Locker: unlike a sync.Mutex, a lockedfile.Mutex
// can fail to lock (e.g. if there is a permission error in the filesystem).
//
// Like a sync.Mutex, a Mutex may be included as a field of a larger struct but
// must not be copied after first use. The Path field must be set before first
// use and must not be change thereafter.
type Mutex struct {
Path string // The path to the well-known lock file. Must be non-empty.
mu sync.Mutex // A redundant mutex. The race detector doesn't know about file locking, so in tests we may need to lock something that it understands.
}
// MutexAt returns a new Mutex with Path set to the given non-empty path.
func MutexAt(path string) *Mutex {
if path == "" {
panic("lockedfile.MutexAt: path must be non-empty")
}
return &Mutex{Path: path}
}
func (mu *Mutex) String() string {
return fmt.Sprintf("lockedfile.Mutex(%s)", mu.Path)
}
// Lock attempts to lock the Mutex.
//
// If successful, Lock returns a non-nil unlock function: it is provided as a
// return-value instead of a separate method to remind the caller to check the
// accompanying error. (See https://golang.org/issue/20803.)
func (mu *Mutex) Lock() (unlock func(), err error) {
if mu.Path == "" {
panic("lockedfile.Mutex: missing Path during Lock")
}
// We could use either O_RDWR or O_WRONLY here. If we choose O_RDWR and the
// file at mu.Path is write-only, the call to OpenFile will fail with a
// permission error. That's actually what we want: if we add an RLock method
// in the future, it should call OpenFile with O_RDONLY and will require the
// files must be readable, so we should not let the caller make any
// assumptions about Mutex working with write-only files.
f, err := OpenFile(mu.Path, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
return nil, err
}
mu.mu.Lock()
return func() {
mu.mu.Unlock()
f.Close()
}, nil
}

View File

@@ -0,0 +1,103 @@
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// js and wasip1 do not support inter-process file locking.
//
//go:build !js && !wasip1
package lockedfile_test
import (
"bytes"
"encoding/binary"
"math/rand"
"path/filepath"
"testing"
"time"
"hakurei.app/internal/lockedfile"
)
func isPowerOf2(x int) bool {
return x > 0 && x&(x-1) == 0
}
func roundDownToPowerOf2(x int) int {
if x <= 0 {
panic("nonpositive x")
}
bit := 1
for x != bit {
x = x &^ bit
bit <<= 1
}
return x
}
func TestTransform(t *testing.T) {
path := filepath.Join(t.TempDir(), "blob.bin")
const maxChunkWords = 8 << 10
buf := make([]byte, 2*maxChunkWords*8)
for i := uint64(0); i < 2*maxChunkWords; i++ {
binary.LittleEndian.PutUint64(buf[i*8:], i)
}
if err := lockedfile.Write(path, bytes.NewReader(buf[:8]), 0666); err != nil {
t.Fatal(err)
}
var attempts int64 = 128
if !testing.Short() {
attempts *= 16
}
const parallel = 32
var sem = make(chan bool, parallel)
for n := attempts; n > 0; n-- {
sem <- true
go func() {
defer func() { <-sem }()
time.Sleep(time.Duration(rand.Intn(100)) * time.Microsecond)
chunkWords := roundDownToPowerOf2(rand.Intn(maxChunkWords) + 1)
offset := rand.Intn(chunkWords)
err := lockedfile.Transform(path, func(data []byte) (chunk []byte, err error) {
chunk = buf[offset*8 : (offset+chunkWords)*8]
if len(data)&^7 != len(data) {
t.Errorf("read %d bytes, but each write is an integer multiple of 8 bytes", len(data))
return chunk, nil
}
words := len(data) / 8
if !isPowerOf2(words) {
t.Errorf("read %d 8-byte words, but each write is a power-of-2 number of words", words)
return chunk, nil
}
u := binary.LittleEndian.Uint64(data)
for i := 1; i < words; i++ {
next := binary.LittleEndian.Uint64(data[i*8:])
if next != u+1 {
t.Errorf("wrote sequential integers, but read integer out of sequence at offset %d", i)
return chunk, nil
}
u = next
}
return chunk, nil
})
if err != nil {
t.Errorf("unexpected error from Transform: %v", err)
}
}()
}
for n := parallel; n > 0; n-- {
sem <- true
}
}

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"context"

View File

@@ -1,8 +1,10 @@
package app
package outcome
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"log"
@@ -21,7 +23,6 @@ import (
"hakurei.app/container/seccomp"
"hakurei.app/container/stub"
"hakurei.app/hst"
"hakurei.app/internal/app/state"
"hakurei.app/message"
"hakurei.app/system"
)
@@ -48,8 +49,8 @@ const (
wantRuntimeSharePath = wantRunDirPath + "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
// checkExpectInstanceId is the [state.ID] value used by checkOpBehaviour to initialise outcomeState.
var checkExpectInstanceId = *(*state.ID)(bytes.Repeat([]byte{0xaa}, len(state.ID{})))
// checkExpectInstanceId is the [hst.ID] value used by checkOpBehaviour to initialise outcomeState.
var checkExpectInstanceId = *(*hst.ID)(bytes.Repeat([]byte{0xaa}, len(hst.ID{})))
type (
// pStateSysFunc is called before each test case is run to prepare outcomeStateSys.
@@ -193,8 +194,8 @@ func checkOpBehaviour(t *testing.T, testCases []opBehaviourTestCase) {
}
wantConfig := tc.newConfig()
k := &kstub{panicDispatcher{}, stub.New(t,
func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{panicDispatcher{}, s} },
k := &kstub{nil, nil, panicDispatcher{}, stub.New(t,
func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{nil, nil, panicDispatcher{}, s} },
stub.Expect{Calls: wantCallsFull},
)}
defer stub.HandleExit(t)
@@ -297,7 +298,12 @@ func checkSimple(t *testing.T, fname string, testCases []simpleTestCase) {
t.Parallel()
defer stub.HandleExit(t)
k := &kstub{panicDispatcher{}, stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{panicDispatcher{}, s} }, tc.want)}
uNotifyContext, uShimReader := make(chan struct{}), make(chan struct{})
k := &kstub{uNotifyContext, uShimReader, panicDispatcher{},
stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher {
return &kstub{uNotifyContext, uShimReader, panicDispatcher{}, s}
}, tc.want)}
if err := tc.f(k); !reflect.DeepEqual(err, tc.wantErr) {
t.Errorf("%s: error = %#v, want %#v", fname, err, tc.wantErr)
}
@@ -312,6 +318,11 @@ func checkSimple(t *testing.T, fname string, testCases []simpleTestCase) {
// kstub partially implements syscallDispatcher via [stub.Stub].
type kstub struct {
// notifyContext blocks on unblockNotifyContext if provided with a kstub.Stub track.
unblockNotifyContext chan struct{}
// stubTrackReader blocks on unblockShimReader if unblocking notifyContext.
unblockShimReader chan struct{}
panicDispatcher
*stub.Stub[syscallDispatcher]
}
@@ -354,6 +365,19 @@ func (k *kstub) readdir(name string) ([]os.DirEntry, error) {
stub.CheckArg(k.Stub, "name", name, 0))
}
func (k *kstub) tempdir() string { k.Helper(); return k.Expects("tempdir").Ret.(string) }
func (k *kstub) exit(code int) {
k.Helper()
expect := k.Expects("exit")
if errors.Is(expect.Err, unblockNotifyContext) {
close(k.unblockNotifyContext)
}
if !stub.CheckArg(k.Stub, "code", code, 0) {
k.FailNow()
}
panic(expect.Ret.(int))
}
func (k *kstub) evalSymlinks(path string) (string, error) {
k.Helper()
expect := k.Expects("evalSymlinks")
@@ -388,16 +412,17 @@ func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error
func (k *kstub) expectCheckContainer(expect *stub.Call, z *container.Container) error {
k.Helper()
err := expect.Error(
stub.CheckArgReflect(k.Stub, "params", &z.Params, 0))
if err != nil {
if !stub.CheckArgReflect(k.Stub, "params", &z.Params, 0) {
k.Errorf("params:\n%s\n%s", mustMarshal(&z.Params), mustMarshal(expect.Args[0]))
}
return err
return expect.Err
}
func (k *kstub) containerStart(z *container.Container) error {
k.Helper()
if k.unblockShimReader != nil {
close(k.unblockShimReader)
}
return k.expectCheckContainer(k.Expects("containerStart"), z)
}
func (k *kstub) containerServe(z *container.Container) error {
@@ -428,12 +453,25 @@ func (k *kstub) cmdOutput(cmd *exec.Cmd) ([]byte, error) {
func (k *kstub) notifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) {
k.Helper()
if k.Expects("notifyContext").Error(
expect := k.Expects("notifyContext")
if expect.Error(
stub.CheckArgReflect(k.Stub, "parent", parent, 0),
stub.CheckArgReflect(k.Stub, "signals", signals, 1)) != nil {
k.FailNow()
}
return k.Context(), func() { k.Helper(); k.Expects("notifyContextStop") }
if sub, ok := expect.Ret.(int); ok && sub >= 0 {
subVal := reflect.ValueOf(k.Stub).Elem().FieldByName("sub")
ks := &kstub{nil, nil, panicDispatcher{}, reflect.
NewAt(subVal.Type(), unsafe.Pointer(subVal.UnsafeAddr())).Elem().
Interface().([]*stub.Stub[syscallDispatcher])[sub]}
<-k.unblockNotifyContext
return k.Context(), func() { k.Helper(); ks.Expects("notifyContextStop") }
}
return k.Context(), func() { panic("unexpected call to stop") }
}
func (k *kstub) mustHsuPath() *check.Absolute {
@@ -457,26 +495,51 @@ type stubTrackReader struct {
*kstub
}
// unblockNotifyContext is passed via call and must be handled by stubTrackReader.Read
var unblockNotifyContext = errors.New("this error unblocks notifyContext and must not be returned")
func (r *stubTrackReader) Read(p []byte) (n int, err error) {
r.subOnce.Do(func() {
subVal := reflect.ValueOf(r.kstub.Stub).Elem().FieldByName("sub")
r.kstub = &kstub{panicDispatcher{}, reflect.
r.kstub = &kstub{r.kstub.unblockNotifyContext, r.kstub.unblockShimReader, panicDispatcher{}, reflect.
NewAt(subVal.Type(), unsafe.Pointer(subVal.UnsafeAddr())).Elem().
Interface().([]*stub.Stub[syscallDispatcher])[r.sub]}
})
return r.kstub.Read(p)
n, err = r.kstub.Read(p)
if errors.Is(err, unblockNotifyContext) {
err = nil
close(r.unblockNotifyContext)
<-r.unblockShimReader
}
return n, err
}
func (k *kstub) setupContSignal(pid int) (io.ReadCloser, func(), error) {
k.Helper()
expect := k.Expects("setupContSignal")
return &stubTrackReader{sub: expect.Ret.(int), kstub: k}, func() { k.Expects("wKeepAlive") }, expect.Error(
return &stubTrackReader{sub: expect.Ret.(int), kstub: k}, func() { k.Helper(); k.Expects("wKeepAlive") }, expect.Error(
stub.CheckArg(k.Stub, "pid", pid, 0))
}
func (k *kstub) getMsg() message.Msg { k.Helper(); k.Expects("getMsg"); return k }
func (k *kstub) fatal(v ...any) {
if k.Expects("fatal").Error(
stub.CheckArgReflect(k.Stub, "v", v, 0)) != nil {
k.FailNow()
}
panic(stub.PanicExit)
}
func (k *kstub) fatalf(format string, v ...any) {
if k.Expects("fatalf").Error(
stub.CheckArg(k.Stub, "format", format, 0),
stub.CheckArgReflect(k.Stub, "v", v, 1)) != nil {
k.FailNow()
}
panic(stub.PanicExit)
}
func (k *kstub) Close() error { k.Helper(); return k.Expects("rcClose").Err }
func (k *kstub) Read(p []byte) (n int, err error) {
k.Helper()
@@ -586,6 +649,23 @@ type errorReader struct{ val error }
func (r errorReader) Read([]byte) (int, error) { return -1, r.val }
// mustMarshal returns the result of [json.Marshal] as a string and panics on error.
func mustMarshal(v any) string {
if b, err := json.Marshal(v); err != nil {
panic(err.Error())
} else {
return string(b)
}
}
// m is a shortcut for [check.MustAbs].
func m(pathname string) *check.Absolute { return check.MustAbs(pathname) }
// f returns [hst.FilesystemConfig] wrapped in its [json] adapter.
func f(c hst.FilesystemConfig) hst.FilesystemConfigJSON {
return hst.FilesystemConfigJSON{FilesystemConfig: c}
}
// panicMsgContext implements [message.Msg] and [context.Context] with methods wrapping panic.
// This should be assigned to test cases to be checked against.
type panicMsgContext struct{}

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"context"
@@ -9,7 +9,6 @@ import (
"sync/atomic"
"hakurei.app/hst"
"hakurei.app/internal/app/state"
"hakurei.app/message"
"hakurei.app/system"
)
@@ -37,7 +36,7 @@ type outcome struct {
syscallDispatcher
}
func (k *outcome) finalise(ctx context.Context, msg message.Msg, id *state.ID, config *hst.Config) error {
func (k *outcome) finalise(ctx context.Context, msg message.Msg, id *hst.ID, config *hst.Config) error {
if ctx == nil || id == nil {
// unreachable
panic("invalid call to finalise")

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"errors"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"os"

View File

@@ -1,27 +1,24 @@
// Package app implements high-level hakurei container behaviour.
package app
package outcome
import (
"context"
"log"
"os"
"hakurei.app/hst"
"hakurei.app/internal/app/state"
"hakurei.app/message"
)
// Main runs an app according to [hst.Config] and terminates. Main does not return.
func Main(ctx context.Context, msg message.Msg, config *hst.Config) {
var id state.ID
if err := state.NewAppID(&id); err != nil {
log.Fatal(err)
var id hst.ID
if err := hst.NewInstanceID(&id); err != nil {
log.Fatal(err.Error())
}
seal := outcome{syscallDispatcher: direct{msg}}
if err := seal.finalise(ctx, msg, &id, config); err != nil {
printMessageError("cannot seal app:", err)
os.Exit(1)
printMessageError(msg.GetLogger().Fatalln, "cannot seal app:", err)
panic("unreachable")
}
seal.main(msg)

View File

@@ -1,9 +1,8 @@
package app
package outcome
import (
"bytes"
"encoding/gob"
"encoding/json"
"errors"
"fmt"
"io"
@@ -22,14 +21,13 @@ import (
"hakurei.app/container/fhs"
"hakurei.app/container/seccomp"
"hakurei.app/hst"
"hakurei.app/internal/app/state"
"hakurei.app/message"
"hakurei.app/system"
"hakurei.app/system/acl"
"hakurei.app/system/dbus"
)
func TestApp(t *testing.T) {
func TestOutcomeMain(t *testing.T) {
t.Parallel()
msg := message.NewMsg(nil)
msg.SwapVerbose(testing.Verbose())
@@ -38,7 +36,7 @@ func TestApp(t *testing.T) {
name string
k syscallDispatcher
config *hst.Config
id state.ID
id hst.ID
wantSys *system.I
wantParams *container.Params
}{
@@ -212,7 +210,7 @@ func TestApp(t *testing.T) {
Args: []string{"/run/current-system/sw/bin/zsh"},
Flags: hst.FUserns | hst.FHostNet | hst.FHostAbstract | hst.FTty | hst.FShareRuntime | hst.FShareTmpdir,
}}, state.ID{
}}, hst.ID{
0x4a, 0x45, 0x0b, 0x65,
0x96, 0xd7, 0xbc, 0x15,
0xbd, 0x01, 0x78, 0x0e,
@@ -336,7 +334,7 @@ func TestApp(t *testing.T) {
Flags: hst.FUserns | hst.FHostNet | hst.FHostAbstract | hst.FTty | hst.FShareRuntime | hst.FShareTmpdir,
},
}, state.ID{
}, hst.ID{
0xeb, 0xf0, 0x83, 0xd1,
0xb1, 0x75, 0x91, 0x17,
0x82, 0xd4, 0x13, 0x36,
@@ -490,7 +488,7 @@ func TestApp(t *testing.T) {
DirectWayland: true,
Identity: 1, Groups: []string{},
}, state.ID{
}, hst.ID{
0x8e, 0x2c, 0x76, 0xb0,
0x66, 0xda, 0xbe, 0x57,
0x4c, 0xf0, 0x73, 0xbd,
@@ -654,14 +652,6 @@ func TestApp(t *testing.T) {
}
}
func mustMarshal(v any) string {
if b, err := json.Marshal(v); err != nil {
panic(err.Error())
} else {
return string(b)
}
}
func stubDirEntries(names ...string) (e []fs.DirEntry, err error) {
e = make([]fs.DirEntry, len(names))
for i, name := range names {
@@ -931,11 +921,3 @@ func (k *stubNixOS) fatalf(format string, v ...any) { panic(fmt.Sprintf(format,
func (k *stubNixOS) isVerbose() bool { return true }
func (k *stubNixOS) verbose(v ...any) { log.Print(v...) }
func (k *stubNixOS) verbosef(format string, v ...any) { log.Printf(format, v...) }
func m(pathname string) *check.Absolute {
return check.MustAbs(pathname)
}
func f(c hst.FilesystemConfig) hst.FilesystemConfigJSON {
return hst.FilesystemConfigJSON{FilesystemConfig: c}
}

View File

@@ -1,4 +1,5 @@
package app
// Package outcome implements the outcome of the privileged and container sides of a hakurei container.
package outcome
import (
"errors"
@@ -8,7 +9,7 @@ import (
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/hst"
"hakurei.app/internal/app/state"
"hakurei.app/internal/env"
"hakurei.app/message"
"hakurei.app/system"
"hakurei.app/system/acl"
@@ -36,15 +37,15 @@ type outcomeState struct {
Shim *shimParams
// Generated and accounted for by the caller.
ID *state.ID
ID *hst.ID
// Copied from ID.
id *stringPair[state.ID]
id *stringPair[hst.ID]
// Copied from the [hst.Config] field of the same name.
Identity int
// Copied from Identity.
identity *stringPair[int]
// Returned by [Hsu.MustIDMsg].
// Returned by [Hsu.MustID].
UserID int
// Target init namespace uid resolved from UserID and identity.
uid *stringPair[int]
@@ -59,7 +60,7 @@ type outcomeState struct {
// Copied from [EnvPaths] per-process.
sc hst.Paths
*EnvPaths
*env.Paths
// Copied via populateLocal.
k syscallDispatcher
@@ -73,17 +74,17 @@ func (s *outcomeState) valid() bool {
s.Shim.valid() &&
s.ID != nil &&
s.Container != nil &&
s.EnvPaths != nil
s.Paths != nil
}
// newOutcomeState returns the address of a new outcomeState with its exported fields populated via syscallDispatcher.
func newOutcomeState(k syscallDispatcher, msg message.Msg, id *state.ID, config *hst.Config, hsu *Hsu) *outcomeState {
func newOutcomeState(k syscallDispatcher, msg message.Msg, id *hst.ID, config *hst.Config, hsu *Hsu) *outcomeState {
s := outcomeState{
Shim: &shimParams{PrivPID: k.getpid(), Verbose: msg.IsVerbose()},
ID: id,
Identity: config.Identity,
UserID: hsu.MustID(msg),
EnvPaths: copyPaths(k),
Paths: env.CopyPathsFunc(k.fatalf, k.tempdir, func(key string) string { v, _ := k.lookupEnv(key); return v }),
Container: config.Container,
}
@@ -120,7 +121,7 @@ func (s *outcomeState) populateLocal(k syscallDispatcher, msg message.Msg) error
s.k = k
s.msg = msg
s.id = &stringPair[state.ID]{*s.ID, s.ID.String()}
s.id = &stringPair[hst.ID]{*s.ID, s.ID.String()}
s.Copy(&s.sc, s.UserID)
msg.Verbosef("process share directory at %q, runtime directory at %q", s.sc.SharePath, s.sc.RunDirPath)

View File

@@ -1,10 +1,10 @@
package app
package outcome
import (
"testing"
"hakurei.app/hst"
"hakurei.app/internal/app/state"
"hakurei.app/internal/env"
)
func TestOutcomeStateValid(t *testing.T) {
@@ -17,11 +17,11 @@ func TestOutcomeStateValid(t *testing.T) {
}{
{"nil", nil, false},
{"zero", new(outcomeState), false},
{"shim", &outcomeState{Shim: &shimParams{PrivPID: -1, Ops: []outcomeOp{}}, Container: new(hst.ContainerConfig), EnvPaths: new(EnvPaths)}, false},
{"id", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, Container: new(hst.ContainerConfig), EnvPaths: new(EnvPaths)}, false},
{"container", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(state.ID), EnvPaths: new(EnvPaths)}, false},
{"envpaths", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(state.ID), Container: new(hst.ContainerConfig)}, false},
{"valid", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(state.ID), Container: new(hst.ContainerConfig), EnvPaths: new(EnvPaths)}, true},
{"shim", &outcomeState{Shim: &shimParams{PrivPID: -1, Ops: []outcomeOp{}}, Container: new(hst.ContainerConfig), Paths: new(env.Paths)}, false},
{"id", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, Container: new(hst.ContainerConfig), Paths: new(env.Paths)}, false},
{"container", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(hst.ID), Paths: new(env.Paths)}, false},
{"envpaths", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(hst.ID), Container: new(hst.ContainerConfig)}, false},
{"valid", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(hst.ID), Container: new(hst.ContainerConfig), Paths: new(env.Paths)}, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -1,10 +1,9 @@
package app
package outcome
import (
"context"
"encoding/gob"
"errors"
"log"
"os"
"os/exec"
"strconv"
@@ -16,7 +15,7 @@ import (
"hakurei.app/container/fhs"
"hakurei.app/hst"
"hakurei.app/internal"
"hakurei.app/internal/app/state"
"hakurei.app/internal/state"
"hakurei.app/message"
"hakurei.app/system"
)
@@ -68,7 +67,7 @@ func (ms mainState) beforeExit(isFault bool) {
// updates hasErr but does not terminate
perror := func(err error, message string) {
hasErr = true
printMessageError("cannot "+message+":", err)
printMessageError(ms.GetLogger().Println, "cannot "+message+":", err)
}
exitCode := 1
defer func() {
@@ -121,7 +120,7 @@ func (ms mainState) beforeExit(isFault bool) {
// this is only reachable when shim did not exit within shimWaitTimeout, after its WaitDelay has elapsed.
// This is different from the container failing to terminate within its timeout period, as that is enforced
// by the shim. This path is instead reached when there is a lockup in shim preventing it from completing.
log.Printf("process %d did not terminate", ms.cmd.Process.Pid)
ms.GetLogger().Printf("process %d did not terminate", ms.cmd.Process.Pid)
}
ms.Resume()
@@ -167,7 +166,7 @@ func (ms mainState) beforeExit(isFault bool) {
if s.Config != nil {
rt |= s.Config.Enablements.Unwrap()
} else {
log.Printf("state entry %d does not contain config", i)
ms.GetLogger().Printf("state entry %d does not contain config", i)
}
}
@@ -192,17 +191,11 @@ func (ms mainState) beforeExit(isFault bool) {
} else if ms.uintptr&mainNeedsDestroy != 0 {
panic("unreachable")
}
if ms.store != nil {
if err := ms.store.Close(); err != nil {
perror(err, "close state store")
}
}
}
// fatal calls printMessageError, performs necessary cleanup, followed by a call to [os.Exit](1).
func (ms mainState) fatal(fallback string, ferr error) {
printMessageError(fallback, ferr)
printMessageError(ms.GetLogger().Println, fallback, ferr)
ms.beforeExit(true)
os.Exit(1)
}
@@ -227,7 +220,7 @@ func (k *outcome) main(msg message.Msg) {
ms.fatal("cannot commit system setup:", err)
}
ms.uintptr |= mainNeedsRevert
ms.store = state.NewMulti(msg, k.state.sc.RunDirPath.String())
ms.store = state.NewMulti(msg, k.state.sc.RunDirPath)
ctx, cancel := context.WithCancel(k.ctx)
defer cancel()
@@ -289,11 +282,12 @@ func (k *outcome) main(msg message.Msg) {
// shim accepted setup payload, create process state
if ok, err := ms.store.Do(k.state.identity.unwrap(), func(c state.Cursor) {
if err := c.Save(&state.State{
ID: k.state.id.unwrap(),
PID: ms.cmd.Process.Pid,
Config: k.config,
Time: *ms.Time,
if err := c.Save(&hst.State{
ID: k.state.id.unwrap(),
PID: os.Getpid(),
ShimPID: ms.cmd.Process.Pid,
Config: k.config,
Time: *ms.Time,
}); err != nil {
ms.fatal("cannot save state entry:", err)
}
@@ -315,12 +309,12 @@ func (k *outcome) main(msg message.Msg) {
// printMessageError prints the error message according to [message.GetMessage],
// or fallback prepended to err if an error message is not available.
func printMessageError(fallback string, err error) {
func printMessageError(println func(v ...any), fallback string, err error) {
m, ok := message.GetMessage(err)
if !ok {
log.Println(fallback, err)
println(fallback, err)
return
}
log.Print(m)
println(m)
}

View File

@@ -8,35 +8,32 @@
static pid_t hakurei_shim_param_ppid = -1;
static int hakurei_shim_fd = -1;
static ssize_t hakurei_shim_write(const void *buf, size_t count) {
/* see shim.go for handling of the message */
static inline ssize_t hakurei_shim_write(hakurei_shim_msg msg) {
int savedErrno = errno;
ssize_t ret = write(hakurei_shim_fd, buf, count);
unsigned char buf = (unsigned char)msg;
ssize_t ret = write(hakurei_shim_fd, &buf, 1);
if (ret == -1 && errno != EAGAIN)
exit(EXIT_FAILURE);
errno = savedErrno;
return ret;
}
/* see shim_linux.go for handling of the value */
static void hakurei_shim_sigaction(int sig, siginfo_t *si, void *ucontext) {
if (sig != SIGCONT || si == NULL) {
/* unreachable */
hakurei_shim_write("\2", 1);
hakurei_shim_write(HAKUREI_SHIM_INVALID);
return;
}
if (si->si_pid == hakurei_shim_param_ppid) {
/* monitor requests shim exit */
hakurei_shim_write("\0", 1);
hakurei_shim_write(HAKUREI_SHIM_EXIT_REQUESTED);
return;
}
/* unexpected si_pid */
hakurei_shim_write("\3", 1);
hakurei_shim_write(HAKUREI_SHIM_BAD_PID);
if (getppid() != hakurei_shim_param_ppid)
/* shim orphaned before monitor delivers a signal */
hakurei_shim_write("\1", 1);
hakurei_shim_write(HAKUREI_SHIM_ORPHAN);
}
void hakurei_shim_setup_cont_signal(pid_t ppid, int fd) {

View File

@@ -0,0 +1,11 @@
#include <signal.h>
/* see shim.go for documentation */
typedef enum {
HAKUREI_SHIM_EXIT_REQUESTED,
HAKUREI_SHIM_ORPHAN,
HAKUREI_SHIM_INVALID,
HAKUREI_SHIM_BAD_PID,
} hakurei_shim_msg;
void hakurei_shim_setup_cont_signal(pid_t ppid, int fd);

View File

@@ -1,8 +1,9 @@
package app
package outcome
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
@@ -22,6 +23,17 @@ import (
//#include "shim-signal.h"
import "C"
const (
/* hakurei requests shim exit */
shimMsgExitRequested = C.HAKUREI_SHIM_EXIT_REQUESTED
/* shim orphaned before hakurei delivers a signal */
shimMsgOrphaned = C.HAKUREI_SHIM_ORPHAN
/* unreachable */
shimMsgInvalid = C.HAKUREI_SHIM_INVALID
/* unexpected si_pid */
shimMsgBadPID = C.HAKUREI_SHIM_BAD_PID
)
// setupContSignal sets up the SIGCONT signal handler for the cross-uid shim exit hack.
// The signal handler is implemented in C, signals can be processed by reading from the returned reader.
// The returned function must be called after all signal processing concludes.
@@ -81,7 +93,7 @@ func shimEntrypoint(k syscallDispatcher) {
}
if err := k.setDumpable(container.SUID_DUMP_DISABLE); err != nil {
k.fatalf("cannot set SUID_DUMP_DISABLE: %s", err)
k.fatalf("cannot set SUID_DUMP_DISABLE: %v", err)
}
var (
@@ -102,11 +114,8 @@ func shimEntrypoint(k syscallDispatcher) {
closeSetup = f
if err = state.populateLocal(k, msg); err != nil {
if m, ok := message.GetMessage(err); ok {
k.fatal(m)
} else {
k.fatalf("cannot populate local state: %v", err)
}
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot populate local state:", err)
}
}
@@ -126,7 +135,6 @@ func shimEntrypoint(k syscallDispatcher) {
k.fatalf("cannot set up exit request: %v", err)
return
}
} else {
defer wKeepAlive()
signalPipe = r
@@ -140,13 +148,13 @@ func shimEntrypoint(k syscallDispatcher) {
stateParams := state.newParams()
for _, op := range state.Shim.Ops {
if err := op.toContainer(stateParams); err != nil {
if m, ok := message.GetMessage(err); ok {
k.fatal(m)
} else {
k.fatalf("cannot create container state: %v", err)
}
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot create container state:", err)
}
}
if stateParams.params.Ops == nil { // only reachable with corrupted outcomeState
k.fatal("invalid container state")
}
// shim exit outcomes
var cancelContainer atomic.Pointer[context.CancelFunc]
@@ -158,7 +166,7 @@ func shimEntrypoint(k syscallDispatcher) {
}
switch buf[0] {
case 0: // got SIGCONT from monitor: shim exit requested
case shimMsgExitRequested: // got SIGCONT from hakurei: shim exit requested
if fp := cancelContainer.Load(); stateParams.params.ForwardCancel && fp != nil && *fp != nil {
(*fp)()
// shim now bound by ShimWaitDelay, implemented below
@@ -166,19 +174,15 @@ func shimEntrypoint(k syscallDispatcher) {
}
// setup has not completed, terminate immediately
msg.Resume()
k.exit(hst.ExitRequest)
return
case 1: // got SIGCONT after adoption: monitor died before delivering signal
msg.BeforeExit()
case shimMsgOrphaned: // got SIGCONT after orphaned: hakurei died before delivering signal
k.exit(hst.ExitOrphan)
return
case 2: // unreachable
case shimMsgInvalid: // unreachable
msg.Verbose("sa_sigaction got invalid siginfo")
case 3: // got SIGCONT from unexpected process: hopefully the terminal driver
case shimMsgBadPID: // got SIGCONT from unexpected process: hopefully the terminal driver
msg.Verbose("got SIGCONT from unexpected process")
default: // unreachable
@@ -187,10 +191,6 @@ func shimEntrypoint(k syscallDispatcher) {
}
})
if stateParams.params.Ops == nil {
k.fatal("invalid container params")
}
// close setup socket
if err := closeSetup(); err != nil {
msg.Verbosef("cannot close setup pipe: %v", err)
@@ -207,11 +207,20 @@ func shimEntrypoint(k syscallDispatcher) {
z.WaitDelay = state.Shim.WaitDelay
if err := k.containerStart(z); err != nil {
printMessageError("cannot start container:", err)
var f func(v ...any)
if logger := msg.GetLogger(); logger != nil {
f = logger.Println
} else {
f = func(v ...any) {
msg.Verbose(fmt.Sprintln(v...))
}
}
printMessageError(f, "cannot start container:", err)
k.exit(hst.ExitFailure)
}
if err := k.containerServe(z); err != nil {
printMessageError("cannot configure container:", err)
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot configure container:", err)
}
if err := k.seccompLoad(

View File

@@ -0,0 +1,494 @@
package outcome
import (
"bytes"
"context"
"log"
"os"
"syscall"
"testing"
"hakurei.app/container"
"hakurei.app/container/comp"
"hakurei.app/container/fhs"
"hakurei.app/container/seccomp"
"hakurei.app/container/stub"
"hakurei.app/hst"
"hakurei.app/internal/env"
)
func TestShimEntrypoint(t *testing.T) {
t.Parallel()
shimPreset := seccomp.Preset(comp.PresetStrict, seccomp.AllowMultiarch)
templateParams := &container.Params{
Dir: m("/data/data/org.chromium.Chromium"),
Env: []string{
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus",
"DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket",
"GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT",
"HOME=/data/data/org.chromium.Chromium",
"PULSE_COOKIE=/.hakurei/pulse-cookie",
"PULSE_SERVER=unix:/run/user/1000/pulse/native",
"SHELL=/run/current-system/sw/bin/zsh",
"TERM=xterm-256color",
"USER=chronos",
"WAYLAND_DISPLAY=wayland-0",
"XDG_RUNTIME_DIR=/run/user/1000",
"XDG_SESSION_CLASS=user",
"XDG_SESSION_TYPE=wayland",
},
// spParamsOp
Hostname: "localhost",
RetainSession: true,
HostNet: true,
HostAbstract: true,
ForwardCancel: true,
Path: m("/run/current-system/sw/bin/chromium"),
Args: []string{
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland",
},
SeccompFlags: seccomp.AllowMultiarch,
Uid: 1000,
Gid: 100,
Ops: new(container.Ops).
// resolveRoot
Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable).
// spParamsOp
Proc(fhs.AbsProc).
Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755).
Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice).
Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777).
// spRuntimeOp
Tmpfs(fhs.AbsRunUser, 1<<12, 0755).
Bind(m("/tmp/hakurei.10/runtime/9999"), m("/run/user/1000"), comp.BindWritable).
// spTmpdirOp
Bind(m("/tmp/hakurei.10/tmpdir/9999"), fhs.AbsTmp, comp.BindWritable).
// spAccountOp
Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")).
Place(m("/etc/group"), []byte("hakurei:x:100:\n")).
// spWaylandOp
Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/wayland"), m("/run/user/1000/wayland-0"), 0).
// spPulseOp
Bind(m("/run/user/1000/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pulse"), m("/run/user/1000/pulse/native"), 0).
Place(m("/.hakurei/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)).
// spDBusOp
Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bus"), m("/run/user/1000/bus"), 0).
Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/system_bus_socket"), m("/var/run/dbus/system_bus_socket"), 0).
// spFilesystemOp
Etc(fhs.AbsEtc, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").
Tmpfs(fhs.AbsTmp, 0, 0755).
Overlay(m("/nix/store"),
fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"),
fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/work"),
fhs.AbsVarLib.Append("hakurei/base/org.nixos/ro-store")).
Link(m("/run/current-system"), "/run/current-system", true).
Link(m("/run/opengl-driver"), "/run/opengl-driver", true).
Bind(fhs.AbsVarLib.Append("hakurei/u0/org.chromium.Chromium"),
m("/data/data/org.chromium.Chromium"),
comp.BindWritable|comp.BindEnsure).
Bind(fhs.AbsDev.Append("dri"), fhs.AbsDev.Append("dri"),
comp.BindOptional|comp.BindWritable|comp.BindDevice).
Remount(fhs.AbsRoot, syscall.MS_RDONLY),
}
newShimParams := func() *shimParams {
return &shimParams{PrivPID: 0xbad, WaitDelay: 0xf, Verbose: true, Ops: []outcomeOp{
&spParamsOp{"xterm-256color", true},
&spRuntimeOp{sessionTypeWayland},
spTmpdirOp{},
spAccountOp{},
&spWaylandOp{},
&spPulseOp{(*[pulseCookieSizeMax]byte)(bytes.Repeat([]byte{0}, pulseCookieSizeMax)), pulseCookieSizeMax},
&spDBusOp{true},
&spFilesystemOp{},
}}
}
templateState := outcomeState{
Shim: newShimParams(),
ID: &checkExpectInstanceId,
Identity: hst.IdentityMax,
UserID: 10,
Container: hst.Template().Container,
Mapuid: 1000,
Mapgid: 100,
Paths: &env.Paths{TempDir: fhs.AbsTmp, RuntimePath: fhs.AbsRunUser.Append("1000")},
}
checkSimple(t, "shimEntrypoint", []simpleTestCase{
{"dumpable", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, new(log.Logger), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, stub.UniqueError(11)),
call("fatalf", stub.ExpectArgs{"cannot set SUID_DUMP_DISABLE: %v", []any{stub.UniqueError(11)}}, nil, nil),
}}, nil},
{"receive fd", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", outcomeState{}, nil}, nil, syscall.EBADF),
call("fatal", stub.ExpectArgs{[]any{"invalid config descriptor"}}, nil, nil),
}}, nil},
{"receive env", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", outcomeState{}, nil}, nil, container.ErrReceiveEnv),
call("fatal", stub.ExpectArgs{[]any{"HAKUREI_SHIM not set"}}, nil, nil),
}}, nil},
{"receive strange", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", outcomeState{}, nil}, nil, stub.UniqueError(10)),
call("fatalf", stub.ExpectArgs{"cannot receive shim setup params: %v", []any{stub.UniqueError(10)}}, nil, nil),
}}, nil},
{"invalid state", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", func() outcomeState {
state := templateState
state.Shim = newShimParams()
state.Shim.PrivPID = 0
return state
}(), nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("fatal", stub.ExpectArgs{[]any{"impossible outcome state reached\n"}}, nil, nil),
}}, nil},
{"sigaction pipe", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, &os.SyscallError{Syscall: "pipe2", Err: stub.UniqueError(9)}),
call("fatal", stub.ExpectArgs{[]any{"pipe2: unique error 9 injected by the test suite"}}, nil, nil),
}}, nil},
{"sigaction cgo", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, syscall.ENOTRECOVERABLE),
call("fatalf", stub.ExpectArgs{"cannot install SIGCONT handler: %v", []any{syscall.ENOTRECOVERABLE}}, nil, nil),
}}, nil},
{"sigaction strange", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, stub.UniqueError(8)),
call("fatalf", stub.ExpectArgs{"cannot set up exit request: %v", []any{stub.UniqueError(8)}}, nil, nil),
}}, nil},
{"prctl", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, stub.UniqueError(7)),
call("fatalf", stub.ExpectArgs{"cannot set parent-death signal: %v", []any{stub.UniqueError(7)}}, nil, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}}, nil},
{"toContainer", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", func() outcomeState {
state := templateState
state.Shim = newShimParams()
state.Shim.Ops = []outcomeOp{errorOp(6)}
return state
}(), nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("fatal", stub.ExpectArgs{[]any{"cannot create container state: unique error 6 injected by the test suite\n"}}, nil, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}}, nil},
{"bad ops", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", func() outcomeState {
state := templateState
state.Shim = newShimParams()
state.Shim.Ops = nil
return state
}(), nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("fatal", stub.ExpectArgs{[]any{"invalid container state"}}, nil, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}}, nil},
{"start", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, -1, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, stub.UniqueError(5)),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("verbose", stub.ExpectArgs{[]any{"cannot start container: unique error 5 injected by the test suite\n"}}, nil, nil),
call("exit", stub.ExpectArgs{hst.ExitFailure}, stub.PanicExit, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, nil, nil), // stub terminates this goroutine
}}}}, nil},
{"start logger signalread", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, -1, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, stub.UniqueError(5)),
call("getLogger", stub.ExpectArgs{}, log.Default(), nil),
call("exit", stub.ExpectArgs{hst.ExitFailure}, stub.PanicExit, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, []byte{}, stub.UniqueError(4)),
call("fatalf", stub.ExpectArgs{"cannot read from signal pipe: %v", []any{stub.UniqueError(4)}}, nil, nil),
}}}}, nil},
{"serve", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, -1, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, nil),
call("containerServe", stub.ExpectArgs{templateParams}, nil, stub.UniqueError(3)),
call("fatal", stub.ExpectArgs{[]any{"cannot configure container: unique error 3 injected by the test suite\n"}}, nil, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, nil, nil), // stub terminates this goroutine
}}}}, nil},
{"seccomp", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, -1, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, nil),
call("containerServe", stub.ExpectArgs{templateParams}, nil, nil),
call("seccompLoad", stub.ExpectArgs{shimPreset, seccomp.AllowMultiarch}, nil, stub.UniqueError(2)),
call("fatalf", stub.ExpectArgs{"cannot load syscall filter: %v", []any{stub.UniqueError(2)}}, nil, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, nil, nil), // stub terminates this goroutine
}}}}, nil},
{"exited closesetup earlyrequested", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, stub.UniqueError(1)),
call("verbosef", stub.ExpectArgs{"cannot close setup pipe: %v", []any{stub.UniqueError(1)}}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, 0, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, nil),
call("containerServe", stub.ExpectArgs{templateParams}, nil, nil),
call("seccompLoad", stub.ExpectArgs{shimPreset, seccomp.AllowMultiarch}, nil, nil),
call("containerWait", stub.ExpectArgs{templateParams}, nil, makeExitError(1<<8)),
call("exit", stub.ExpectArgs{1}, stub.PanicExit, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, []byte{shimMsgExitRequested}, nil),
call("exit", stub.ExpectArgs{hst.ExitRequest}, stub.PanicExit, unblockNotifyContext),
}}}}, nil},
{"exited requested", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, 0, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, nil),
call("containerServe", stub.ExpectArgs{templateParams}, nil, nil),
call("seccompLoad", stub.ExpectArgs{shimPreset, seccomp.AllowMultiarch}, nil, nil),
call("containerWait", stub.ExpectArgs{templateParams}, nil, makeExitError(1<<8)),
call("exit", stub.ExpectArgs{1}, stub.PanicExit, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, []byte{shimMsgExitRequested}, unblockNotifyContext),
call("notifyContextStop", stub.ExpectArgs{}, nil, nil),
call("rcRead", stub.ExpectArgs{}, nil, nil), // stub terminates this goroutine
}}}}, nil},
{"canceled orphaned", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, -1, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, nil),
call("containerServe", stub.ExpectArgs{templateParams}, nil, nil),
call("seccompLoad", stub.ExpectArgs{shimPreset, seccomp.AllowMultiarch}, nil, nil),
call("containerWait", stub.ExpectArgs{templateParams}, nil, context.Canceled),
call("exit", stub.ExpectArgs{hst.ExitCancel}, stub.PanicExit, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, []byte{shimMsgOrphaned}, nil),
call("exit", stub.ExpectArgs{hst.ExitOrphan}, stub.PanicExit, nil),
}}}}, nil},
{"strangewait invalidmsg", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, -1, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, nil),
call("containerServe", stub.ExpectArgs{templateParams}, nil, nil),
call("seccompLoad", stub.ExpectArgs{shimPreset, seccomp.AllowMultiarch}, nil, nil),
call("containerWait", stub.ExpectArgs{templateParams}, nil, stub.UniqueError(0)),
call("verbosef", stub.ExpectArgs{"cannot wait: %v", []any{stub.UniqueError(0)}}, nil, nil),
call("exit", stub.ExpectArgs{127}, stub.PanicExit, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, []byte{0xff}, nil),
call("fatalf", stub.ExpectArgs{"got invalid message %d from signal handler", []any{byte(0xff)}}, nil, nil),
}}}}, nil},
{"success", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{
call("getMsg", stub.ExpectArgs{}, nil, nil),
call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil),
call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", templateState, nil}, nil, nil),
call("swapVerbose", stub.ExpectArgs{true}, false, nil),
call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil),
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("closeReceive", stub.ExpectArgs{}, nil, nil),
call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, -1, nil),
call("containerStart", stub.ExpectArgs{templateParams}, nil, nil),
call("containerServe", stub.ExpectArgs{templateParams}, nil, nil),
call("seccompLoad", stub.ExpectArgs{shimPreset, seccomp.AllowMultiarch}, nil, nil),
call("containerWait", stub.ExpectArgs{templateParams}, nil, nil),
// deferred
call("wKeepAlive", stub.ExpectArgs{}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("rcRead", stub.ExpectArgs{}, []byte{shimMsgInvalid}, nil),
call("verbose", stub.ExpectArgs{[]any{"sa_sigaction got invalid siginfo"}}, nil, nil),
call("rcRead", stub.ExpectArgs{}, []byte{shimMsgBadPID}, nil),
call("verbose", stub.ExpectArgs{[]any{"got SIGCONT from unexpected process"}}, nil, nil),
call("rcRead", stub.ExpectArgs{}, nil, nil), // stub terminates this goroutine
}}}}, nil},
})
}
// errorOp implements a noop outcomeOp that unconditionally returns [stub.UniqueError].
type errorOp stub.UniqueError
func (e errorOp) toSystem(*outcomeStateSys) error { return stub.UniqueError(e) }
func (e errorOp) toContainer(*outcomeStateParams) error { return stub.UniqueError(e) }

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"encoding/gob"
@@ -6,6 +6,7 @@ import (
"syscall"
"hakurei.app/container/fhs"
"hakurei.app/internal/validate"
)
func init() { gob.Register(spAccountOp{}) }
@@ -21,7 +22,7 @@ func (s spAccountOp) toSystem(state *outcomeStateSys) error {
}
// default is applied in toContainer
if state.Container.Username != "" && !isValidUsername(state.Container.Username) {
if state.Container.Username != "" && !validate.IsValidUsername(state.Container.Username) {
return newWithMessage(fmt.Sprintf("invalid user name %q", state.Container.Username))
}
return nil

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"os"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"encoding/gob"
@@ -16,6 +16,7 @@ import (
"hakurei.app/container/fhs"
"hakurei.app/container/seccomp"
"hakurei.app/hst"
"hakurei.app/internal/validate"
"hakurei.app/message"
"hakurei.app/system"
"hakurei.app/system/acl"
@@ -243,7 +244,7 @@ func (s *spFilesystemOp) toSystem(state *outcomeStateSys) error {
continue
}
if ok, err := deepContainsH(p[0], hidePaths[i]); err != nil {
if ok, err := validate.DeepContainsH(p[0], hidePaths[i]); err != nil {
return &hst.AppError{Step: "determine path hiding outcome", Err: err}
} else if ok {
hidePathMatch[i] = true

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"errors"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"encoding/gob"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"syscall"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"encoding/gob"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"bytes"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"encoding/gob"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"testing"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"encoding/gob"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"testing"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"encoding/gob"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"testing"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"encoding/gob"

View File

@@ -1,4 +1,4 @@
package app
package outcome
import (
"os"

52
internal/state/data.go Normal file
View File

@@ -0,0 +1,52 @@
package state
import (
"encoding/gob"
"fmt"
"io"
"os"
"hakurei.app/hst"
)
// entryEncode encodes [hst.State] into [io.Writer] with the state entry header.
// entryEncode does not validate the embedded [hst.Config] value.
//
// A non-nil error returned by entryEncode is of type [hst.AppError].
func entryEncode(w io.Writer, s *hst.State) error {
if err := entryWriteHeader(w, s.Enablements.Unwrap()); err != nil {
return &hst.AppError{Step: "encode state header", Err: err}
} else if err = gob.NewEncoder(w).Encode(s); err != nil {
return &hst.AppError{Step: "encode state body", Err: err}
} else {
return nil
}
}
// entryDecodeHeader calls entryReadHeader, returning [hst.AppError] for a non-nil error.
func entryDecodeHeader(r io.Reader) (hst.Enablement, error) {
if et, err := entryReadHeader(r); err != nil {
return 0, &hst.AppError{Step: "decode state header", Err: err}
} else {
return et, nil
}
}
// entryDecode decodes [hst.State] from [io.Reader] and stores the result in the value pointed to by p.
// entryDecode validates the embedded [hst.Config] value.
//
// A non-nil error returned by entryDecode is of type [hst.AppError].
func entryDecode(r io.Reader, p *hst.State) (hst.Enablement, error) {
if et, err := entryDecodeHeader(r); err != nil {
return et, err
} else if err = gob.NewDecoder(r).Decode(&p); err != nil {
return et, &hst.AppError{Step: "decode state body", Err: err}
} else if err = p.Config.Validate(); err != nil {
return et, err
} else if p.Enablements.Unwrap() != et {
return et, &hst.AppError{Step: "validate state enablement", Err: os.ErrInvalid,
Msg: fmt.Sprintf("state entry %s has unexpected enablement byte %#x, %#x", p.ID.String(), byte(p.Enablements.Unwrap()), byte(et))}
} else {
return et, nil
}
}

145
internal/state/data_test.go Normal file
View File

@@ -0,0 +1,145 @@
package state
import (
"bytes"
"encoding/gob"
"errors"
"io"
"os"
"reflect"
"strings"
"testing"
"time"
"hakurei.app/container/stub"
"hakurei.app/hst"
)
func TestEntryData(t *testing.T) {
t.Parallel()
mustEncodeGob := func(e any) string {
var buf bytes.Buffer
if err := gob.NewEncoder(&buf).Encode(e); err != nil {
t.Fatalf("cannot encode invalid state: %v", err)
return "\x00" // not reached
} else {
return buf.String()
}
}
templateStateGob := mustEncodeGob(newTemplateState())
testCases := []struct {
name string
data string
s *hst.State
err error
}{
{"invalid header", "\x00\xff\xca\xfe\xff\xff\xff\x00", nil, &hst.AppError{
Step: "decode state header", Err: errors.New("unexpected revision ffff")}},
{"invalid gob", "\x00\xff\xca\xfe\x00\x00\xff\x00", nil, &hst.AppError{
Step: "decode state body", Err: io.EOF}},
{"invalid config", "\x00\xff\xca\xfe\x00\x00\xff\x00" + mustEncodeGob(new(hst.State)), new(hst.State), &hst.AppError{
Step: "validate configuration", Err: hst.ErrConfigNull,
Msg: "invalid configuration"}},
{"inconsistent enablement", "\x00\xff\xca\xfe\x00\x00\xff\x00" + templateStateGob, newTemplateState(), &hst.AppError{
Step: "validate state enablement", Err: os.ErrInvalid,
Msg: "state entry aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa has unexpected enablement byte 0xd, 0xff"}},
{"template", "\x00\xff\xca\xfe\x00\x00\x0d\xf2" + templateStateGob, newTemplateState(), nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
t.Run("encode", func(t *testing.T) {
if tc.s == nil || tc.s.Config == nil {
return
}
t.Parallel()
var buf bytes.Buffer
if err := entryEncode(&buf, tc.s); err != nil {
t.Fatalf("entryEncode: error = %v", err)
}
if tc.err == nil {
// Gob encoding is not guaranteed to be deterministic.
// While the current implementation mostly is, it has randomised order
// for iterating over maps, and hst.Config holds a map for environ.
var got hst.State
if et, err := entryDecode(&buf, &got); err != nil {
t.Fatalf("entryDecode: error = %v", err)
} else if stateEt := got.Enablements.Unwrap(); et != stateEt {
t.Fatalf("entryDecode: et = %x, state %x", et, stateEt)
}
if !reflect.DeepEqual(&got, tc.s) {
t.Errorf("entryEncode: %x", buf.Bytes())
}
} else if testing.Verbose() {
t.Logf("%x", buf.String())
}
})
t.Run("decode", func(t *testing.T) {
t.Parallel()
var got hst.State
if et, err := entryDecode(strings.NewReader(tc.data), &got); !reflect.DeepEqual(err, tc.err) {
t.Fatalf("entryDecode: error = %#v, want %#v", err, tc.err)
} else if err != nil {
return
} else if stateEt := got.Enablements.Unwrap(); et != stateEt {
t.Fatalf("entryDecode: et = %x, state %x", et, stateEt)
}
if !reflect.DeepEqual(&got, tc.s) {
t.Errorf("entryDecode: %#v, want %#v", &got, tc.s)
}
})
})
}
t.Run("encode fault", func(t *testing.T) {
t.Parallel()
s := newTemplateState()
t.Run("gob", func(t *testing.T) {
var want = &hst.AppError{Step: "encode state body", Err: stub.UniqueError(0xcafe)}
if err := entryEncode(stubNErrorWriter(entryHeaderSize), s); !reflect.DeepEqual(err, want) {
t.Errorf("entryEncode: error = %#v, want %#v", err, want)
}
})
t.Run("header", func(t *testing.T) {
var want = &hst.AppError{Step: "encode state header", Err: stub.UniqueError(0xcafe)}
if err := entryEncode(stubNErrorWriter(entryHeaderSize-1), s); !reflect.DeepEqual(err, want) {
t.Errorf("entryEncode: error = %#v, want %#v", err, want)
}
})
})
}
// newTemplateState returns the address of a new template [hst.State] struct.
func newTemplateState() *hst.State {
return &hst.State{
ID: hst.ID(bytes.Repeat([]byte{0xaa}, len(hst.ID{}))),
PID: 0xcafebabe,
ShimPID: 0xdeadbeef,
Config: hst.Template(),
Time: time.Unix(0, 0),
}
}
// stubNErrorWriter returns an error for writes above a certain size.
type stubNErrorWriter int
func (w stubNErrorWriter) Write(p []byte) (n int, err error) {
if len(p) > int(w) {
return int(w), stub.UniqueError(0xcafe)
}
return io.Discard.Write(p)
}

86
internal/state/header.go Normal file
View File

@@ -0,0 +1,86 @@
package state
import (
"encoding/hex"
"errors"
"io"
"os"
"strconv"
"syscall"
"hakurei.app/hst"
)
const (
// entryHeaderMagic are magic bytes at the beginning of the state entry file.
entryHeaderMagic = "\x00\xff\xca\xfe"
// entryHeaderRevision follows entryHeaderMagic and is incremented for revisions of the format.
entryHeaderRevision = "\x00\x00"
// entryHeaderSize is the fixed size of the header in bytes, including the enablement byte and its complement.
entryHeaderSize = len(entryHeaderMagic+entryHeaderRevision) + 2
)
// entryHeaderEncode encodes a state entry header for a [hst.Enablement] byte.
func entryHeaderEncode(et hst.Enablement) *[entryHeaderSize]byte {
data := [entryHeaderSize]byte([]byte(
entryHeaderMagic + entryHeaderRevision + string([]hst.Enablement{et, ^et}),
))
return &data
}
// entryHeaderDecode validates a state entry header and returns the [hst.Enablement] byte.
func entryHeaderDecode(data *[entryHeaderSize]byte) (hst.Enablement, error) {
if magic := data[:len(entryHeaderMagic)]; string(magic) != entryHeaderMagic {
return 0, errors.New("invalid header " + hex.EncodeToString(magic))
}
if revision := data[len(entryHeaderMagic):len(entryHeaderMagic+entryHeaderRevision)]; string(revision) != entryHeaderRevision {
return 0, errors.New("unexpected revision " + hex.EncodeToString(revision))
}
et := data[len(entryHeaderMagic+entryHeaderRevision)]
if et != ^data[len(entryHeaderMagic+entryHeaderRevision)+1] {
return 0, errors.New("header enablement value is inconsistent")
}
return hst.Enablement(et), nil
}
// EntrySizeError is returned for a file too small to hold a state entry header.
type EntrySizeError struct {
Name string
Size int64
}
func (e *EntrySizeError) Error() string {
if e.Name == "" {
return "state entry file is too short"
}
return "state entry file " + strconv.Quote(e.Name) + " is too short"
}
// entryCheckFile checks whether [os.FileInfo] refers to a file that might hold [hst.State].
func entryCheckFile(fi os.FileInfo) error {
if fi.IsDir() {
return syscall.EISDIR
}
if s := fi.Size(); s <= int64(entryHeaderSize) {
return &EntrySizeError{Name: fi.Name(), Size: s}
}
return nil
}
// entryReadHeader reads [hst.Enablement] from an [io.Reader].
func entryReadHeader(r io.Reader) (hst.Enablement, error) {
var data [entryHeaderSize]byte
if n, err := r.Read(data[:]); err != nil {
return 0, err
} else if n != entryHeaderSize {
return 0, &EntrySizeError{Size: int64(n)}
}
return entryHeaderDecode(&data)
}
// entryWriteHeader writes [hst.Enablement] header to an [io.Writer].
func entryWriteHeader(w io.Writer, et hst.Enablement) error {
_, err := w.Write(entryHeaderEncode(et)[:])
return err
}

View File

@@ -0,0 +1,184 @@
package state
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"reflect"
"syscall"
"testing"
"time"
"hakurei.app/hst"
)
func TestEntryHeader(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
data [entryHeaderSize]byte
et hst.Enablement
err error
}{
{"complement mismatch", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00,
0x0a, 0xf6}, 0,
errors.New("header enablement value is inconsistent")},
{"unexpected revision", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0xff, 0xff}, 0,
errors.New("unexpected revision ffff")},
{"invalid header", [entryHeaderSize]byte{0x00, 0xfe, 0xca, 0xfe}, 0,
errors.New("invalid header 00fecafe")},
{"success high", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00,
0xff, 0x00}, 0xff, nil},
{"success", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00,
0x09, 0xf6}, hst.EWayland | hst.EPulse, nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
t.Run("encode", func(t *testing.T) {
if tc.err != nil {
return
}
t.Parallel()
if got := entryHeaderEncode(tc.et); *got != tc.data {
t.Errorf("entryHeaderEncode: %x, want %x", *got, tc.data)
}
t.Run("write", func(t *testing.T) {
var buf bytes.Buffer
if err := entryWriteHeader(&buf, tc.et); err != nil {
t.Fatalf("entryWriteHeader: error = %v", err)
}
if got := ([entryHeaderSize]byte)(buf.Bytes()); got != tc.data {
t.Errorf("entryWriteHeader: %x, want %x", got, tc.data)
}
})
})
t.Run("decode", func(t *testing.T) {
t.Parallel()
got, err := entryHeaderDecode(&tc.data)
if !reflect.DeepEqual(err, tc.err) {
t.Fatalf("entryHeaderDecode: error = %#v, want %#v", err, tc.err)
}
if err != nil {
return
}
if got != tc.et {
t.Errorf("entryHeaderDecode: et = %q, want %q", got, tc.et)
}
if got, err = entryReadHeader(bytes.NewReader(tc.data[:])); err != nil {
t.Fatalf("entryReadHeader: error = %#v", err)
} else if got != tc.et {
t.Errorf("entryReadHeader: et = %q, want %q", got, tc.et)
}
})
})
}
}
func TestEntrySizeError(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
err error
want string
}{
{"size only", &EntrySizeError{Size: 0xdeadbeef},
`state entry file is too short`},
{"full", &EntrySizeError{Name: "nonexistent", Size: 0xdeadbeef},
`state entry file "nonexistent" is too short`},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.err.Error(); got != tc.want {
t.Errorf("Error: %s, want %s", got, tc.want)
}
})
}
}
func TestEntryCheckFile(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
fi os.FileInfo
err error
}{
{"dir", &stubFi{name: "dir", isDir: true},
syscall.EISDIR},
{"short", stubFi{name: "short", size: 8},
&EntrySizeError{Name: "short", Size: 8}},
{"success", stubFi{size: 9}, nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if err := entryCheckFile(tc.fi); !reflect.DeepEqual(err, tc.err) {
t.Errorf("entryCheckFile: error = %#v, want %#v", err, tc.err)
}
})
}
}
func TestEntryReadHeader(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
newR func() io.Reader
err error
}{
{"eof", func() io.Reader { return bytes.NewReader([]byte{}) }, io.EOF},
{"short", func() io.Reader { return bytes.NewReader([]byte{0}) }, &EntrySizeError{Size: 1}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if _, err := entryReadHeader(tc.newR()); !reflect.DeepEqual(err, tc.err) {
t.Errorf("entryReadHeader: error = %#v, want %#v", err, tc.err)
}
})
}
}
// stubFi partially implements [os.FileInfo] using hardcoded values.
type stubFi struct {
name string
size int64
isDir bool
}
func (fi stubFi) Name() string {
if fi.name == "" {
panic("unreachable")
}
return fi.name
}
func (fi stubFi) Size() int64 {
if fi.size < 0 {
panic("unreachable")
}
return fi.size
}
func (fi stubFi) IsDir() bool { return fi.isDir }
func (fi stubFi) Mode() fs.FileMode { panic("unreachable") }
func (fi stubFi) ModTime() time.Time { panic("unreachable") }
func (fi stubFi) Sys() any { panic("unreachable") }

View File

@@ -3,6 +3,8 @@ package state
import (
"errors"
"maps"
"hakurei.app/hst"
)
var (
@@ -14,20 +16,22 @@ Joiner is the interface that wraps the Join method.
The Join function uses Joiner if available.
*/
type Joiner interface{ Join() (Entries, error) }
type Joiner interface {
Join() (map[hst.ID]*hst.State, error)
}
// Join returns joined state entries of all active aids.
func Join(s Store) (Entries, error) {
// Join returns joined state entries of all active identities.
func Join(s Store) (map[hst.ID]*hst.State, error) {
if j, ok := s.(Joiner); ok {
return j.Join()
}
var (
aids []int
entries = make(Entries)
entries = make(map[hst.ID]*hst.State)
el int
res Entries
res map[hst.ID]*hst.State
loadErr error
)

161
internal/state/segment.go Normal file
View File

@@ -0,0 +1,161 @@
package state
import (
"errors"
"fmt"
"iter"
"os"
"strconv"
"sync"
"hakurei.app/container/check"
"hakurei.app/hst"
"hakurei.app/internal/lockedfile"
)
// stateEntryHandle is a handle on a state entry retrieved from a storeHandle.
// Must only be used while its parent storeHandle.fileMu is held.
type stateEntryHandle struct {
// Error returned while decoding pathname.
// A non-nil value disables stateEntryHandle.
decodeErr error
// Checked path to entry file.
pathname *check.Absolute
hst.ID
}
// open opens the underlying state entry file, returning [hst.AppError] for a non-nil error.
func (eh *stateEntryHandle) open(flag int, perm os.FileMode) (*os.File, error) {
if eh.decodeErr != nil {
return nil, eh.decodeErr
}
if f, err := os.OpenFile(eh.pathname.String(), flag, perm); err != nil {
return nil, &hst.AppError{Step: "open state entry", Err: err}
} else {
return f, nil
}
}
// destroy removes the underlying state entry file, returning [hst.AppError] for a non-nil error.
func (eh *stateEntryHandle) destroy() error {
// destroy does not go through open
if eh.decodeErr != nil {
return eh.decodeErr
}
if err := os.Remove(eh.pathname.String()); err != nil {
return &hst.AppError{Step: "destroy state entry", Err: err}
}
return nil
}
// save encodes [hst.State] and writes it to the underlying file.
// An error is returned if a file already exists with the same identifier.
// save does not validate the embedded [hst.Config].
func (eh *stateEntryHandle) save(state *hst.State) error {
f, err := eh.open(os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
err = entryEncode(f, state)
if closeErr := f.Close(); closeErr != nil && err == nil {
err = &hst.AppError{Step: "close state file", Err: closeErr}
}
return err
}
// load loads and validates the state entry header, and returns the [hst.Enablement] byte.
// for a non-nil v, the full state payload is decoded and stored in the value pointed to by v.
// load validates the embedded hst.Config value.
func (eh *stateEntryHandle) load(v *hst.State) (hst.Enablement, error) {
f, err := eh.open(os.O_RDONLY, 0)
if err != nil {
return 0, err
}
var et hst.Enablement
if v != nil {
et, err = entryDecode(f, v)
if err == nil && v.ID != eh.ID {
err = &hst.AppError{Step: "validate state identifier", Err: os.ErrInvalid,
Msg: fmt.Sprintf("state entry %s has unexpected id %s", eh.ID.String(), v.ID.String())}
}
} else {
et, err = entryDecodeHeader(f)
}
if closeErr := f.Close(); closeErr != nil && err == nil {
err = &hst.AppError{Step: "close state file", Err: closeErr}
}
return et, err
}
// storeHandle is a handle on a stateStore segment.
// Initialised by stateStore.identityHandle.
type storeHandle struct {
// Identity of instances tracked by this segment.
identity int
// Pathname of directory that the segment referred to by storeHandle is rooted in.
path *check.Absolute
// Inter-process mutex to synchronise operations against resources in this segment.
fileMu *lockedfile.Mutex
// Must be held alongside fileMu.
mu sync.Mutex
}
// entries returns an iterator over all stateEntryHandle held in this segment.
// Must be called while holding a lock on mu and fileMu.
// A non-nil error attached to a stateEntryHandle indicates a malformed identifier and is of type [hst.AppError].
// A non-nil error returned by entries is of type [hst.AppError].
func (h *storeHandle) entries() (iter.Seq[*stateEntryHandle], int, error) {
// for error reporting
const step = "read store segment entries"
// read directory contents, should only contain storeMutexName and identifier
var entries []os.DirEntry
if pl, err := os.ReadDir(h.path.String()); err != nil {
return nil, -1, &hst.AppError{Step: step, Err: err}
} else {
entries = pl
}
// expects lock file
l := len(entries)
if l > 0 {
l--
}
return func(yield func(*stateEntryHandle) bool) {
for _, ent := range entries {
var eh = stateEntryHandle{pathname: h.path.Append(ent.Name())}
// this should never happen
if ent.IsDir() {
eh.decodeErr = &hst.AppError{Step: step,
Err: errors.New("unexpected directory " + strconv.Quote(ent.Name()) + " in store")}
goto out
}
// silently skip lock file
if ent.Name() == storeMutexName {
continue
}
// this either indicates a serious bug or external interference
if err := eh.ID.UnmarshalText([]byte(ent.Name())); err != nil {
eh.decodeErr = &hst.AppError{Step: "decode store segment entry", Err: err}
goto out
}
out:
if !yield(&eh) {
break
}
}
}, l, nil
}

View File

@@ -0,0 +1,259 @@
package state
import (
"errors"
"iter"
"os"
"reflect"
"slices"
"strings"
"syscall"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/hst"
"hakurei.app/internal/lockedfile"
)
func TestStateEntryHandle(t *testing.T) {
t.Parallel()
t.Run("lockout", func(t *testing.T) {
t.Parallel()
wantErr := func() error { return stub.UniqueError(0) }
eh := stateEntryHandle{decodeErr: wantErr(), pathname: check.MustAbs("/proc/nonexistent")}
if _, err := eh.open(-1, 0); !reflect.DeepEqual(err, wantErr()) {
t.Errorf("open: error = %v, want %v", err, wantErr())
}
if err := eh.destroy(); !reflect.DeepEqual(err, wantErr()) {
t.Errorf("destroy: error = %v, want %v", err, wantErr())
}
if err := eh.save(nil); !reflect.DeepEqual(err, wantErr()) {
t.Errorf("save: error = %v, want %v", err, wantErr())
}
if _, err := eh.load(nil); !reflect.DeepEqual(err, wantErr()) {
t.Errorf("load: error = %v, want %v", err, wantErr())
}
})
t.Run("od", func(t *testing.T) {
t.Parallel()
{
eh := stateEntryHandle{pathname: check.MustAbs(t.TempDir()).Append("entry")}
if f, err := eh.open(os.O_CREATE|syscall.O_EXCL, 0); err != nil {
t.Fatalf("open: error = %v", err)
} else if err = f.Close(); err != nil {
t.Errorf("Close: error = %v", err)
}
if err := eh.destroy(); err != nil {
t.Fatalf("destroy: error = %v", err)
}
}
t.Run("nonexistent", func(t *testing.T) {
t.Parallel()
eh := stateEntryHandle{pathname: check.MustAbs("/proc/nonexistent")}
wantErrOpen := &hst.AppError{Step: "open state entry",
Err: &os.PathError{Op: "open", Path: "/proc/nonexistent", Err: syscall.ENOENT}}
if _, err := eh.open(os.O_CREATE|syscall.O_EXCL, 0); !reflect.DeepEqual(err, wantErrOpen) {
t.Errorf("open: error = %#v, want %#v", err, wantErrOpen)
}
wantErrDestroy := &hst.AppError{Step: "destroy state entry",
Err: &os.PathError{Op: "remove", Path: "/proc/nonexistent", Err: syscall.ENOENT}}
if err := eh.destroy(); !reflect.DeepEqual(err, wantErrDestroy) {
t.Errorf("destroy: error = %#v, want %#v", err, wantErrDestroy)
}
})
})
t.Run("saveload", func(t *testing.T) {
t.Parallel()
eh := stateEntryHandle{pathname: check.MustAbs(t.TempDir()).Append("entry"),
ID: newTemplateState().ID}
if err := eh.save(newTemplateState()); err != nil {
t.Fatalf("save: error = %v", err)
}
t.Run("validate", func(t *testing.T) {
t.Parallel()
t.Run("internal", func(t *testing.T) {
t.Parallel()
var got hst.State
if f, err := os.Open(eh.pathname.String()); err != nil {
t.Fatal(err.Error())
} else if _, err = entryDecode(f, &got); err != nil {
t.Fatalf("entryDecode: error = %v", err)
} else if err = f.Close(); err != nil {
t.Fatal(f.Close())
}
if want := newTemplateState(); !reflect.DeepEqual(&got, want) {
t.Errorf("entryDecode: %#v, want %#v", &got, want)
}
})
t.Run("load header only", func(t *testing.T) {
t.Parallel()
if et, err := eh.load(nil); err != nil {
t.Fatalf("load: error = %v", err)
} else if want := newTemplateState().Enablements.Unwrap(); et != want {
t.Errorf("load: et = %x, want %x", et, want)
}
})
t.Run("load", func(t *testing.T) {
t.Parallel()
var got hst.State
if _, err := eh.load(&got); err != nil {
t.Fatalf("load: error = %v", err)
} else if want := newTemplateState(); !reflect.DeepEqual(&got, want) {
t.Errorf("load: %#v, want %#v", &got, want)
}
})
t.Run("load inconsistent", func(t *testing.T) {
t.Parallel()
wantErr := &hst.AppError{Step: "validate state identifier", Err: os.ErrInvalid,
Msg: "state entry 00000000000000000000000000000000 has unexpected id aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
ehi := stateEntryHandle{pathname: eh.pathname}
if _, err := ehi.load(new(hst.State)); !reflect.DeepEqual(err, wantErr) {
t.Errorf("load: error = %#v, want %#v", err, wantErr)
}
})
})
})
}
func TestStoreHandle(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
ents [2][]string
want func(newEh func(err error, name string) *stateEntryHandle) []*stateEntryHandle
ext func(t *testing.T, entries iter.Seq[*stateEntryHandle], n int)
}{
{"errors", [2][]string{{
"e81eb203b4190ac5c3842ef44d429945",
"lock",
"f0-invalid",
}, {
"f1-directory",
}}, func(newEh func(err error, name string) *stateEntryHandle) []*stateEntryHandle {
return []*stateEntryHandle{
newEh(nil, "e81eb203b4190ac5c3842ef44d429945"),
newEh(&hst.AppError{Step: "decode store segment entry",
Err: hst.IdentifierDecodeError{Err: hst.ErrIdentifierLength}}, "f0-invalid"),
newEh(&hst.AppError{Step: "read store segment entries",
Err: errors.New(`unexpected directory "f1-directory" in store`)}, "f1-directory"),
}
}, nil},
{"success", [2][]string{{
"e81eb203b4190ac5c3842ef44d429945",
"7958cfbb9272d9cf9cfd61c85afa13f1",
"d0b5f7446dd5bd3424ff2f7ac9cace1e",
"c8c8e2c4aea5c32fe47240ff8caa874e",
"fa0d30b249d80f155a1f80ceddcc32f2",
"lock",
}}, func(newEh func(err error, name string) *stateEntryHandle) []*stateEntryHandle {
return []*stateEntryHandle{
newEh(nil, "7958cfbb9272d9cf9cfd61c85afa13f1"),
newEh(nil, "c8c8e2c4aea5c32fe47240ff8caa874e"),
newEh(nil, "d0b5f7446dd5bd3424ff2f7ac9cace1e"),
newEh(nil, "e81eb203b4190ac5c3842ef44d429945"),
newEh(nil, "fa0d30b249d80f155a1f80ceddcc32f2"),
}
}, func(t *testing.T, entries iter.Seq[*stateEntryHandle], n int) {
if n != 5 {
t.Fatalf("entries: n = %d", n)
}
// check partial drain
for range entries {
break
}
}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
p := check.MustAbs(t.TempDir()).Append("segment")
if err := os.Mkdir(p.String(), 0700); err != nil {
t.Fatal(err.Error())
}
createEntries(t, p, tc.ents)
var got []*stateEntryHandle
if entries, n, err := (&storeHandle{
identity: -0xbad,
path: p,
fileMu: lockedfile.MutexAt(p.Append("lock").String()),
}).entries(); err != nil {
t.Fatalf("entries: error = %v", err)
} else {
got = slices.AppendSeq(make([]*stateEntryHandle, 0, n), entries)
if tc.ext != nil {
tc.ext(t, entries, n)
}
}
slices.SortFunc(got, func(a, b *stateEntryHandle) int { return strings.Compare(a.pathname.String(), b.pathname.String()) })
want := tc.want(func(err error, name string) *stateEntryHandle {
eh := stateEntryHandle{decodeErr: err, pathname: p.Append(name)}
if err == nil {
if err = eh.UnmarshalText([]byte(name)); err != nil {
t.Fatalf("UnmarshalText: error = %v", err)
}
}
return &eh
})
if !reflect.DeepEqual(got, want) {
t.Errorf("entries: %q, want %q", got, want)
}
})
}
t.Run("nonexistent", func(t *testing.T) {
var wantErr = &hst.AppError{Step: "read store segment entries", Err: &os.PathError{
Op: "open",
Path: "/proc/nonexistent",
Err: syscall.ENOENT,
}}
if _, _, err := (&storeHandle{
identity: -0xbad,
path: check.MustAbs("/proc/nonexistent"),
}).entries(); !reflect.DeepEqual(err, wantErr) {
t.Fatalf("entries: error = %#v, want %#v", err, wantErr)
}
})
}
// createEntries creates file and directory entries in the specified prefix.
func createEntries(t *testing.T, prefix *check.Absolute, ents [2][]string) {
for _, s := range ents[0] {
if f, err := os.OpenFile(prefix.Append(s).String(), os.O_CREATE|os.O_EXCL, 0600); err != nil {
t.Fatal(err.Error())
} else if err = f.Close(); err != nil {
t.Fatal(err.Error())
}
}
for _, s := range ents[1] {
if err := os.Mkdir(prefix.Append(s).String(), 0700); err != nil {
t.Fatal(err.Error())
}
}
}

131
internal/state/state.go Normal file
View File

@@ -0,0 +1,131 @@
// Package state provides cross-process state tracking for hakurei container instances.
package state
import (
"strconv"
"hakurei.app/container/check"
"hakurei.app/hst"
"hakurei.app/message"
)
/* this provides an implementation of Store on top of the improved state tracking to ease in the changes */
type Store interface {
// Do calls f exactly once and ensures store exclusivity until f returns.
// Returns whether f is called and any errors during the locking process.
// Cursor provided to f becomes invalid as soon as f returns.
Do(identity int, f func(c Cursor)) (ok bool, err error)
// List queries the store and returns a list of identities known to the store.
// Note that some or all returned identities might not have any active apps.
List() (identities []int, err error)
}
func (s *stateStore) Do(identity int, f func(c Cursor)) (bool, error) {
if h, err := s.identityHandle(identity); err != nil {
return false, err
} else {
return h.do(f)
}
}
// storeAdapter satisfies [Store] via stateStore.
type storeAdapter struct {
msg message.Msg
*stateStore
}
func (s storeAdapter) List() ([]int, error) {
segments, n, err := s.segments()
if err != nil {
return nil, err
}
identities := make([]int, 0, n)
for si := range segments {
if si.err != nil {
if m, ok := message.GetMessage(err); ok {
s.msg.Verbose(m)
} else {
// unreachable
return nil, err
}
continue
}
identities = append(identities, si.identity)
}
return identities, nil
}
// NewMulti returns an instance of the multi-file store.
func NewMulti(msg message.Msg, prefix *check.Absolute) Store {
return storeAdapter{msg, newStore(prefix.Append("state"))}
}
// Cursor provides access to the store of an identity.
type Cursor interface {
Save(state *hst.State) error
Destroy(id hst.ID) error
Load() (map[hst.ID]*hst.State, error)
Len() (int, error)
}
// do implements stateStore.Do on storeHandle.
func (h *storeHandle) do(f func(c Cursor)) (bool, error) {
if unlock, err := h.fileMu.Lock(); err != nil {
return false, &hst.AppError{Step: "acquire lock on store segment " + strconv.Itoa(h.identity), Err: err}
} else {
defer unlock()
}
f(h)
return true, nil
}
/* these compatibility methods must only be called while fileMu is held */
func (h *storeHandle) Save(state *hst.State) error {
return (&stateEntryHandle{nil, h.path.Append(state.ID.String()), state.ID}).save(state)
}
func (h *storeHandle) Destroy(id hst.ID) error {
return (&stateEntryHandle{nil, h.path.Append(id.String()), id}).destroy()
}
func (h *storeHandle) Load() (map[hst.ID]*hst.State, error) {
entries, n, err := h.entries()
if err != nil {
return nil, err
}
r := make(map[hst.ID]*hst.State, n)
for eh := range entries {
if eh.decodeErr != nil {
err = eh.decodeErr
break
}
var s hst.State
if _, err = eh.load(&s); err != nil {
break
}
r[eh.ID] = &s
}
return r, err
}
func (h *storeHandle) Len() (int, error) {
entries, _, err := h.entries()
if err != nil {
return -1, err
}
var n int
for eh := range entries {
if eh.decodeErr != nil {
err = eh.decodeErr
}
n++
}
return n, err
}

View File

@@ -0,0 +1,120 @@
package state_test
import (
"log"
"math/rand"
"reflect"
"slices"
"testing"
"time"
"hakurei.app/container/check"
"hakurei.app/hst"
"hakurei.app/internal/state"
"hakurei.app/message"
)
func TestMulti(t *testing.T) {
s := state.NewMulti(message.NewMsg(log.New(log.Writer(), "multi: ", 0)), check.MustAbs(t.TempDir()))
t.Run("list empty store", func(t *testing.T) {
if identities, err := s.List(); err != nil {
t.Fatalf("List: error = %v", err)
} else if len(identities) != 0 {
t.Fatalf("List: identities = %#v", identities)
}
})
const (
insertEntryChecked = iota
insertEntryNoCheck
insertEntryOtherApp
tl
)
var tc [tl]hst.State
for i := 0; i < tl; i++ {
if err := hst.NewInstanceID(&tc[i].ID); err != nil {
t.Fatalf("cannot create dummy state: %v", err)
}
tc[i].PID = rand.Int()
tc[i].Config = hst.Template()
tc[i].Time = time.Now()
}
do := func(identity int, f func(c state.Cursor)) {
if ok, err := s.Do(identity, f); err != nil {
t.Fatalf("Do: ok = %v, error = %v", ok, err)
}
}
insert := func(i, identity int) {
do(identity, func(c state.Cursor) {
if err := c.Save(&tc[i]); err != nil {
t.Fatalf("Save: error = %v", err)
}
})
}
check := func(i, identity int) {
do(identity, func(c state.Cursor) {
if entries, err := c.Load(); err != nil {
t.Fatalf("Load: error = %v", err)
} else if got, ok := entries[tc[i].ID]; !ok {
t.Fatalf("Load: entry %s missing", &tc[i].ID)
} else {
got.Time = tc[i].Time
if !reflect.DeepEqual(got, &tc[i]) {
t.Fatalf("Load: entry %s got %#v, want %#v", &tc[i].ID, got, &tc[i])
}
}
})
}
// insert entry checked
insert(insertEntryChecked, 0)
check(insertEntryChecked, 0)
// insert entry unchecked
insert(insertEntryNoCheck, 0)
// insert entry different identity
insert(insertEntryOtherApp, 1)
check(insertEntryOtherApp, 1)
// check previous insertion
check(insertEntryNoCheck, 0)
// list identities
if identities, err := s.List(); err != nil {
t.Fatalf("List: error = %v", err)
} else {
slices.Sort(identities)
want := []int{0, 1}
if !slices.Equal(identities, want) {
t.Fatalf("List() = %#v, want %#v", identities, want)
}
}
// join store
if entries, err := state.Join(s); err != nil {
t.Fatalf("Join: error = %v", err)
} else if len(entries) != 3 {
t.Fatalf("Join(s) = %#v", entries)
}
// clear identity 1
do(1, func(c state.Cursor) {
if err := c.Destroy(tc[insertEntryOtherApp].ID); err != nil {
t.Fatalf("Destroy: error = %v", err)
}
})
do(1, func(c state.Cursor) {
if l, err := c.Len(); err != nil {
t.Fatalf("Len: error = %v", err)
} else if l != 0 {
t.Fatalf("Len: %d, want 0", l)
}
})
}

162
internal/state/store.go Normal file
View File

@@ -0,0 +1,162 @@
package state
import (
"errors"
"io/fs"
"iter"
"os"
"strconv"
"sync"
"syscall"
"hakurei.app/container/check"
"hakurei.app/hst"
"hakurei.app/internal/lockedfile"
)
// storeMutexName is the pathname of the file backing [lockedfile.Mutex] of a stateStore and storeHandle.
const storeMutexName = "lock"
// A stateStore keeps track of [hst.State] via a well-known filesystem accessible to all hakurei priv-side processes.
// Access to store data and related resources are synchronised on a per-segment basis via storeHandle.
type stateStore struct {
// Pathname of directory that the store is rooted in.
base *check.Absolute
// All currently known instances of storeHandle, keyed by their identity.
handles sync.Map
// Inter-process mutex to synchronise operations against the entire store.
// Held during List and when initialising previously unknown identities during Do.
// Must not be accessed directly. Callers should use the bigLock method instead.
fileMu *lockedfile.Mutex
// For creating the base directory.
mkdirOnce sync.Once
// Stored error value via mkdirOnce.
mkdirErr error
}
// bigLock acquires fileMu on stateStore.
// A non-nil error returned by bigLock is of type [hst.AppError].
func (s *stateStore) bigLock() (unlock func(), err error) {
s.mkdirOnce.Do(func() { s.mkdirErr = os.MkdirAll(s.base.String(), 0700) })
if s.mkdirErr != nil {
return nil, &hst.AppError{Step: "create state store directory", Err: s.mkdirErr}
}
if unlock, err = s.fileMu.Lock(); err != nil {
return nil, &hst.AppError{Step: "acquire lock on the state store", Err: err}
}
return
}
// identityHandle loads or initialises a storeHandle for identity.
// A non-nil error returned by identityHandle is of type [hst.AppError].
func (s *stateStore) identityHandle(identity int) (*storeHandle, error) {
h := new(storeHandle)
h.mu.Lock()
if v, ok := s.handles.LoadOrStore(identity, h); ok {
h = v.(*storeHandle)
} else {
// acquire big lock to initialise previously unknown segment handle
if unlock, err := s.bigLock(); err != nil {
return nil, err
} else {
defer unlock()
}
h.identity = identity
h.path = s.base.Append(strconv.Itoa(identity))
h.fileMu = lockedfile.MutexAt(h.path.Append(storeMutexName).String())
err := os.MkdirAll(h.path.String(), 0700)
h.mu.Unlock()
if err != nil && !errors.Is(err, fs.ErrExist) {
// handle methods will likely return ENOENT
s.handles.CompareAndDelete(identity, h)
return nil, &hst.AppError{Step: "create store segment directory", Err: err}
}
}
return h, nil
}
// segmentIdentity is produced by the iterator returned by stateStore.segments.
type segmentIdentity struct {
// Identity of the current segment.
identity int
// Error encountered while processing this segment.
err error
}
// segments returns an iterator over all segmentIdentity known to the store.
// To obtain a storeHandle on a segment, caller must then call identityHandle.
// A non-nil error returned by segments is of type [hst.AppError].
func (s *stateStore) segments() (iter.Seq[segmentIdentity], int, error) {
// read directory contents, should only contain storeMutexName and identity
var entries []os.DirEntry
// acquire big lock to read store segment list
if unlock, err := s.bigLock(); err != nil {
return nil, -1, err
} else {
entries, err = os.ReadDir(s.base.String())
unlock()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, -1, &hst.AppError{Step: "read store segments", Err: err}
}
}
// expects lock file
l := len(entries)
if l > 0 {
l--
}
return func(yield func(segmentIdentity) bool) {
// for error reporting
const step = "process store segment"
for _, ent := range entries {
si := segmentIdentity{identity: -1}
// should only be the big lock
if !ent.IsDir() {
if ent.Name() == storeMutexName {
continue
}
// this should never happen
si.err = &hst.AppError{Step: step, Err: syscall.EISDIR,
Msg: "skipped non-directory entry " + strconv.Quote(ent.Name())}
goto out
}
// failure paths either indicates a serious bug or external interference
if v, err := strconv.Atoi(ent.Name()); err != nil {
si.err = &hst.AppError{Step: step, Err: err,
Msg: "skipped non-identity entry " + strconv.Quote(ent.Name())}
goto out
} else if v < hst.IdentityMin || v > hst.IdentityMax {
si.err = &hst.AppError{Step: step, Err: syscall.ERANGE,
Msg: "skipped out of bounds entry " + strconv.Itoa(v)}
goto out
} else {
si.identity = v
}
out:
if !yield(si) {
break
}
}
}, l, nil
}
// newStore returns the address of a new instance of stateStore.
// Multiple instances of stateStore rooted in the same directory is supported, but discouraged.
func newStore(base *check.Absolute) *stateStore {
return &stateStore{base: base, fileMu: lockedfile.MutexAt(base.Append(storeMutexName).String())}
}

View File

@@ -0,0 +1,254 @@
package state
import (
"cmp"
"iter"
"os"
"reflect"
"slices"
"strconv"
"strings"
"syscall"
"testing"
"hakurei.app/container/check"
"hakurei.app/hst"
)
func TestStateStoreBigLock(t *testing.T) {
t.Parallel()
{
s := newStore(check.MustAbs(t.TempDir()).Append("state"))
for i := 0; i < 2; i++ { // check once behaviour
if unlock, err := s.bigLock(); err != nil {
t.Fatalf("bigLock: error = %v", err)
} else {
unlock()
}
}
}
t.Run("mkdir", func(t *testing.T) {
t.Parallel()
wantErr := &hst.AppError{Step: "create state store directory",
Err: &os.PathError{Op: "mkdir", Path: "/proc/nonexistent", Err: syscall.ENOENT}}
for i := 0; i < 2; i++ { // check once behaviour
if _, err := newStore(check.MustAbs("/proc/nonexistent")).bigLock(); !reflect.DeepEqual(err, wantErr) {
t.Errorf("bigLock: error = %#v, want %#v", err, wantErr)
}
}
})
t.Run("access", func(t *testing.T) {
t.Parallel()
base := check.MustAbs(t.TempDir()).Append("inaccessible")
if err := os.MkdirAll(base.String(), 0); err != nil {
t.Fatal(err.Error())
}
wantErr := &hst.AppError{Step: "acquire lock on the state store",
Err: &os.PathError{Op: "open", Path: base.Append(storeMutexName).String(), Err: syscall.EACCES}}
if _, err := newStore(base).bigLock(); !reflect.DeepEqual(err, wantErr) {
t.Errorf("bigLock: error = %#v, want %#v", err, wantErr)
}
})
}
func TestStateStoreIdentityHandle(t *testing.T) {
t.Parallel()
t.Run("loadstore", func(t *testing.T) {
t.Parallel()
s := newStore(check.MustAbs(t.TempDir()).Append("store"))
var handleAddr [8]*storeHandle
checkHandle := func(identity int, load bool) {
if h, err := s.identityHandle(identity); err != nil {
t.Fatalf("identityHandle: error = %v", err)
} else if load != (handleAddr[identity] != nil) {
t.Fatalf("identityHandle: load = %v, want %v", load, handleAddr[identity] != nil)
} else if !load {
handleAddr[identity] = h
if h.identity != identity {
t.Errorf("identityHandle: identity = %d, want %d", h.identity, identity)
}
} else if h != handleAddr[identity] {
t.Fatalf("identityHandle: %p, want %p", h, handleAddr[identity])
}
}
checkHandle(0, false)
checkHandle(1, false)
checkHandle(2, false)
checkHandle(3, false)
checkHandle(7, false)
checkHandle(7, true)
checkHandle(2, true)
checkHandle(1, true)
checkHandle(2, true)
checkHandle(0, true)
})
t.Run("access", func(t *testing.T) {
t.Parallel()
base := check.MustAbs(t.TempDir()).Append("inaccessible")
if err := os.MkdirAll(base.String(), 0); err != nil {
t.Fatal(err.Error())
}
wantErr := &hst.AppError{Step: "acquire lock on the state store",
Err: &os.PathError{Op: "open", Path: base.Append(storeMutexName).String(), Err: syscall.EACCES}}
if _, err := newStore(base).identityHandle(0); !reflect.DeepEqual(err, wantErr) {
t.Errorf("identityHandle: error = %#v, want %#v", err, wantErr)
}
})
t.Run("access segment", func(t *testing.T) {
t.Parallel()
base := check.MustAbs(t.TempDir()).Append("inaccessible")
if err := os.MkdirAll(base.String(), 0700); err != nil {
t.Fatal(err.Error())
}
if f, err := os.Create(base.Append(storeMutexName).String()); err != nil {
t.Fatal(err.Error())
} else if err = f.Close(); err != nil {
t.Fatal(err.Error())
}
if err := os.Chmod(base.String(), 0100); err != nil {
t.Fatal(err.Error())
}
t.Cleanup(func() {
if err := os.Chmod(base.String(), 0700); err != nil {
t.Fatal(err.Error())
}
})
wantErr := &hst.AppError{Step: "create store segment directory",
Err: &os.PathError{Op: "mkdir", Path: base.Append("0").String(), Err: syscall.EACCES}}
if _, err := newStore(base).identityHandle(0); !reflect.DeepEqual(err, wantErr) {
t.Errorf("identityHandle: error = %#v, want %#v", err, wantErr)
}
})
}
func TestStateStoreSegments(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
ents [2][]string
want []segmentIdentity
ext func(t *testing.T, segments iter.Seq[segmentIdentity], n int)
}{
{"errors", [2][]string{{
"f0-invalid-file",
}, {
"f1-invalid-syntax",
"9999",
"16384",
}}, []segmentIdentity{
{-1, &hst.AppError{Step: "process store segment", Err: syscall.EISDIR,
Msg: `skipped non-directory entry "f0-invalid-file"`}},
{-1, &hst.AppError{Step: "process store segment", Err: syscall.ERANGE,
Msg: `skipped out of bounds entry 16384`}},
{-1, &hst.AppError{Step: "process store segment",
Err: &strconv.NumError{Func: "Atoi", Num: "f1-invalid-syntax", Err: strconv.ErrSyntax},
Msg: `skipped non-identity entry "f1-invalid-syntax"`}},
{9999, nil},
}, nil},
{"success", [2][]string{{
"lock",
}, {
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"9",
"13",
"20",
"31",
"197",
}}, []segmentIdentity{
{0, nil},
{1, nil},
{2, nil},
{3, nil},
{4, nil},
{5, nil},
{6, nil},
{7, nil},
{9, nil},
{13, nil},
{20, nil},
{31, nil},
{197, nil},
}, func(t *testing.T, segments iter.Seq[segmentIdentity], n int) {
if n != 13 {
t.Fatalf("segments: n = %d", n)
}
// check partial drain
for range segments {
break
}
}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
base := check.MustAbs(t.TempDir()).Append("store")
if err := os.Mkdir(base.String(), 0700); err != nil {
t.Fatal(err.Error())
}
createEntries(t, base, tc.ents)
var got []segmentIdentity
if segments, n, err := newStore(base).segments(); err != nil {
t.Fatalf("segments: error = %v", err)
} else {
got = slices.AppendSeq(make([]segmentIdentity, 0, n), segments)
if tc.ext != nil {
tc.ext(t, segments, n)
}
}
slices.SortFunc(got, func(a, b segmentIdentity) int {
if a.identity == b.identity {
return strings.Compare(a.err.Error(), b.err.Error())
}
return cmp.Compare(a.identity, b.identity)
})
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("segments: %#v, want %#v", got, tc.want)
}
})
}
t.Run("access", func(t *testing.T) {
t.Parallel()
base := check.MustAbs(t.TempDir()).Append("inaccessible")
if err := os.MkdirAll(base.String(), 0); err != nil {
t.Fatal(err.Error())
}
wantErr := &hst.AppError{Step: "acquire lock on the state store",
Err: &os.PathError{Op: "open", Path: base.Append(storeMutexName).String(), Err: syscall.EACCES}}
if _, _, err := newStore(base).segments(); !reflect.DeepEqual(err, wantErr) {
t.Errorf("segments: error = %#v, want %#v", err, wantErr)
}
})
}

View File

@@ -0,0 +1,8 @@
package validate
//#include <unistd.h>
import "C"
const SC_LOGIN_NAME_MAX = C._SC_LOGIN_NAME_MAX
func Sysconf(name C.int) int { return int(C.sysconf(name)) }

View File

@@ -1,6 +1,10 @@
package app
package validate_test
import "testing"
import (
"testing"
"hakurei.app/internal/validate"
)
const (
_POSIX_LOGIN_NAME_MAX = 9
@@ -10,7 +14,7 @@ func TestSysconf(t *testing.T) {
t.Parallel()
t.Run("LOGIN_NAME_MAX", func(t *testing.T) {
if got := sysconf(_SC_LOGIN_NAME_MAX); got < _POSIX_LOGIN_NAME_MAX {
if got := validate.Sysconf(validate.SC_LOGIN_NAME_MAX); got < _POSIX_LOGIN_NAME_MAX {
t.Errorf("sysconf(_SC_LOGIN_NAME_MAX): %d < _POSIX_LOGIN_NAME_MAX", got)
}
})

View File

@@ -1,12 +1,12 @@
package app
package validate
import "regexp"
// nameRegex is the default NAME_REGEX value from adduser.
var nameRegex = regexp.MustCompilePOSIX(`^[a-zA-Z][a-zA-Z0-9_-]*\$?$`)
// isValidUsername returns whether the argument is a valid username
func isValidUsername(username string) bool {
return len(username) < sysconf(_SC_LOGIN_NAME_MAX) &&
// IsValidUsername returns whether the argument is a valid username.
func IsValidUsername(username string) bool {
return len(username) < Sysconf(SC_LOGIN_NAME_MAX) &&
nameRegex.MatchString(username)
}

View File

@@ -0,0 +1,30 @@
package validate_test
import (
"strings"
"testing"
"hakurei.app/internal/validate"
)
func TestIsValidUsername(t *testing.T) {
t.Parallel()
t.Run("long", func(t *testing.T) {
if validate.IsValidUsername(strings.Repeat("a", validate.Sysconf(validate.SC_LOGIN_NAME_MAX))) {
t.Errorf("IsValidUsername unexpected true")
}
})
t.Run("regexp", func(t *testing.T) {
if validate.IsValidUsername("0") {
t.Errorf("IsValidUsername unexpected true")
}
})
t.Run("valid", func(t *testing.T) {
if !validate.IsValidUsername("alice") {
t.Errorf("IsValidUsername unexpected false")
}
})
}

View File

@@ -0,0 +1,20 @@
// Package validate provides functions for validating string values of various types.
package validate
import (
"path/filepath"
"strings"
)
// DeepContainsH returns whether basepath is equivalent to or is the parent of targpath.
//
// This is used for path hiding warning behaviour, the purpose of which is to improve
// user experience and is *not* a security feature and must not be treated as such.
func DeepContainsH(basepath, targpath string) (bool, error) {
const upper = ".." + string(filepath.Separator)
rel, err := filepath.Rel(basepath, targpath)
return err == nil &&
rel != ".." &&
!strings.HasPrefix(rel, upper), err
}

View File

@@ -1,7 +1,9 @@
package app
package validate_test
import (
"testing"
"hakurei.app/internal/validate"
)
func TestDeepContainsH(t *testing.T) {
@@ -78,10 +80,10 @@ func TestDeepContainsH(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got, err := deepContainsH(tc.basepath, tc.targpath); (err != nil) != tc.wantErr {
t.Errorf("deepContainsH() error = %v, wantErr %v", err, tc.wantErr)
if got, err := validate.DeepContainsH(tc.basepath, tc.targpath); (err != nil) != tc.wantErr {
t.Errorf("DeepContainsH: error = %v, wantErr %v", err, tc.wantErr)
} else if got != tc.want {
t.Errorf("deepContainsH() = %v, want %v", got, tc.want)
t.Errorf("DeepContainsH: = %v, want %v", got, tc.want)
}
})
}

View File

@@ -58,17 +58,15 @@ def check_state(name, enablements):
raise Exception(f"unexpected state length {len(instances)}")
instance = next(iter(instances.values()))
config = instance['config']
command = f"{name}-start"
if not (config['container']['path'].startswith("/nix/store/")) or not (config['container']['path'].endswith(command)):
raise Exception(f"unexpected path {config['path']}")
if not (instance['container']['path'].startswith("/nix/store/")) or not (instance['container']['path'].endswith(command)):
raise Exception(f"unexpected path {instance['path']}")
if len(config['container']['args']) != 1 or config['container']['args'][0] != command:
raise Exception(f"unexpected args {config['args']}")
if len(instance['container']['args']) != 1 or instance['container']['args'][0] != command:
raise Exception(f"unexpected args {instance['args']}")
if config['enablements'] != enablements:
raise Exception(f"unexpected enablements {config['enablements']['enablements']}")
if instance['enablements'] != enablements:
raise Exception(f"unexpected enablements {instance['enablements']['enablements']}")
def hakurei(command):