diff --git a/system/dispatcher.go b/system/dispatcher.go index 038231b..502c8e9 100644 --- a/system/dispatcher.go +++ b/system/dispatcher.go @@ -1,12 +1,20 @@ package system import ( + "io" + "io/fs" "os" "hakurei.app/system/acl" "hakurei.app/system/dbus" ) +type osFile interface { + Name() string + io.Writer + fs.File +} + // syscallDispatcher provides methods that make state-dependent system calls as part of their behaviour. // syscallDispatcher is embedded in [I], so all methods must be unexported. type syscallDispatcher interface { @@ -15,6 +23,10 @@ type syscallDispatcher interface { // just synchronising access is not enough, as this is for test instrumentation. new(f func(k syscallDispatcher)) + // stat provides os.Stat. + stat(name string) (os.FileInfo, error) + // open provides [os.Open]. + open(name string) (osFile, error) // mkdir provides os.Mkdir. mkdir(name string, perm os.FileMode) error // chmod provides os.Chmod. @@ -48,6 +60,8 @@ type direct struct{} func (k direct) new(f func(k syscallDispatcher)) { go f(k) } +func (k direct) stat(name string) (os.FileInfo, error) { return os.Stat(name) } +func (k direct) open(name string) (osFile, error) { return os.Open(name) } func (k direct) mkdir(name string, perm os.FileMode) error { return os.Mkdir(name, perm) } func (k direct) chmod(name string, mode os.FileMode) error { return os.Chmod(name, mode) } func (k direct) link(oldname, newname string) error { return os.Link(oldname, newname) } diff --git a/system/dispatcher_test.go b/system/dispatcher_test.go index 3497387..f798047 100644 --- a/system/dispatcher_test.go +++ b/system/dispatcher_test.go @@ -1,10 +1,13 @@ package system import ( + "io" + "io/fs" "os" "reflect" "slices" "testing" + "time" "unsafe" "hakurei.app/container/stub" @@ -180,6 +183,34 @@ func checkOpMeta(t *testing.T, testCases []opMetaTestCase) { }) } +type stubFi struct { + size int64 + isDir bool +} + +func (stubFi) Name() string { panic("unreachable") } +func (fi stubFi) Size() int64 { return fi.size } +func (stubFi) Mode() fs.FileMode { panic("unreachable") } +func (stubFi) ModTime() time.Time { panic("unreachable") } +func (fi stubFi) IsDir() bool { return fi.isDir } +func (stubFi) Sys() any { panic("unreachable") } + +type readerOsFile struct { + closed bool + io.Reader +} + +func (*readerOsFile) Name() string { panic("unreachable") } +func (*readerOsFile) Write([]byte) (int, error) { panic("unreachable") } +func (*readerOsFile) Stat() (fs.FileInfo, error) { panic("unreachable") } +func (r *readerOsFile) Close() error { + if r.closed { + return os.ErrClosed + } + r.closed = true + return nil +} + // InternalNew initialises [I] with a stub syscallDispatcher. func InternalNew(t *testing.T, want stub.Expect, uid int) (*I, *stub.Stub[syscallDispatcher]) { k := stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{s} }, want) @@ -192,6 +223,28 @@ type kstub struct{ *stub.Stub[syscallDispatcher] } func (k *kstub) new(f func(k syscallDispatcher)) { k.Helper(); k.New(f) } +func (k *kstub) stat(name string) (fi os.FileInfo, err error) { + k.Helper() + expect := k.Expects("stat") + err = expect.Error( + stub.CheckArg(k.Stub, "name", name, 0)) + if err == nil { + fi = expect.Ret.(os.FileInfo) + } + return +} + +func (k *kstub) open(name string) (f osFile, err error) { + k.Helper() + expect := k.Expects("open") + err = expect.Error( + stub.CheckArg(k.Stub, "name", name, 0)) + if err == nil { + f = expect.Ret.(osFile) + } + return +} + func (k *kstub) mkdir(name string, perm os.FileMode) error { k.Helper() return k.Expects("mkdir").Error( diff --git a/system/tmpfiles.go b/system/tmpfiles.go index bd865d4..d2a6c93 100644 --- a/system/tmpfiles.go +++ b/system/tmpfiles.go @@ -28,15 +28,15 @@ type tmpfileOp struct { func (t *tmpfileOp) Type() Enablement { return Process } -func (t *tmpfileOp) apply(*I) error { - msg.Verbose("copying", t) - +func (t *tmpfileOp) apply(sys *I) error { if t.payload == nil { - // this is a misuse of the API; do not return an error message + // this is a misuse of the API; do not return a wrapped error return errors.New("invalid payload") } - if b, err := os.Stat(t.src); err != nil { + sys.verbose("copying", t) + + if b, err := sys.stat(t.src); err != nil { return newOpError("tmpfile", err, false) } else { if b.IsDir() { @@ -47,9 +47,20 @@ func (t *tmpfileOp) apply(*I) error { } } - if f, err := os.Open(t.src); err != nil { + var r io.ReadCloser + if f, err := sys.open(t.src); err != nil { return newOpError("tmpfile", err, false) - } else if _, err = io.CopyN(t.buf, f, t.n); err != nil { + } else { + r = f + } + if n, err := io.CopyN(t.buf, r, t.n); err != nil { + if !errors.Is(err, io.EOF) { + _ = r.Close() + return newOpError("tmpfile", err, false) + } + sys.verbosef("copied %d bytes from %q", n, t.src) + } + if err := r.Close(); err != nil { return newOpError("tmpfile", err, false) } diff --git a/system/tmpfiles_test.go b/system/tmpfiles_test.go index a06242e..5ee5e0d 100644 --- a/system/tmpfiles_test.go +++ b/system/tmpfiles_test.go @@ -1,44 +1,142 @@ package system import ( - "strconv" + "bytes" + "errors" + "os" + "strings" + "syscall" "testing" + + "hakurei.app/container/stub" ) -func TestCopyFile(t *testing.T) { - testCases := []struct { - tcOp - cap int - n int64 - }{ - {tcOp{Process, "/home/ophestra/xdg/config/pulse/cookie"}, 256, 256}, - } - for _, tc := range testCases { - t.Run("copy file "+tc.path+" with cap = "+strconv.Itoa(tc.cap)+" n = "+strconv.Itoa(int(tc.n)), func(t *testing.T) { - sys := New(t.Context(), 150) - sys.CopyFile(new([]byte), tc.path, tc.cap, tc.n) - tc.test(t, sys.ops, []Op{ - &tmpfileOp{nil, tc.path, tc.n, nil}, - }, "CopyFile") - }) - } -} +type errorReader struct{} -func TestTmpfile_String(t *testing.T) { - testCases := []struct { - src string - n int64 - want string - }{ - {"/home/ophestra/xdg/config/pulse/cookie", 256, +func (errorReader) Read([]byte) (int, error) { return 0, stub.UniqueError(0xdeadbeef) } + +func TestTmpfileOp(t *testing.T) { + // 255 bytes + const paSample = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"payload", 0xdead, 0xff, &tmpfileOp{ + nil, "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }, nil, errors.New("invalid payload"), nil, nil}, + + {"stat", 0xdead, 0xff, &tmpfileOp{ + new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }, []stub.Call{ + call("verbose", stub.ExpectArgs{[]any{"copying", &tmpfileOp{new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }()}}}, nil, nil), + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, nil, stub.UniqueError(1)), + }, &OpError{Op: "tmpfile", Err: stub.UniqueError(1)}, nil, nil}, + + {"stat EISDIR", 0xdead, 0xff, &tmpfileOp{ + new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }, []stub.Call{ + call("verbose", stub.ExpectArgs{[]any{"copying", &tmpfileOp{new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }()}}}, nil, nil), + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, stubFi{1 << 8, true}, nil), + }, &OpError{Op: "tmpfile", Err: &os.PathError{Op: "stat", Path: "/home/ophestra/xdg/config/pulse/cookie", Err: syscall.EISDIR}}, nil, nil}, + + {"stat ENOMEM", 0xdead, 0xff, &tmpfileOp{ + new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }, []stub.Call{ + call("verbose", stub.ExpectArgs{[]any{"copying", &tmpfileOp{new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }()}}}, nil, nil), + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, stubFi{1<<8 + 1, false}, nil), + }, &OpError{Op: "tmpfile", Err: &os.PathError{Op: "stat", Path: "/home/ophestra/xdg/config/pulse/cookie", Err: syscall.ENOMEM}}, nil, nil}, + + {"open", 0xdead, 0xff, &tmpfileOp{ + new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }, []stub.Call{ + call("verbose", stub.ExpectArgs{[]any{"copying", &tmpfileOp{new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }()}}}, nil, nil), + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, stubFi{1 << 8, false}, nil), + call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, nil, stub.UniqueError(0)), + }, &OpError{Op: "tmpfile", Err: stub.UniqueError(0)}, nil, nil}, + + {"reader", 0xdead, 0xff, &tmpfileOp{ + new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }, []stub.Call{ + call("verbose", stub.ExpectArgs{[]any{"copying", &tmpfileOp{new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }()}}}, nil, nil), + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, stubFi{1 << 8, false}, nil), + call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &readerOsFile{true, errorReader{}}, nil), + }, &OpError{Op: "tmpfile", Err: stub.UniqueError(0xdeadbeef)}, nil, nil}, + + {"closed", 0xdead, 0xff, &tmpfileOp{ + new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }, []stub.Call{ + call("verbose", stub.ExpectArgs{[]any{"copying", &tmpfileOp{new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }()}}}, nil, nil), + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, stubFi{1 << 8, false}, nil), + call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &readerOsFile{true, strings.NewReader(paSample + "=")}, nil), + }, &OpError{Op: "tmpfile", Err: os.ErrClosed}, nil, nil}, + + {"success full", 0xdead, 0xff, &tmpfileOp{ + new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }, []stub.Call{ + call("verbose", stub.ExpectArgs{[]any{"copying", &tmpfileOp{new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }()}}}, nil, nil), + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, stubFi{1 << 8, false}, nil), + call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &readerOsFile{false, strings.NewReader(paSample + "=")}, nil), + }, nil, nil, nil}, + + {"success", 0xdead, 0xff, &tmpfileOp{ + new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }, []stub.Call{ + call("verbose", stub.ExpectArgs{[]any{"copying", &tmpfileOp{new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }()}}}, nil, nil), + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, stubFi{1 << 8, false}, nil), + call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &readerOsFile{false, strings.NewReader(paSample)}, nil), + call("verbosef", stub.ExpectArgs{"copied %d bytes from %q", []any{int64(1<<8 - 1), "/home/ophestra/xdg/config/pulse/cookie"}}, nil, nil), + }, nil, nil, nil}, + }) + + checkOpsBuilder(t, "CopyFile", []opsBuilderTestCase{ + {"pulse", 0xcafebabe, func(_ *testing.T, sys *I) { + sys.CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1<<8, 1<<8) + }, []Op{&tmpfileOp{ + new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, + func() *bytes.Buffer { buf := new(bytes.Buffer); buf.Grow(1 << 8); return buf }(), + }}, stub.Expect{}}, + }) + + checkOpIs(t, []opIsTestCase{ + {"nil", (*tmpfileOp)(nil), (*tmpfileOp)(nil), false}, + {"zero", new(tmpfileOp), new(tmpfileOp), true}, + + {"n differs", &tmpfileOp{ + src: "/home/ophestra/xdg/config/pulse/cookie", + n: 1 << 7, + }, &tmpfileOp{ + src: "/home/ophestra/xdg/config/pulse/cookie", + n: 1 << 8, + }, false}, + + {"src differs", &tmpfileOp{ + src: "/home/ophestra/xdg/config/pulse", + n: 1 << 8, + }, &tmpfileOp{ + src: "/home/ophestra/xdg/config/pulse/cookie", + n: 1 << 8, + }, false}, + + {"equals", &tmpfileOp{ + src: "/home/ophestra/xdg/config/pulse/cookie", + n: 1 << 8, + }, &tmpfileOp{ + src: "/home/ophestra/xdg/config/pulse/cookie", + n: 1 << 8, + }, true}, + }) + + checkOpMeta(t, []opMetaTestCase{ + {"pulse", &tmpfileOp{nil, "/home/ophestra/xdg/config/pulse/cookie", 1 << 8, nil}, + Process, "/home/ophestra/xdg/config/pulse/cookie", `up to 256 bytes from "/home/ophestra/xdg/config/pulse/cookie"`}, - } - - for _, tc := range testCases { - t.Run(tc.want, func(t *testing.T) { - if got := (&tmpfileOp{src: tc.src, n: tc.n}).String(); got != tc.want { - t.Errorf("String() = %v, want %v", got, tc.want) - } - }) - } + }) }