Compare commits
23 Commits
0fd357e7f6
...
46c5ce4936
| Author | SHA1 | Date | |
|---|---|---|---|
|
46c5ce4936
|
|||
|
36f8064905
|
|||
|
eeb9f98e5b
|
|||
|
3f9f331501
|
|||
|
2563391086
|
|||
|
a0b4e47acc
|
|||
|
a52f7038e5
|
|||
|
274686d10d
|
|||
|
65342d588f
|
|||
|
5e5826459e
|
|||
|
4a463b7f03
|
|||
|
dacd9550e0
|
|||
|
546b00429f
|
|||
|
86f4219062
|
|||
|
fe2929d5f7
|
|||
|
470e545d27
|
|||
|
8d3381821f
|
|||
|
e9d00b9071
|
|||
|
4f41afee0f
|
|||
|
7de593e816
|
|||
|
2442eda8d9
|
|||
|
05488bfb8f
|
|||
|
dd94818f20
|
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
101
cmd/hakurei/parse_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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].
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
87
hst/instance.go
Normal 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
113
hst/instance_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#include <signal.h>
|
||||
|
||||
void hakurei_shim_setup_cont_signal(pid_t ppid, int fd);
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)) }
|
||||
@@ -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
66
internal/env/env.go
vendored
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
83
internal/lockedfile/internal/filelock/filelock.go
Normal file
83
internal/lockedfile/internal/filelock/filelock.go
Normal 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)
|
||||
}
|
||||
210
internal/lockedfile/internal/filelock/filelock_fcntl.go
Normal file
210
internal/lockedfile/internal/filelock/filelock_fcntl.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
35
internal/lockedfile/internal/filelock/filelock_other.go
Normal file
35
internal/lockedfile/internal/filelock/filelock_other.go
Normal 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,
|
||||
}
|
||||
}
|
||||
209
internal/lockedfile/internal/filelock/filelock_test.go
Normal file
209
internal/lockedfile/internal/filelock/filelock_test.go
Normal 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)
|
||||
}
|
||||
40
internal/lockedfile/internal/filelock/filelock_unix.go
Normal file
40
internal/lockedfile/internal/filelock/filelock_unix.go
Normal 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)
|
||||
}
|
||||
57
internal/lockedfile/internal/filelock/filelock_windows.go
Normal file
57
internal/lockedfile/internal/filelock/filelock_windows.go
Normal 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
|
||||
}
|
||||
43
internal/lockedfile/internal/testexec/exec.go
Normal file
43
internal/lockedfile/internal/testexec/exec.go
Normal 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
|
||||
}
|
||||
189
internal/lockedfile/lockedfile.go
Normal file
189
internal/lockedfile/lockedfile.go
Normal 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
|
||||
}
|
||||
65
internal/lockedfile/lockedfile_filelock.go
Normal file
65
internal/lockedfile/lockedfile_filelock.go
Normal 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
|
||||
}
|
||||
94
internal/lockedfile/lockedfile_plan9.go
Normal file
94
internal/lockedfile/lockedfile_plan9.go
Normal 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()
|
||||
}
|
||||
263
internal/lockedfile/lockedfile_test.go
Normal file
263
internal/lockedfile/lockedfile_test.go
Normal 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)
|
||||
}
|
||||
67
internal/lockedfile/mutex.go
Normal file
67
internal/lockedfile/mutex.go
Normal 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
|
||||
}
|
||||
103
internal/lockedfile/transform_test.go
Normal file
103
internal/lockedfile/transform_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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{}
|
||||
@@ -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")
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
11
internal/outcome/shim-signal.h
Normal file
11
internal/outcome/shim-signal.h
Normal 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);
|
||||
@@ -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(
|
||||
494
internal/outcome/shim_test.go
Normal file
494
internal/outcome/shim_test.go
Normal 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) }
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"os"
|
||||
52
internal/state/data.go
Normal file
52
internal/state/data.go
Normal 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
145
internal/state/data_test.go
Normal 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
86
internal/state/header.go
Normal 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
|
||||
}
|
||||
184
internal/state/header_test.go
Normal file
184
internal/state/header_test.go
Normal 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") }
|
||||
@@ -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
161
internal/state/segment.go
Normal 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
|
||||
}
|
||||
259
internal/state/segment_test.go
Normal file
259
internal/state/segment_test.go
Normal 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
131
internal/state/state.go
Normal 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
|
||||
}
|
||||
120
internal/state/state_test.go
Normal file
120
internal/state/state_test.go
Normal 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
162
internal/state/store.go
Normal 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())}
|
||||
}
|
||||
254
internal/state/store_test.go
Normal file
254
internal/state/store_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
8
internal/validate/sysconf.go
Normal file
8
internal/validate/sysconf.go
Normal 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)) }
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
30
internal/validate/username_test.go
Normal file
30
internal/validate/username_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
20
internal/validate/validate.go
Normal file
20
internal/validate/validate.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
14
test/test.py
14
test/test.py
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user