From ae2df2c450dd655b93a950124b6ab16e2b4d967b Mon Sep 17 00:00:00 2001 From: Ophestra Date: Thu, 25 Sep 2025 13:46:21 +0900 Subject: internal: remove sys package This package is replaced by container/stub. Remove and replace it with unexported implementation for the upcoming test suite rewrite. Signed-off-by: Ophestra --- internal/app/app.go | 8 +- internal/app/app_stub_test.go | 177 ++++++++---- internal/app/app_test.go | 91 ++++--- internal/app/container.go | 41 +-- internal/app/dispatcher.go | 99 +++++++ internal/app/export_test.go | 16 -- internal/app/finalise.go | 605 ++++++++++++++++++++++++++++++++++++++++++ internal/app/hsu.go | 96 +++++++ internal/app/paths.go | 36 +++ internal/app/process.go | 54 ++-- internal/app/seal.go | 602 ----------------------------------------- internal/sys/hsu.go | 88 ------ internal/sys/interface.go | 76 ------ internal/sys/std.go | 44 --- 14 files changed, 1054 insertions(+), 979 deletions(-) create mode 100644 internal/app/dispatcher.go delete mode 100644 internal/app/export_test.go create mode 100644 internal/app/finalise.go create mode 100644 internal/app/hsu.go create mode 100644 internal/app/paths.go delete mode 100644 internal/app/seal.go delete mode 100644 internal/sys/hsu.go delete mode 100644 internal/sys/interface.go delete mode 100644 internal/sys/std.go (limited to 'internal') diff --git a/internal/app/app.go b/internal/app/app.go index 4ecf1cc0..b5331568 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,19 +8,17 @@ import ( "hakurei.app/hst" "hakurei.app/internal/app/state" - "hakurei.app/internal/sys" ) // Main runs an app according to [hst.Config] and terminates. Main does not return. -func Main(ctx context.Context, k sys.State, config *hst.Config) { +func Main(ctx context.Context, config *hst.Config) { var id state.ID if err := state.NewAppID(&id); err != nil { log.Fatal(err) } - var seal outcome - seal.id = &stringPair[state.ID]{id, id.String()} - if err := seal.finalise(ctx, k, config); err != nil { + seal := outcome{id: &stringPair[state.ID]{id, id.String()}, syscallDispatcher: direct{}} + if err := seal.finalise(ctx, config); err != nil { printMessageError("cannot seal app:", err) os.Exit(1) } diff --git a/internal/app/app_stub_test.go b/internal/app/app_stub_test.go index f386eb01..ff218492 100644 --- a/internal/app/app_stub_test.go +++ b/internal/app/app_stub_test.go @@ -1,34 +1,24 @@ -package app_test +package app import ( "fmt" "io/fs" "log" + "os/exec" "os/user" - "strconv" - - "hakurei.app/hst" ) -// fs methods are not implemented using a real FS -// to help better understand filesystem access behaviour type stubNixOS struct { lookPathErr map[string]error usernameErr map[string]error } -func (s *stubNixOS) Getuid() int { return 1971 } -func (s *stubNixOS) Getgid() int { return 100 } -func (s *stubNixOS) TempDir() string { return "/tmp" } -func (s *stubNixOS) MustExecutable() string { return "/run/wrappers/bin/hakurei" } -func (s *stubNixOS) Exit(code int) { panic("called exit on stub with code " + strconv.Itoa(code)) } -func (s *stubNixOS) EvalSymlinks(path string) (string, error) { return path, nil } -func (s *stubNixOS) Uid(aid int) (int, error) { return 1000000 + 0*10000 + aid, nil } +func (k *stubNixOS) new(func(k syscallDispatcher)) { panic("not implemented") } -func (s *stubNixOS) Println(v ...any) { log.Println(v...) } -func (s *stubNixOS) Printf(format string, v ...any) { log.Printf(format, v...) } +func (k *stubNixOS) getuid() int { return 1971 } +func (k *stubNixOS) getgid() int { return 100 } -func (s *stubNixOS) LookupEnv(key string) (string, bool) { +func (k *stubNixOS) lookupEnv(key string) (string, bool) { switch key { case "SHELL": return "/run/current-system/sw/bin/zsh", true @@ -40,6 +30,8 @@ func (s *stubNixOS) LookupEnv(key string) (string, bool) { return "", false case "HOME": return "/home/ophestra", true + case "XDG_RUNTIME_DIR": + return "/run/user/1971", true case "XDG_CONFIG_HOME": return "/home/ophestra/xdg/config", true default: @@ -47,35 +39,29 @@ func (s *stubNixOS) LookupEnv(key string) (string, bool) { } } -func (s *stubNixOS) LookPath(file string) (string, error) { - if s.lookPathErr != nil { - if err, ok := s.lookPathErr[file]; ok { - return "", err - } - } - - switch file { - case "zsh": - return "/run/current-system/sw/bin/zsh", nil - default: - panic(fmt.Sprintf("attempted to look up unexpected executable %q", file)) - } -} - -func (s *stubNixOS) LookupGroup(name string) (*user.Group, error) { +func (k *stubNixOS) stat(name string) (fs.FileInfo, error) { switch name { - case "video": - return &user.Group{Gid: "26", Name: "video"}, nil + case "/var/run/nscd": + return nil, nil + case "/run/user/1971/pulse": + return nil, nil + case "/run/user/1971/pulse/native": + return stubFileInfoMode(0666), nil + case "/home/ophestra/.pulse-cookie": + return stubFileInfoIsDir(true), nil + case "/home/ophestra/xdg/config/pulse/cookie": + return stubFileInfoIsDir(false), nil default: - return nil, user.UnknownGroupError(name) + panic(fmt.Sprintf("attempted to stat unexpected path %q", name)) } } -func (s *stubNixOS) ReadDir(name string) ([]fs.DirEntry, error) { +func (k *stubNixOS) readdir(name string) ([]fs.DirEntry, error) { switch name { case "/": return stubDirEntries("bin", "boot", "dev", "etc", "home", "lib", "lib64", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var") + case "/run": return stubDirEntries("agetty.reload", "binfmt", "booted-system", "credentials", "cryptsetup", "current-system", "dbus", "host", "keys", @@ -83,6 +69,7 @@ func (s *stubNixOS) ReadDir(name string) ([]fs.DirEntry, error) { "nginx", "nixos", "nscd", "opengl-driver", "pppd", "resolvconf", "sddm", "store", "syncoid", "system", "systemd", "tmpfiles.d", "udev", "udisks2", "user", "utmp", "virtlogd.pid", "wrappers", "zed.pid", "zed.state") + case "/etc": return stubDirEntries("alsa", "bashrc", "binfmt.d", "dbus-1", "default", "ethertypes", "fonts", "fstab", "fuse.conf", "group", "host.conf", "hostid", @@ -96,39 +83,117 @@ func (s *stubNixOS) ReadDir(name string) ([]fs.DirEntry, error) { "ssl", "static", "subgid", "subuid", "sudoers", "sysctl.d", "systemd", "terminfo", "tmpfiles.d", "udev", "udisks2", "UPower", "vconsole.conf", "X11", "zfs", "zinputrc", "zoneinfo", "zprofile", "zshenv", "zshrc") + default: panic(fmt.Sprintf("attempted to read unexpected directory %q", name)) } } -func (s *stubNixOS) Stat(name string) (fs.FileInfo, error) { - switch name { - case "/var/run/nscd": - return nil, nil - case "/run/user/1971/pulse": - return nil, nil - case "/run/user/1971/pulse/native": - return stubFileInfoMode(0666), nil - case "/home/ophestra/.pulse-cookie": - return stubFileInfoIsDir(true), nil - case "/home/ophestra/xdg/config/pulse/cookie": - return stubFileInfoIsDir(false), nil +func (k *stubNixOS) tempdir() string { return "/tmp/" } + +func (k *stubNixOS) evalSymlinks(path string) (string, error) { + switch path { + case "/run/user/1971": + return "/run/user/1971", nil + case "/tmp/hakurei.0": + return "/tmp/hakurei.0", nil + case "/run/dbus": + return "/run/dbus", nil + case "/dev/kvm": + return "/dev/kvm", nil + case "/etc/": + return "/etc/", nil + case "/bin": + return "/bin", nil + case "/boot": + return "/boot", nil + case "/home": + return "/home", nil + case "/lib": + return "/lib", nil + case "/lib64": + return "/lib64", nil + case "/nix": + return "/nix", nil + case "/root": + return "/root", nil + case "/run": + return "/run", nil + case "/srv": + return "/srv", nil + case "/sys": + return "/sys", nil + case "/usr": + return "/usr", nil + case "/var": + return "/var", nil + case "/dev/dri": + return "/dev/dri", nil + case "/usr/bin/": + return "/usr/bin/", nil + case "/nix/store": + return "/nix/store", nil + case "/run/current-system": + return "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-nixos-system-satori-25.05.99999999.aaaaaaa", nil + case "/sys/block": + return "/sys/block", nil + case "/sys/bus": + return "/sys/bus", nil + case "/sys/class": + return "/sys/class", nil + case "/sys/dev": + return "/sys/dev", nil + case "/sys/devices": + return "/sys/devices", nil + case "/run/opengl-driver": + return "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-graphics-drivers", nil + case "/var/lib/persist/module/hakurei/0/1": + return "/var/lib/persist/module/hakurei/0/1", nil default: - panic(fmt.Sprintf("attempted to stat unexpected path %q", name)) + panic(fmt.Sprintf("attempted to evaluate unexpected path %q", path)) + } +} + +func (k *stubNixOS) lookPath(file string) (string, error) { + if k.lookPathErr != nil { + if err, ok := k.lookPathErr[file]; ok { + return "", err + } + } + + switch file { + case "zsh": + return "/run/current-system/sw/bin/zsh", nil + default: + panic(fmt.Sprintf("attempted to look up unexpected executable %q", file)) } } -func (s *stubNixOS) Open(name string) (fs.File, error) { +func (k *stubNixOS) lookupGroupId(name string) (string, error) { switch name { + case "video": + return "26", nil default: - panic(fmt.Sprintf("attempted to open unexpected file %q", name)) + return "", user.UnknownGroupError(name) } } -func (s *stubNixOS) Paths() hst.Paths { - return hst.Paths{ - SharePath: m("/tmp/hakurei.1971"), - RuntimePath: m("/run/user/1971"), - RunDirPath: m("/run/user/1971/hakurei"), +func (k *stubNixOS) cmdOutput(cmd *exec.Cmd) ([]byte, error) { + switch cmd.Path { + case "/proc/nonexistent/hsu": + return []byte{'0'}, nil + default: + panic(fmt.Sprintf("unexpected cmd %#v", cmd)) } } + +func (k *stubNixOS) overflowUid() int { return 65534 } +func (k *stubNixOS) overflowGid() int { return 65534 } + +func (k *stubNixOS) mustHsuPath() string { return "/proc/nonexistent/hsu" } + +func (k *stubNixOS) fatalf(format string, v ...any) { panic(fmt.Sprintf(format, v...)) } + +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...) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 9da72395..005b721d 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,4 +1,4 @@ -package app_test +package app import ( "context" @@ -13,9 +13,7 @@ import ( "hakurei.app/container" "hakurei.app/container/seccomp" "hakurei.app/hst" - "hakurei.app/internal/app" "hakurei.app/internal/app/state" - "hakurei.app/internal/sys" "hakurei.app/system" "hakurei.app/system/acl" "hakurei.app/system/dbus" @@ -24,7 +22,7 @@ import ( func TestApp(t *testing.T) { testCases := []struct { name string - os sys.State + k syscallDispatcher config *hst.Config id state.ID wantSys *system.I @@ -40,11 +38,11 @@ func TestApp(t *testing.T) { 0xb9, 0xa6, 0x07, 0xac, }, system.New(context.TODO(), 1000000). - Ensure("/tmp/hakurei.1971", 0711). - Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute). - Ensure("/tmp/hakurei.1971/runtime/0", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/0", acl.Read, acl.Write, acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute), + Ensure("/tmp/hakurei.0", 0711). + Ensure("/tmp/hakurei.0/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.0/runtime", acl.Execute). + Ensure("/tmp/hakurei.0/runtime/0", 0700).UpdatePermType(system.User, "/tmp/hakurei.0/runtime/0", acl.Read, acl.Write, acl.Execute). + Ensure("/tmp/hakurei.0/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.0/tmpdir", acl.Execute). + Ensure("/tmp/hakurei.0/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/hakurei.0/tmpdir/0", acl.Read, acl.Write, acl.Execute), &container.Params{ Dir: m("/home/chronos"), Path: m("/run/current-system/sw/bin/zsh"), @@ -71,8 +69,8 @@ func TestApp(t *testing.T) { Tmpfs(m("/run/dbus"), 8192, 0755). Remount(m("/dev/"), syscall.MS_RDONLY). Tmpfs(m("/run/user/"), 4096, 0755). - Bind(m("/tmp/hakurei.1971/runtime/0"), m("/run/user/65534"), container.BindWritable). - Bind(m("/tmp/hakurei.1971/tmpdir/0"), m("/tmp/"), container.BindWritable). + Bind(m("/tmp/hakurei.0/runtime/0"), m("/run/user/65534"), container.BindWritable). + Bind(m("/tmp/hakurei.0/tmpdir/0"), m("/tmp/"), container.BindWritable). Place(m("/etc/passwd"), []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). Place(m("/etc/group"), []byte("hakurei:x:65534:\n")). Remount(m("/"), syscall.MS_RDONLY), @@ -132,19 +130,19 @@ func TestApp(t *testing.T) { 0x9b, 0x64, 0xce, 0x7c, }, system.New(context.TODO(), 1000009). - Ensure("/tmp/hakurei.1971", 0711). - Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute). - Ensure("/tmp/hakurei.1971/runtime/9", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/9", acl.Read, acl.Write, acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir/9", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/9", acl.Read, acl.Write, acl.Execute). - Ephemeral(system.Process, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c", 0711). - Wayland(new(*os.File), "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"). + Ensure("/tmp/hakurei.0", 0711). + Ensure("/tmp/hakurei.0/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.0/runtime", acl.Execute). + Ensure("/tmp/hakurei.0/runtime/9", 0700).UpdatePermType(system.User, "/tmp/hakurei.0/runtime/9", acl.Read, acl.Write, acl.Execute). + Ensure("/tmp/hakurei.0/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.0/tmpdir", acl.Execute). + Ensure("/tmp/hakurei.0/tmpdir/9", 01700).UpdatePermType(system.User, "/tmp/hakurei.0/tmpdir/9", acl.Read, acl.Write, acl.Execute). + Ephemeral(system.Process, "/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c", 0711). + Wayland(new(*os.File), "/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"). Ensure("/run/user/1971/hakurei", 0700).UpdatePermType(system.User, "/run/user/1971/hakurei", acl.Execute). Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset Ephemeral(system.Process, "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c", acl.Execute). Link("/run/user/1971/pulse/native", "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse"). CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 256, 256). - MustProxyDBus("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{ + MustProxyDBus("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{ Talk: []string{ "org.freedesktop.Notifications", "org.freedesktop.FileManager1", @@ -166,7 +164,7 @@ func TestApp(t *testing.T) { "org.freedesktop.portal.*": "@/org/freedesktop/portal/*", }, Filter: true, - }, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", &dbus.Config{ + }, "/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket", &dbus.Config{ Talk: []string{ "org.bluez", "org.freedesktop.Avahi", @@ -174,8 +172,8 @@ func TestApp(t *testing.T) { }, Filter: true, }). - UpdatePerm("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write). - UpdatePerm("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write), + UpdatePerm("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write). + UpdatePerm("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write), &container.Params{ Dir: m("/home/chronos"), Path: m("/run/current-system/sw/bin/zsh"), @@ -208,15 +206,15 @@ func TestApp(t *testing.T) { Tmpfs(m("/run/dbus"), 8192, 0755). Remount(m("/dev/"), syscall.MS_RDONLY). Tmpfs(m("/run/user/"), 4096, 0755). - Bind(m("/tmp/hakurei.1971/runtime/9"), m("/run/user/65534"), container.BindWritable). - Bind(m("/tmp/hakurei.1971/tmpdir/9"), m("/tmp/"), container.BindWritable). + Bind(m("/tmp/hakurei.0/runtime/9"), m("/run/user/65534"), container.BindWritable). + Bind(m("/tmp/hakurei.0/tmpdir/9"), m("/tmp/"), container.BindWritable). Place(m("/etc/passwd"), []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). Place(m("/etc/group"), []byte("hakurei:x:65534:\n")). - Bind(m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland"), m("/run/user/65534/wayland-0"), 0). + Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/wayland"), m("/run/user/65534/wayland-0"), 0). Bind(m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse"), m("/run/user/65534/pulse/native"), 0). Place(m(hst.Tmp+"/pulse-cookie"), nil). - Bind(m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus"), m("/run/user/65534/bus"), 0). - Bind(m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket"), m("/run/dbus/system_bus_socket"), 0). + Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus"), m("/run/user/65534/bus"), 0). + Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket"), m("/run/dbus/system_bus_socket"), 0). Remount(m("/"), syscall.MS_RDONLY), SeccompPresets: seccomp.PresetExt | seccomp.PresetDenyDevel, HostNet: true, @@ -283,19 +281,19 @@ func TestApp(t *testing.T) { 0xb4, 0x6e, 0xb5, 0xc1, }, system.New(context.TODO(), 1000001). - Ensure("/tmp/hakurei.1971", 0711). - Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute). - Ensure("/tmp/hakurei.1971/runtime/1", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/1", acl.Read, acl.Write, acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute). - Ensure("/tmp/hakurei.1971/tmpdir/1", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/1", acl.Read, acl.Write, acl.Execute). + Ensure("/tmp/hakurei.0", 0711). + Ensure("/tmp/hakurei.0/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.0/runtime", acl.Execute). + Ensure("/tmp/hakurei.0/runtime/1", 0700).UpdatePermType(system.User, "/tmp/hakurei.0/runtime/1", acl.Read, acl.Write, acl.Execute). + Ensure("/tmp/hakurei.0/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.0/tmpdir", acl.Execute). + Ensure("/tmp/hakurei.0/tmpdir/1", 01700).UpdatePermType(system.User, "/tmp/hakurei.0/tmpdir/1", acl.Read, acl.Write, acl.Execute). Ensure("/run/user/1971/hakurei", 0700).UpdatePermType(system.User, "/run/user/1971/hakurei", acl.Execute). Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute). Ephemeral(system.Process, "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1", 0700).UpdatePermType(system.Process, "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1", acl.Execute). Link("/run/user/1971/pulse/native", "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse"). CopyFile(nil, "/home/ophestra/xdg/config/pulse/cookie", 256, 256). - Ephemeral(system.Process, "/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711). - MustProxyDBus("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", &dbus.Config{ + Ephemeral(system.Process, "/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1", 0711). + MustProxyDBus("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus", &dbus.Config{ Talk: []string{ "org.freedesktop.FileManager1", "org.freedesktop.Notifications", "org.freedesktop.ScreenSaver", "org.freedesktop.secrets", @@ -308,7 +306,7 @@ func TestApp(t *testing.T) { }, Call: map[string]string{}, Broadcast: map[string]string{}, Filter: true, - }, "/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", &dbus.Config{ + }, "/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", &dbus.Config{ Talk: []string{ "org.bluez", "org.freedesktop.Avahi", @@ -316,8 +314,8 @@ func TestApp(t *testing.T) { }, Filter: true, }). - UpdatePerm("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", acl.Read, acl.Write). - UpdatePerm("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", acl.Read, acl.Write), + UpdatePerm("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus", acl.Read, acl.Write). + UpdatePerm("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", acl.Read, acl.Write), &container.Params{ Uid: 1971, Gid: 100, @@ -358,15 +356,15 @@ func TestApp(t *testing.T) { Bind(m("/var/lib/persist/module/hakurei/0/1"), m("/var/lib/persist/module/hakurei/0/1"), container.BindWritable|container.BindEnsure). Remount(m("/dev/"), syscall.MS_RDONLY). Tmpfs(m("/run/user/"), 4096, 0755). - Bind(m("/tmp/hakurei.1971/runtime/1"), m("/run/user/1971"), container.BindWritable). - Bind(m("/tmp/hakurei.1971/tmpdir/1"), m("/tmp/"), container.BindWritable). + Bind(m("/tmp/hakurei.0/runtime/1"), m("/run/user/1971"), container.BindWritable). + Bind(m("/tmp/hakurei.0/tmpdir/1"), m("/tmp/"), container.BindWritable). Place(m("/etc/passwd"), []byte("u0_a1:x:1971:100:Hakurei:/var/lib/persist/module/hakurei/0/1:/run/current-system/sw/bin/zsh\n")). Place(m("/etc/group"), []byte("hakurei:x:100:\n")). Bind(m("/run/user/1971/wayland-0"), m("/run/user/1971/wayland-0"), 0). Bind(m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse"), m("/run/user/1971/pulse/native"), 0). Place(m(hst.Tmp+"/pulse-cookie"), nil). - Bind(m("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus"), m("/run/user/1971/bus"), 0). - Bind(m("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket"), m("/run/dbus/system_bus_socket"), 0). + Bind(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus"), m("/run/user/1971/bus"), 0). + Bind(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket"), m("/run/dbus/system_bus_socket"), 0). Remount(m("/"), syscall.MS_RDONLY), SeccompPresets: seccomp.PresetExt | seccomp.PresetDenyTTY | seccomp.PresetDenyDevel, HostNet: true, @@ -378,7 +376,8 @@ func TestApp(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Run("finalise", func(t *testing.T) { - sys, params, err := app.FinaliseIParams(t.Context(), tc.os, tc.config, &tc.id) + seal := outcome{syscallDispatcher: tc.k, id: &stringPair[state.ID]{tc.id, tc.id.String()}} + err := seal.finalise(t.Context(), tc.config) if err != nil { if s, ok := container.GetErrorMessage(err); !ok { t.Fatalf("Seal: error = %v", err) @@ -388,14 +387,14 @@ func TestApp(t *testing.T) { } t.Run("sys", func(t *testing.T) { - if !sys.Equal(tc.wantSys) { - t.Errorf("Seal: sys = %#v, want %#v", sys, tc.wantSys) + if !seal.sys.Equal(tc.wantSys) { + t.Errorf("Seal: sys = %#v, want %#v", seal.sys, tc.wantSys) } }) t.Run("params", func(t *testing.T) { - if !reflect.DeepEqual(params, tc.wantParams) { - t.Errorf("seal: params =\n%s\n, want\n%s", mustMarshal(params), mustMarshal(tc.wantParams)) + if !reflect.DeepEqual(seal.container, tc.wantParams) { + t.Errorf("seal: container =\n%s\n, want\n%s", mustMarshal(seal.container), mustMarshal(tc.wantParams)) } }) }) diff --git a/internal/app/container.go b/internal/app/container.go index 26bda265..d406d211 100644 --- a/internal/app/container.go +++ b/internal/app/container.go @@ -11,7 +11,6 @@ import ( "hakurei.app/container" "hakurei.app/container/seccomp" "hakurei.app/hst" - "hakurei.app/internal/sys" "hakurei.app/system/dbus" ) @@ -20,7 +19,13 @@ const preallocateOpsCount = 1 << 5 // newContainer initialises [container.Params] via [hst.ContainerConfig]. // Note that remaining container setup must be queued by the caller. -func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid *int) (*container.Params, map[string]string, error) { +func newContainer( + k syscallDispatcher, + s *hst.ContainerConfig, + prefix string, + sc *hst.Paths, + uid, gid *int, +) (*container.Params, map[string]string, error) { if s == nil { return nil, nil, newWithMessage("invalid container configuration") } @@ -38,9 +43,7 @@ func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid ForwardCancel: s.WaitDelay >= 0, } - as := &hst.ApplyState{ - AutoEtcPrefix: prefix, - } + as := &hst.ApplyState{AutoEtcPrefix: prefix} { ops := make(container.Ops, 0, preallocateOpsCount+len(s.Filesystem)) params.Ops = &ops @@ -65,13 +68,13 @@ func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid } if s.MapRealUID { - params.Uid = os.Getuid() + params.Uid = k.getuid() *uid = params.Uid - params.Gid = os.Getgid() + params.Gid = k.getgid() *gid = params.Gid } else { - *uid = container.OverflowUid() - *gid = container.OverflowGid() + *uid = k.overflowUid() + *gid = k.overflowGid() } filesystem := s.Filesystem @@ -107,7 +110,6 @@ func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid to warn about issues in custom configuration; it is NOT a security feature and should not be treated as such, ALWAYS be careful with what you bind */ var hidePaths []string - sc := os.Paths() hidePaths = append(hidePaths, sc.RuntimePath.String(), sc.SharePath.String()) _, systemBusAddr := dbus.Address() if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil { @@ -124,11 +126,11 @@ func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid // get parent dir of socket dir := path.Dir(pair[1]) if dir == "." || dir == container.FHSRoot { - os.Printf("dbus socket %q is in an unusual location", pair[1]) + k.verbosef("dbus socket %q is in an unusual location", pair[1]) } hidePaths = append(hidePaths, dir) } else { - os.Printf("dbus socket %q is not absolute", pair[1]) + k.verbosef("dbus socket %q is not absolute", pair[1]) } } } @@ -136,7 +138,7 @@ func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid } hidePathMatch := make([]bool, len(hidePaths)) for i := range hidePaths { - if err := evalSymlinks(os, &hidePaths[i]); err != nil { + if err := evalSymlinks(k, &hidePaths[i]); err != nil { return nil, nil, err } } @@ -155,7 +157,7 @@ func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid // AutoRootOp is a collection of many BindMountOp internally var autoRootEntries []fs.DirEntry if autoroot != nil { - if d, err := os.ReadDir(autoroot.Source.String()); err != nil { + if d, err := k.readdir(autoroot.Source.String()); err != nil { return nil, nil, err } else { // autoroot counter @@ -191,7 +193,7 @@ func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid } hidePathSourceEval[i] = [2]string{a.String(), a.String()} - if err := evalSymlinks(os, &hidePathSourceEval[i][0]); err != nil { + if err := evalSymlinks(k, &hidePathSourceEval[i][0]); err != nil { return nil, nil, err } } @@ -207,7 +209,7 @@ func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid return nil, nil, err } else if ok { hidePathMatch[i] = true - os.Printf("hiding path %q from %q", hidePaths[i], p[1]) + k.verbosef("hiding path %q from %q", hidePaths[i], p[1]) } } } @@ -238,12 +240,13 @@ func newContainer(s *hst.ContainerConfig, os sys.State, prefix string, uid, gid return params, maps.Clone(s.Env), nil } -func evalSymlinks(os sys.State, v *string) error { - if p, err := os.EvalSymlinks(*v); err != nil { +// evalSymlinks calls syscallDispatcher.evalSymlinks but discards errors unwrapping to [fs.ErrNotExist]. +func evalSymlinks(k syscallDispatcher, v *string) error { + if p, err := k.evalSymlinks(*v); err != nil { if !errors.Is(err, fs.ErrNotExist) { return err } - os.Printf("path %q does not yet exist", *v) + k.verbosef("path %q does not yet exist", *v) } else { *v = p } diff --git a/internal/app/dispatcher.go b/internal/app/dispatcher.go new file mode 100644 index 00000000..deecfa92 --- /dev/null +++ b/internal/app/dispatcher.go @@ -0,0 +1,99 @@ +package app + +import ( + "log" + "os" + "os/exec" + "os/user" + "path/filepath" + + "hakurei.app/container" + "hakurei.app/internal" + "hakurei.app/internal/hlog" +) + +// syscallDispatcher provides methods that make state-dependent system calls as part of their behaviour. +type syscallDispatcher interface { + // new starts a goroutine with a new instance of syscallDispatcher. + // A syscallDispatcher must never be used in any goroutine other than the one owning it, + // just synchronising access is not enough, as this is for test instrumentation. + new(f func(k syscallDispatcher)) + + // getuid provides [os.Getuid]. + getuid() int + // getgid provides [os.Getgid]. + getgid() int + // lookupEnv provides [os.LookupEnv]. + lookupEnv(key string) (string, bool) + // stat provides [os.Stat]. + stat(name string) (os.FileInfo, error) + // readdir provides [os.ReadDir]. + readdir(name string) ([]os.DirEntry, error) + // tempdir provides [os.TempDir]. + tempdir() string + + // evalSymlinks provides [filepath.EvalSymlinks]. + evalSymlinks(path string) (string, error) + + // lookPath provides exec.LookPath. + lookPath(file string) (string, error) + + // lookupGroupId calls [user.LookupGroup] and returns the Gid field of the resulting [user.Group] struct. + lookupGroupId(name string) (string, error) + + // cmdOutput provides the Output method of [exec.Cmd]. + cmdOutput(cmd *exec.Cmd) ([]byte, error) + + // overflowUid provides [container.OverflowUid]. + overflowUid() int + // overflowGid provides [container.OverflowGid]. + overflowGid() int + + // mustHsuPath provides [internal.MustHsuPath]. + mustHsuPath() string + + // fatalf provides [log.Fatalf]. + fatalf(format string, v ...any) + + isVerbose() bool + verbose(v ...any) + verbosef(format string, v ...any) +} + +// direct implements syscallDispatcher on the current kernel. +type direct struct{} + +func (k direct) new(f func(k syscallDispatcher)) { go f(k) } + +func (direct) getuid() int { return os.Getuid() } +func (direct) getgid() int { return os.Getgid() } +func (direct) lookupEnv(key string) (string, bool) { return os.LookupEnv(key) } +func (direct) stat(name string) (os.FileInfo, error) { return os.Stat(name) } +func (direct) readdir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) } +func (direct) tempdir() string { return os.TempDir() } + +func (direct) evalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } + +func (direct) lookPath(file string) (string, error) { return exec.LookPath(file) } + +func (direct) lookupGroupId(name string) (gid string, err error) { + var group *user.Group + group, err = user.LookupGroup(name) + if group != nil { + gid = group.Gid + } + return +} + +func (direct) cmdOutput(cmd *exec.Cmd) ([]byte, error) { return cmd.Output() } + +func (direct) overflowUid() int { return container.OverflowUid() } +func (direct) overflowGid() int { return container.OverflowGid() } + +func (direct) mustHsuPath() string { return internal.MustHsuPath() } + +func (direct) fatalf(format string, v ...any) { log.Fatalf(format, v...) } + +func (k direct) isVerbose() bool { return hlog.Load() } +func (direct) verbose(v ...any) { hlog.Verbose(v...) } +func (direct) verbosef(format string, v ...any) { hlog.Verbosef(format, v...) } diff --git a/internal/app/export_test.go b/internal/app/export_test.go deleted file mode 100644 index 1bfba48a..00000000 --- a/internal/app/export_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package app - -import ( - "context" - - "hakurei.app/container" - "hakurei.app/hst" - "hakurei.app/internal/app/state" - "hakurei.app/internal/sys" - "hakurei.app/system" -) - -func FinaliseIParams(ctx context.Context, k sys.State, config *hst.Config, id *state.ID) (*system.I, *container.Params, error) { - seal := outcome{id: &stringPair[state.ID]{*id, id.String()}} - return seal.sys, seal.container, seal.finalise(ctx, k, config) -} diff --git a/internal/app/finalise.go b/internal/app/finalise.go new file mode 100644 index 00000000..2a0e5e6e --- /dev/null +++ b/internal/app/finalise.go @@ -0,0 +1,605 @@ +package app + +import ( + "bytes" + "context" + "encoding/gob" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/user" + "slices" + "strconv" + "strings" + "sync/atomic" + "syscall" + "time" + + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/internal/app/state" + "hakurei.app/internal/hlog" + "hakurei.app/system" + "hakurei.app/system/acl" + "hakurei.app/system/dbus" + "hakurei.app/system/wayland" +) + +func newWithMessage(msg string) error { return newWithMessageError(msg, os.ErrInvalid) } +func newWithMessageError(msg string, err error) error { + return &hst.AppError{Step: "finalise", Err: err, Msg: msg} +} + +// An outcome is the runnable state of a hakurei container via [hst.Config]. +type outcome struct { + // copied from initialising [app] + id *stringPair[state.ID] + // copied from [sys.State] + runDirPath *container.Absolute + + // initial [hst.Config] gob stream for state data; + // this is prepared ahead of time as config is clobbered during seal creation + ct io.WriterTo + // dump dbus proxy message buffer + dbusMsg func() + + user hsuUser + sys *system.I + ctx context.Context + + waitDelay time.Duration + container *container.Params + env map[string]string + sync *os.File + active atomic.Bool + + syscallDispatcher +} + +// shareHost holds optional share directory state that must not be accessed directly +type shareHost struct { + // whether XDG_RUNTIME_DIR is used post hsu + useRuntimeDir bool + // process-specific directory in tmpdir, empty if unused + sharePath *container.Absolute + // process-specific directory in XDG_RUNTIME_DIR, empty if unused + runtimeSharePath *container.Absolute + + seal *outcome + sc hst.Paths +} + +// ensureRuntimeDir must be called if direct access to paths within XDG_RUNTIME_DIR is required +func (share *shareHost) ensureRuntimeDir() { + if share.useRuntimeDir { + return + } + share.useRuntimeDir = true + share.seal.sys.Ensure(share.sc.RunDirPath.String(), 0700) + share.seal.sys.UpdatePermType(system.User, share.sc.RunDirPath.String(), acl.Execute) + share.seal.sys.Ensure(share.sc.RuntimePath.String(), 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset + share.seal.sys.UpdatePermType(system.User, share.sc.RuntimePath.String(), acl.Execute) +} + +// instance returns a process-specific share path within tmpdir +func (share *shareHost) instance() *container.Absolute { + if share.sharePath != nil { + return share.sharePath + } + share.sharePath = share.sc.SharePath.Append(share.seal.id.String()) + share.seal.sys.Ephemeral(system.Process, share.sharePath.String(), 0711) + return share.sharePath +} + +// runtime returns a process-specific share path within XDG_RUNTIME_DIR +func (share *shareHost) runtime() *container.Absolute { + if share.runtimeSharePath != nil { + return share.runtimeSharePath + } + share.ensureRuntimeDir() + share.runtimeSharePath = share.sc.RunDirPath.Append(share.seal.id.String()) + share.seal.sys.Ephemeral(system.Process, share.runtimeSharePath.String(), 0700) + share.seal.sys.UpdatePerm(share.runtimeSharePath.String(), acl.Execute) + return share.runtimeSharePath +} + +// hsuUser stores post-hsu credentials and metadata +type hsuUser struct { + identity *stringPair[int] + // target uid resolved by hid:aid + uid *stringPair[int] + + // supplementary group ids + supp []string + + // app user home directory + home *container.Absolute + // passwd database username + username string +} + +func (k *outcome) finalise(ctx context.Context, config *hst.Config) error { + const ( + home = "HOME" + shell = "SHELL" + + xdgConfigHome = "XDG_CONFIG_HOME" + xdgRuntimeDir = "XDG_RUNTIME_DIR" + xdgSessionClass = "XDG_SESSION_CLASS" + xdgSessionType = "XDG_SESSION_TYPE" + + term = "TERM" + display = "DISPLAY" + + pulseServer = "PULSE_SERVER" + pulseCookie = "PULSE_COOKIE" + + dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS" + dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS" + ) + + if ctx == nil { + // unreachable + panic("invalid call to finalise") + } + if k.ctx != nil { + // unreachable + panic("attempting to finalise twice") + } + k.ctx = ctx + + if config == nil { + return newWithMessage("invalid configuration") + } + if config.Home == nil { + return newWithMessage("invalid path to home directory") + } + + { + // encode initial configuration for state tracking + ct := new(bytes.Buffer) + if err := gob.NewEncoder(ct).Encode(config); err != nil { + return &hst.AppError{Step: "encode initial config", Err: err} + } + k.ct = ct + } + + // allowed identity range 0 to 9999, this is checked again in hsu + if config.Identity < 0 || config.Identity > 9999 { + return newWithMessage(fmt.Sprintf("identity %d out of range", config.Identity)) + } + + k.user = hsuUser{ + identity: newInt(config.Identity), + home: config.Home, + username: config.Username, + } + + hsu := Hsu{k: k} + if k.user.username == "" { + k.user.username = "chronos" + } else if !isValidUsername(k.user.username) { + return newWithMessage(fmt.Sprintf("invalid user name %q", k.user.username)) + } + k.user.uid = newInt(HsuUid(hsu.MustID(), k.user.identity.unwrap())) + + k.user.supp = make([]string, len(config.Groups)) + for i, name := range config.Groups { + if gid, err := k.lookupGroupId(name); err != nil { + var unknownGroupError user.UnknownGroupError + if errors.As(err, &unknownGroupError) { + return newWithMessageError(fmt.Sprintf("unknown group %q", name), unknownGroupError) + } else { + return &hst.AppError{Step: "look up group by name", Err: err} + } + } else { + k.user.supp[i] = gid + } + } + + // permissive defaults + if config.Container == nil { + hlog.Verbose("container configuration not supplied, PROCEED WITH CAUTION") + + if config.Shell == nil { + config.Shell = container.AbsFHSRoot.Append("bin", "sh") + s, _ := k.lookupEnv(shell) + if a, err := container.NewAbs(s); err == nil { + config.Shell = a + } + } + + // hsu clears the environment so resolve paths early + if config.Path == nil { + if len(config.Args) > 0 { + if p, err := k.lookPath(config.Args[0]); err != nil { + return &hst.AppError{Step: "look up executable file", Err: err} + } else if config.Path, err = container.NewAbs(p); err != nil { + return newWithMessageError(err.Error(), err) + } + } else { + config.Path = config.Shell + } + } + + conf := &hst.ContainerConfig{ + Userns: true, + HostNet: true, + HostAbstract: true, + Tty: true, + + Filesystem: []hst.FilesystemConfigJSON{ + // autoroot, includes the home directory + {FilesystemConfig: &hst.FSBind{ + Target: container.AbsFHSRoot, + Source: container.AbsFHSRoot, + Write: true, + Special: true, + }}, + }, + } + + // bind GPU stuff + if config.Enablements.Unwrap()&(system.EX11|system.EWayland) != 0 { + conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{Source: container.AbsFHSDev.Append("dri"), Device: true, Optional: true}}) + } + // opportunistically bind kvm + conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{Source: container.AbsFHSDev.Append("kvm"), Device: true, Optional: true}}) + + // hide nscd from container if present + nscd := container.AbsFHSVar.Append("run/nscd") + if _, err := k.stat(nscd.String()); !errors.Is(err, fs.ErrNotExist) { + conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSEphemeral{Target: nscd}}) + } + + // do autoetc last + conf.Filesystem = append(conf.Filesystem, + hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{ + Target: container.AbsFHSEtc, + Source: container.AbsFHSEtc, + Special: true, + }}, + ) + + config.Container = conf + } + + // late nil checks for pd behaviour + if config.Shell == nil { + return newWithMessage("invalid shell path") + } + if config.Path == nil { + return newWithMessage("invalid program path") + } + + // TODO(ophestra): revert this after params to shim + share := &shareHost{seal: k} + copyPaths(k.syscallDispatcher, &share.sc, hsu.MustID()) + + var mapuid, mapgid *stringPair[int] + { + var uid, gid int + var err error + k.container, k.env, err = newContainer(k, config.Container, k.id.String(), &share.sc, &uid, &gid) + k.waitDelay = config.Container.WaitDelay + if err != nil { + return &hst.AppError{Step: "initialise container configuration", Err: err} + } + if len(config.Args) == 0 { + config.Args = []string{config.Path.String()} + } + k.container.Path = config.Path + k.container.Args = config.Args + + mapuid = newInt(uid) + mapgid = newInt(gid) + if k.env == nil { + k.env = make(map[string]string, 1<<6) + } + } + + // inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as mapped uid + innerRuntimeDir := container.AbsFHSRunUser.Append(mapuid.String()) + k.env[xdgRuntimeDir] = innerRuntimeDir.String() + k.env[xdgSessionClass] = "user" + k.env[xdgSessionType] = "tty" + + k.runDirPath = share.sc.RunDirPath + k.sys = system.New(k.ctx, k.user.uid.unwrap()) + k.sys.Ensure(share.sc.SharePath.String(), 0711) + + { + runtimeDir := share.sc.SharePath.Append("runtime") + k.sys.Ensure(runtimeDir.String(), 0700) + k.sys.UpdatePermType(system.User, runtimeDir.String(), acl.Execute) + runtimeDirInst := runtimeDir.Append(k.user.identity.String()) + k.sys.Ensure(runtimeDirInst.String(), 0700) + k.sys.UpdatePermType(system.User, runtimeDirInst.String(), acl.Read, acl.Write, acl.Execute) + k.container.Tmpfs(container.AbsFHSRunUser, 1<<12, 0755) + k.container.Bind(runtimeDirInst, innerRuntimeDir, container.BindWritable) + } + + { + tmpdir := share.sc.SharePath.Append("tmpdir") + k.sys.Ensure(tmpdir.String(), 0700) + k.sys.UpdatePermType(system.User, tmpdir.String(), acl.Execute) + tmpdirInst := tmpdir.Append(k.user.identity.String()) + k.sys.Ensure(tmpdirInst.String(), 01700) + k.sys.UpdatePermType(system.User, tmpdirInst.String(), acl.Read, acl.Write, acl.Execute) + // mount inner /tmp from share so it shares persistence and storage behaviour of host /tmp + k.container.Bind(tmpdirInst, container.AbsFHSTmp, container.BindWritable) + } + + { + username := "chronos" + if k.user.username != "" { + username = k.user.username + } + k.container.Dir = k.user.home + k.env["HOME"] = k.user.home.String() + k.env["USER"] = username + k.env[shell] = config.Shell.String() + + k.container.Place(container.AbsFHSEtc.Append("passwd"), + []byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Hakurei:"+k.user.home.String()+":"+config.Shell.String()+"\n")) + k.container.Place(container.AbsFHSEtc.Append("group"), + []byte("hakurei:x:"+mapgid.String()+":\n")) + } + + // pass TERM for proper terminal I/O in initial process + if t, ok := k.lookupEnv(term); ok { + k.env[term] = t + } + + if config.Enablements.Unwrap()&system.EWayland != 0 { + // outer wayland socket (usually `/run/user/%d/wayland-%d`) + var socketPath *container.Absolute + if name, ok := k.lookupEnv(wayland.WaylandDisplay); !ok { + hlog.Verbose(wayland.WaylandDisplay + " is not set, assuming " + wayland.FallbackName) + socketPath = share.sc.RuntimePath.Append(wayland.FallbackName) + } else if a, err := container.NewAbs(name); err != nil { + socketPath = share.sc.RuntimePath.Append(name) + } else { + socketPath = a + } + + innerPath := innerRuntimeDir.Append(wayland.FallbackName) + k.env[wayland.WaylandDisplay] = wayland.FallbackName + + if !config.DirectWayland { // set up security-context-v1 + appID := config.ID + if appID == "" { + // use instance ID in case app id is not set + appID = "app.hakurei." + k.id.String() + } + // downstream socket paths + outerPath := share.instance().Append("wayland") + k.sys.Wayland(&k.sync, outerPath.String(), socketPath.String(), appID, k.id.String()) + k.container.Bind(outerPath, innerPath, 0) + } else { // bind mount wayland socket (insecure) + hlog.Verbose("direct wayland access, PROCEED WITH CAUTION") + share.ensureRuntimeDir() + k.container.Bind(socketPath, innerPath, 0) + k.sys.UpdatePermType(system.EWayland, socketPath.String(), acl.Read, acl.Write, acl.Execute) + } + } + + if config.Enablements.Unwrap()&system.EX11 != 0 { + if d, ok := k.lookupEnv(display); !ok { + return newWithMessage("DISPLAY is not set") + } else { + socketDir := container.AbsFHSTmp.Append(".X11-unix") + + // the socket file at `/tmp/.X11-unix/X%d` is typically owned by the priv user + // and not accessible by the target user + var socketPath *container.Absolute + if len(d) > 1 && d[0] == ':' { // `:%d` + if n, err := strconv.Atoi(d[1:]); err == nil && n >= 0 { + socketPath = socketDir.Append("X" + strconv.Itoa(n)) + } + } else if len(d) > 5 && strings.HasPrefix(d, "unix:") { // `unix:%s` + if a, err := container.NewAbs(d[5:]); err == nil { + socketPath = a + } + } + if socketPath != nil { + if _, err := k.stat(socketPath.String()); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return &hst.AppError{Step: fmt.Sprintf("access X11 socket %q", socketPath), Err: err} + } + } else { + k.sys.UpdatePermType(system.EX11, socketPath.String(), acl.Read, acl.Write, acl.Execute) + if !config.Container.HostAbstract { + d = "unix:" + socketPath.String() + } + } + } + + k.sys.ChangeHosts("#" + k.user.uid.String()) + k.env[display] = d + k.container.Bind(socketDir, socketDir, 0) + } + } + + if config.Enablements.Unwrap()&system.EPulse != 0 { + // PulseAudio runtime directory (usually `/run/user/%d/pulse`) + pulseRuntimeDir := share.sc.RuntimePath.Append("pulse") + // PulseAudio socket (usually `/run/user/%d/pulse/native`) + pulseSocket := pulseRuntimeDir.Append("native") + + if _, err := k.stat(pulseRuntimeDir.String()); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return &hst.AppError{Step: fmt.Sprintf("access PulseAudio directory %q", pulseRuntimeDir), Err: err} + } + return newWithMessage(fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir)) + } + + if s, err := k.stat(pulseSocket.String()); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return &hst.AppError{Step: fmt.Sprintf("access PulseAudio socket %q", pulseSocket), Err: err} + } + return newWithMessage(fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir)) + } else { + if m := s.Mode(); m&0o006 != 0o006 { + return newWithMessage(fmt.Sprintf("unexpected permissions on %q: %s", pulseSocket, m)) + } + } + + // hard link pulse socket into target-executable share + innerPulseRuntimeDir := share.runtime().Append("pulse") + innerPulseSocket := innerRuntimeDir.Append("pulse", "native") + k.sys.Link(pulseSocket.String(), innerPulseRuntimeDir.String()) + k.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0) + k.env[pulseServer] = "unix:" + innerPulseSocket.String() + + // publish current user's pulse cookie for target user + var paCookiePath *container.Absolute + { + const paLocateStep = "locate PulseAudio cookie" + + // from environment + if p, ok := k.lookupEnv(pulseCookie); ok { + if a, err := container.NewAbs(p); err != nil { + return &hst.AppError{Step: paLocateStep, Err: err} + } else { + // this takes precedence, do not verify whether the file is accessible + paCookiePath = a + goto out + } + } + + // $HOME/.pulse-cookie + if p, ok := k.lookupEnv(home); ok { + if a, err := container.NewAbs(p); err != nil { + return &hst.AppError{Step: paLocateStep, Err: err} + } else { + paCookiePath = a.Append(".pulse-cookie") + } + + if s, err := k.stat(paCookiePath.String()); err != nil { + paCookiePath = nil + if !errors.Is(err, fs.ErrNotExist) { + return &hst.AppError{Step: "access PulseAudio cookie", Err: err} + } + // fallthrough + } else if s.IsDir() { + paCookiePath = nil + } else { + goto out + } + } + + // $XDG_CONFIG_HOME/pulse/cookie + if p, ok := k.lookupEnv(xdgConfigHome); ok { + if a, err := container.NewAbs(p); err != nil { + return &hst.AppError{Step: paLocateStep, Err: err} + } else { + paCookiePath = a.Append("pulse", "cookie") + } + if s, err := k.stat(paCookiePath.String()); err != nil { + paCookiePath = nil + if !errors.Is(err, fs.ErrNotExist) { + return &hst.AppError{Step: "access PulseAudio cookie", Err: err} + } + // fallthrough + } else if s.IsDir() { + paCookiePath = nil + } else { + goto out + } + } + out: + } + + if paCookiePath != nil { + innerDst := hst.AbsTmp.Append("/pulse-cookie") + k.env[pulseCookie] = innerDst.String() + var payload *[]byte + k.container.PlaceP(innerDst, &payload) + k.sys.CopyFile(payload, paCookiePath.String(), 256, 256) + } else { + hlog.Verbose("cannot locate PulseAudio cookie (tried " + + "$PULSE_COOKIE, " + + "$XDG_CONFIG_HOME/pulse/cookie, " + + "$HOME/.pulse-cookie)") + } + } + + if config.Enablements.Unwrap()&system.EDBus != 0 { + // ensure dbus session bus defaults + if config.SessionBus == nil { + config.SessionBus = dbus.NewConfig(config.ID, true, true) + } + + // downstream socket paths + sessionPath, systemPath := share.instance().Append("bus"), share.instance().Append("system_bus_socket") + + // configure dbus proxy + if f, err := k.sys.ProxyDBus( + config.SessionBus, config.SystemBus, + sessionPath.String(), systemPath.String(), + ); err != nil { + return err + } else { + k.dbusMsg = f + } + + // share proxy sockets + sessionInner := innerRuntimeDir.Append("bus") + k.env[dbusSessionBusAddress] = "unix:path=" + sessionInner.String() + k.container.Bind(sessionPath, sessionInner, 0) + k.sys.UpdatePerm(sessionPath.String(), acl.Read, acl.Write) + if config.SystemBus != nil { + systemInner := container.AbsFHSRun.Append("dbus/system_bus_socket") + k.env[dbusSystemBusAddress] = "unix:path=" + systemInner.String() + k.container.Bind(systemPath, systemInner, 0) + k.sys.UpdatePerm(systemPath.String(), acl.Read, acl.Write) + } + } + + // mount root read-only as the final setup Op + k.container.Remount(container.AbsFHSRoot, syscall.MS_RDONLY) + + // append ExtraPerms last + for _, p := range config.ExtraPerms { + if p == nil || p.Path == nil { + continue + } + + if p.Ensure { + k.sys.Ensure(p.Path.String(), 0700) + } + + perms := make(acl.Perms, 0, 3) + if p.Read { + perms = append(perms, acl.Read) + } + if p.Write { + perms = append(perms, acl.Write) + } + if p.Execute { + perms = append(perms, acl.Execute) + } + k.sys.UpdatePermType(system.User, p.Path.String(), perms...) + } + + // flatten and sort env for deterministic behaviour + k.container.Env = make([]string, 0, len(k.env)) + for key, value := range k.env { + if strings.IndexByte(key, '=') != -1 { + return &hst.AppError{Step: "flatten environment", Err: syscall.EINVAL, + Msg: fmt.Sprintf("invalid environment variable %s", key)} + } + k.container.Env = append(k.container.Env, key+"="+value) + } + slices.Sort(k.container.Env) + + if hlog.Load() { + hlog.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s, ops: %d", + k.user.uid, k.user.username, config.Groups, k.container.Args, len(*k.container.Ops)) + } + + return nil +} diff --git a/internal/app/hsu.go b/internal/app/hsu.go new file mode 100644 index 00000000..0078ef0d --- /dev/null +++ b/internal/app/hsu.go @@ -0,0 +1,96 @@ +package app + +import ( + "errors" + "fmt" + "log" + "os" + "os/exec" + "strconv" + "sync" + + "hakurei.app/container" + "hakurei.app/hst" + "hakurei.app/internal/hlog" +) + +// Hsu caches responses from cmd/hsu. +type Hsu struct { + idOnce sync.Once + idErr error + id int + + kOnce sync.Once + k syscallDispatcher +} + +var ErrHsuAccess = errors.New("current user is not in the hsurc file") + +// ensureDispatcher ensures Hsu.k is not nil. +func (h *Hsu) ensureDispatcher() { + h.kOnce.Do(func() { + if h.k == nil { + h.k = direct{} + } + }) +} + +// ID returns the current user hsurc identifier. ErrHsuAccess is returned if the current user is not in hsurc. +func (h *Hsu) ID() (int, error) { + h.ensureDispatcher() + h.idOnce.Do(func() { + h.id = -1 + hsuPath := h.k.mustHsuPath() + + cmd := exec.Command(hsuPath) + cmd.Path = hsuPath + cmd.Stderr = os.Stderr // pass through fatal messages + cmd.Env = make([]string, 0) + cmd.Dir = container.FHSRoot + var ( + p []byte + exitError *exec.ExitError + ) + + const step = "obtain uid from hsu" + if p, h.idErr = h.k.cmdOutput(cmd); h.idErr == nil { + h.id, h.idErr = strconv.Atoi(string(p)) + if h.idErr != nil { + h.idErr = &hst.AppError{Step: step, Err: h.idErr, Msg: "invalid uid string from hsu"} + } + } else if errors.As(h.idErr, &exitError) && exitError != nil && exitError.ExitCode() == 1 { + // hsu prints an error message in this case + h.idErr = &hst.AppError{Step: step, Err: ErrHsuAccess} + } else if os.IsNotExist(h.idErr) { + h.idErr = &hst.AppError{Step: step, Err: os.ErrNotExist, + Msg: fmt.Sprintf("the setuid helper is missing: %s", hsuPath)} + } + }) + + return h.id, h.idErr +} + +// MustID calls [Hsu.ID] and terminates on error. +func (h *Hsu) MustID() int { + id, err := h.ID() + if err == nil { + return id + } + + const fallback = "cannot retrieve user id from setuid wrapper:" + if errors.Is(err, ErrHsuAccess) { + hlog.Verbose("*"+fallback, err) + os.Exit(1) + return -0xdeadbeef + } else if m, ok := container.GetErrorMessage(err); ok { + log.Fatal(m) + return -0xdeadbeef + } else { + log.Fatalln(fallback, err) + return -0xdeadbeef + } +} + +// HsuUid returns target uid for the stable hsu uid format. +// No bounds check is performed, a value retrieved from hsu is expected. +func HsuUid(id, identity int) int { return 1000000 + id*10000 + identity } diff --git a/internal/app/paths.go b/internal/app/paths.go new file mode 100644 index 00000000..3316a22e --- /dev/null +++ b/internal/app/paths.go @@ -0,0 +1,36 @@ +package app + +import ( + "strconv" + + "hakurei.app/container" + "hakurei.app/hst" +) + +// CopyPaths populates a [hst.Paths] struct. +func CopyPaths(v *hst.Paths, userid int) { copyPaths(direct{}, v, userid) } + +// copyPaths populates a [hst.Paths] struct. +func copyPaths(k syscallDispatcher, v *hst.Paths, userid int) { + const xdgRuntimeDir = "XDG_RUNTIME_DIR" + + if tempDir, err := container.NewAbs(k.tempdir()); err != nil { + k.fatalf("invalid TMPDIR: %v", err) + } else { + v.TempDir = tempDir + } + + v.SharePath = v.TempDir.Append("hakurei." + strconv.Itoa(userid)) + k.verbosef("process share directory at %q", v.SharePath) + + r, _ := k.lookupEnv(xdgRuntimeDir) + if a, err := container.NewAbs(r); err != 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 = a + v.RunDirPath = v.RuntimePath.Append("hakurei") + } + k.verbosef("runtime directory at %q", v.RunDirPath) +} diff --git a/internal/app/process.go b/internal/app/process.go index 368b655f..8ac05855 100644 --- a/internal/app/process.go +++ b/internal/app/process.go @@ -33,12 +33,12 @@ type mainState struct { // Time is nil if no process was ever created. Time *time.Time - seal *outcome store state.Store cancel context.CancelFunc cmd *exec.Cmd cmdWait chan error + k *outcome uintptr } @@ -79,13 +79,13 @@ func (ms mainState) beforeExit(isFault bool) { waitDone := make(chan struct{}) // TODO(ophestra): enforce this limit early so it does not have to be done twice shimTimeoutCompensated := shimWaitTimeout - if ms.seal.waitDelay > MaxShimWaitDelay { + if ms.k.waitDelay > MaxShimWaitDelay { shimTimeoutCompensated += MaxShimWaitDelay } else { - shimTimeoutCompensated += ms.seal.waitDelay + shimTimeoutCompensated += ms.k.waitDelay } // this ties waitDone to ctx with the additional compensated timeout duration - go func() { <-ms.seal.ctx.Done(); time.Sleep(shimTimeoutCompensated); close(waitDone) }() + go func() { <-ms.k.ctx.Done(); time.Sleep(shimTimeoutCompensated); close(waitDone) }() select { case err := <-ms.cmdWait: @@ -128,20 +128,20 @@ func (ms mainState) beforeExit(isFault bool) { } hlog.Resume() - if ms.seal.sync != nil { - if err := ms.seal.sync.Close(); err != nil { + if ms.k.sync != nil { + if err := ms.k.sync.Close(); err != nil { perror(err, "close wayland security context") } } - if ms.seal.dbusMsg != nil { - ms.seal.dbusMsg() + if ms.k.dbusMsg != nil { + ms.k.dbusMsg() } } if ms.uintptr&mainNeedsRevert != 0 { - if ok, err := ms.store.Do(ms.seal.user.identity.unwrap(), func(c state.Cursor) { + if ok, err := ms.store.Do(ms.k.user.identity.unwrap(), func(c state.Cursor) { if ms.uintptr&mainNeedsDestroy != 0 { - if err := c.Destroy(ms.seal.id.unwrap()); err != nil { + if err := c.Destroy(ms.k.id.unwrap()); err != nil { perror(err, "destroy state entry") } } @@ -151,7 +151,7 @@ func (ms mainState) beforeExit(isFault bool) { // it is impossible to continue from this point; // revert per-process state here to limit damage ec := system.Process - if revertErr := ms.seal.sys.Revert((*system.Criteria)(&ec)); revertErr != nil { + if revertErr := ms.k.sys.Revert((*system.Criteria)(&ec)); revertErr != nil { var joinError interface { Unwrap() []error error @@ -189,7 +189,7 @@ func (ms mainState) beforeExit(isFault bool) { } } - if err = ms.seal.sys.Revert((*system.Criteria)(&ec)); err != nil { + if err = ms.k.sys.Revert((*system.Criteria)(&ec)); err != nil { perror(err, "revert system setup") } } @@ -219,8 +219,8 @@ func (ms mainState) fatal(fallback string, ferr error) { } // main carries out outcome and terminates. main does not return. -func (seal *outcome) main() { - if !seal.f.CompareAndSwap(false, true) { +func (k *outcome) main() { + if !k.active.CompareAndSwap(false, true) { panic("outcome: attempted to run twice") } @@ -228,15 +228,15 @@ func (seal *outcome) main() { hsuPath := internal.MustHsuPath() // ms.beforeExit required beyond this point - ms := &mainState{seal: seal} + ms := &mainState{k: k} - if err := seal.sys.Commit(); err != nil { + if err := k.sys.Commit(); err != nil { ms.fatal("cannot commit system setup:", err) } ms.uintptr |= mainNeedsRevert - ms.store = state.NewMulti(seal.runDirPath.String()) + ms.store = state.NewMulti(k.runDirPath.String()) - ctx, cancel := context.WithCancel(seal.ctx) + ctx, cancel := context.WithCancel(k.ctx) defer cancel() ms.cancel = cancel @@ -255,14 +255,14 @@ func (seal *outcome) main() { // passed through to shim by hsu shimEnv + "=" + strconv.Itoa(fd), // interpreted by hsu - "HAKUREI_IDENTITY=" + seal.user.identity.String(), + "HAKUREI_IDENTITY=" + k.user.identity.String(), } } - if len(seal.user.supp) > 0 { - hlog.Verbosef("attaching supplementary group ids %s", seal.user.supp) + if len(k.user.supp) > 0 { + hlog.Verbosef("attaching supplementary group ids %s", k.user.supp) // interpreted by hsu - ms.cmd.Env = append(ms.cmd.Env, "HAKUREI_GROUPS="+strings.Join(seal.user.supp, " ")) + ms.cmd.Env = append(ms.cmd.Env, "HAKUREI_GROUPS="+strings.Join(k.user.supp, " ")) } hlog.Verbosef("setuid helper at %s", hsuPath) @@ -284,8 +284,8 @@ func (seal *outcome) main() { go func() { setupErr <- e.Encode(&shimParams{ os.Getpid(), - seal.waitDelay, - seal.container, + k.waitDelay, + k.container, hlog.Load(), }) }() @@ -302,12 +302,12 @@ func (seal *outcome) main() { } // shim accepted setup payload, create process state - if ok, err := ms.store.Do(seal.user.identity.unwrap(), func(c state.Cursor) { + if ok, err := ms.store.Do(k.user.identity.unwrap(), func(c state.Cursor) { if err := c.Save(&state.State{ - ID: seal.id.unwrap(), + ID: k.id.unwrap(), PID: ms.cmd.Process.Pid, Time: *ms.Time, - }, seal.ct); err != nil { + }, k.ct); err != nil { ms.fatal("cannot save state entry:", err) } }); err != nil { diff --git a/internal/app/seal.go b/internal/app/seal.go deleted file mode 100644 index c7dd9d63..00000000 --- a/internal/app/seal.go +++ /dev/null @@ -1,602 +0,0 @@ -package app - -import ( - "bytes" - "context" - "encoding/gob" - "errors" - "fmt" - "io" - "io/fs" - "os" - "os/user" - "slices" - "strconv" - "strings" - "sync/atomic" - "syscall" - "time" - - "hakurei.app/container" - "hakurei.app/hst" - "hakurei.app/internal/app/state" - "hakurei.app/internal/hlog" - "hakurei.app/internal/sys" - "hakurei.app/system" - "hakurei.app/system/acl" - "hakurei.app/system/dbus" - "hakurei.app/system/wayland" -) - -func newWithMessage(msg string) error { return newWithMessageError(msg, os.ErrInvalid) } -func newWithMessageError(msg string, err error) error { - return &hst.AppError{Step: "finalise", Err: err, Msg: msg} -} - -// An outcome is the runnable state of a hakurei container via [hst.Config]. -type outcome struct { - // copied from initialising [app] - id *stringPair[state.ID] - // copied from [sys.State] - runDirPath *container.Absolute - - // initial [hst.Config] gob stream for state data; - // this is prepared ahead of time as config is clobbered during seal creation - ct io.WriterTo - // dump dbus proxy message buffer - dbusMsg func() - - user hsuUser - sys *system.I - ctx context.Context - - waitDelay time.Duration - container *container.Params - env map[string]string - sync *os.File - - f atomic.Bool -} - -// shareHost holds optional share directory state that must not be accessed directly -type shareHost struct { - // whether XDG_RUNTIME_DIR is used post hsu - useRuntimeDir bool - // process-specific directory in tmpdir, empty if unused - sharePath *container.Absolute - // process-specific directory in XDG_RUNTIME_DIR, empty if unused - runtimeSharePath *container.Absolute - - seal *outcome - sc hst.Paths -} - -// ensureRuntimeDir must be called if direct access to paths within XDG_RUNTIME_DIR is required -func (share *shareHost) ensureRuntimeDir() { - if share.useRuntimeDir { - return - } - share.useRuntimeDir = true - share.seal.sys.Ensure(share.sc.RunDirPath.String(), 0700) - share.seal.sys.UpdatePermType(system.User, share.sc.RunDirPath.String(), acl.Execute) - share.seal.sys.Ensure(share.sc.RuntimePath.String(), 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset - share.seal.sys.UpdatePermType(system.User, share.sc.RuntimePath.String(), acl.Execute) -} - -// instance returns a process-specific share path within tmpdir -func (share *shareHost) instance() *container.Absolute { - if share.sharePath != nil { - return share.sharePath - } - share.sharePath = share.sc.SharePath.Append(share.seal.id.String()) - share.seal.sys.Ephemeral(system.Process, share.sharePath.String(), 0711) - return share.sharePath -} - -// runtime returns a process-specific share path within XDG_RUNTIME_DIR -func (share *shareHost) runtime() *container.Absolute { - if share.runtimeSharePath != nil { - return share.runtimeSharePath - } - share.ensureRuntimeDir() - share.runtimeSharePath = share.sc.RunDirPath.Append(share.seal.id.String()) - share.seal.sys.Ephemeral(system.Process, share.runtimeSharePath.String(), 0700) - share.seal.sys.UpdatePerm(share.runtimeSharePath.String(), acl.Execute) - return share.runtimeSharePath -} - -// hsuUser stores post-hsu credentials and metadata -type hsuUser struct { - identity *stringPair[int] - // target uid resolved by hid:aid - uid *stringPair[int] - - // supplementary group ids - supp []string - - // app user home directory - home *container.Absolute - // passwd database username - username string -} - -func (seal *outcome) finalise(ctx context.Context, k sys.State, config *hst.Config) error { - const ( - home = "HOME" - shell = "SHELL" - - xdgConfigHome = "XDG_CONFIG_HOME" - xdgRuntimeDir = "XDG_RUNTIME_DIR" - xdgSessionClass = "XDG_SESSION_CLASS" - xdgSessionType = "XDG_SESSION_TYPE" - - term = "TERM" - display = "DISPLAY" - - pulseServer = "PULSE_SERVER" - pulseCookie = "PULSE_COOKIE" - - dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS" - dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS" - ) - - if ctx == nil { - // unreachable - panic("invalid call to finalise") - } - if seal.ctx != nil { - // unreachable - panic("attempting to finalise twice") - } - seal.ctx = ctx - - if config == nil { - // unreachable - return newWithMessage("invalid configuration") - } - if config.Home == nil { - return newWithMessage("invalid path to home directory") - } - - { - // encode initial configuration for state tracking - ct := new(bytes.Buffer) - if err := gob.NewEncoder(ct).Encode(config); err != nil { - return &hst.AppError{Step: "encode initial config", Err: err} - } - seal.ct = ct - } - - // allowed identity range 0 to 9999, this is checked again in hsu - if config.Identity < 0 || config.Identity > 9999 { - return newWithMessage(fmt.Sprintf("identity %d out of range", config.Identity)) - } - - seal.user = hsuUser{ - identity: newInt(config.Identity), - home: config.Home, - username: config.Username, - } - - if seal.user.username == "" { - seal.user.username = "chronos" - } else if !isValidUsername(seal.user.username) { - return newWithMessage(fmt.Sprintf("invalid user name %q", seal.user.username)) - } - seal.user.uid = newInt(sys.MustUid(k, seal.user.identity.unwrap())) - - seal.user.supp = make([]string, len(config.Groups)) - for i, name := range config.Groups { - if g, err := k.LookupGroup(name); err != nil { - var unknownGroupError user.UnknownGroupError - if errors.As(err, &unknownGroupError) { - return newWithMessageError(fmt.Sprintf("unknown group %q", name), unknownGroupError) - } else { - return &hst.AppError{Step: "look up group by name", Err: err} - } - } else { - seal.user.supp[i] = g.Gid - } - } - - // permissive defaults - if config.Container == nil { - hlog.Verbose("container configuration not supplied, PROCEED WITH CAUTION") - - if config.Shell == nil { - config.Shell = container.AbsFHSRoot.Append("bin", "sh") - s, _ := k.LookupEnv(shell) - if a, err := container.NewAbs(s); err == nil { - config.Shell = a - } - } - - // hsu clears the environment so resolve paths early - if config.Path == nil { - if len(config.Args) > 0 { - if p, err := k.LookPath(config.Args[0]); err != nil { - return &hst.AppError{Step: "look up executable file", Err: err} - } else if config.Path, err = container.NewAbs(p); err != nil { - return newWithMessageError(err.Error(), err) - } - } else { - config.Path = config.Shell - } - } - - conf := &hst.ContainerConfig{ - Userns: true, - HostNet: true, - HostAbstract: true, - Tty: true, - - Filesystem: []hst.FilesystemConfigJSON{ - // autoroot, includes the home directory - {FilesystemConfig: &hst.FSBind{ - Target: container.AbsFHSRoot, - Source: container.AbsFHSRoot, - Write: true, - Special: true, - }}, - }, - } - - // bind GPU stuff - if config.Enablements.Unwrap()&(system.EX11|system.EWayland) != 0 { - conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{Source: container.AbsFHSDev.Append("dri"), Device: true, Optional: true}}) - } - // opportunistically bind kvm - conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{Source: container.AbsFHSDev.Append("kvm"), Device: true, Optional: true}}) - - // hide nscd from container if present - nscd := container.AbsFHSVar.Append("run/nscd") - if _, err := k.Stat(nscd.String()); !errors.Is(err, fs.ErrNotExist) { - conf.Filesystem = append(conf.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSEphemeral{Target: nscd}}) - } - - // do autoetc last - conf.Filesystem = append(conf.Filesystem, - hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{ - Target: container.AbsFHSEtc, - Source: container.AbsFHSEtc, - Special: true, - }}, - ) - - config.Container = conf - } - - // late nil checks for pd behaviour - if config.Shell == nil { - return newWithMessage("invalid shell path") - } - if config.Path == nil { - return newWithMessage("invalid program path") - } - - var mapuid, mapgid *stringPair[int] - { - var uid, gid int - var err error - seal.container, seal.env, err = newContainer(config.Container, k, seal.id.String(), &uid, &gid) - seal.waitDelay = config.Container.WaitDelay - if err != nil { - return &hst.AppError{Step: "initialise container configuration", Err: err} - } - if len(config.Args) == 0 { - config.Args = []string{config.Path.String()} - } - seal.container.Path = config.Path - seal.container.Args = config.Args - - mapuid = newInt(uid) - mapgid = newInt(gid) - if seal.env == nil { - seal.env = make(map[string]string, 1<<6) - } - } - - // inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as mapped uid - innerRuntimeDir := container.AbsFHSRunUser.Append(mapuid.String()) - seal.env[xdgRuntimeDir] = innerRuntimeDir.String() - seal.env[xdgSessionClass] = "user" - seal.env[xdgSessionType] = "tty" - - share := &shareHost{seal: seal, sc: k.Paths()} - seal.runDirPath = share.sc.RunDirPath - seal.sys = system.New(seal.ctx, seal.user.uid.unwrap()) - seal.sys.Ensure(share.sc.SharePath.String(), 0711) - - { - runtimeDir := share.sc.SharePath.Append("runtime") - seal.sys.Ensure(runtimeDir.String(), 0700) - seal.sys.UpdatePermType(system.User, runtimeDir.String(), acl.Execute) - runtimeDirInst := runtimeDir.Append(seal.user.identity.String()) - seal.sys.Ensure(runtimeDirInst.String(), 0700) - seal.sys.UpdatePermType(system.User, runtimeDirInst.String(), acl.Read, acl.Write, acl.Execute) - seal.container.Tmpfs(container.AbsFHSRunUser, 1<<12, 0755) - seal.container.Bind(runtimeDirInst, innerRuntimeDir, container.BindWritable) - } - - { - tmpdir := share.sc.SharePath.Append("tmpdir") - seal.sys.Ensure(tmpdir.String(), 0700) - seal.sys.UpdatePermType(system.User, tmpdir.String(), acl.Execute) - tmpdirInst := tmpdir.Append(seal.user.identity.String()) - seal.sys.Ensure(tmpdirInst.String(), 01700) - seal.sys.UpdatePermType(system.User, tmpdirInst.String(), acl.Read, acl.Write, acl.Execute) - // mount inner /tmp from share so it shares persistence and storage behaviour of host /tmp - seal.container.Bind(tmpdirInst, container.AbsFHSTmp, container.BindWritable) - } - - { - username := "chronos" - if seal.user.username != "" { - username = seal.user.username - } - seal.container.Dir = seal.user.home - seal.env["HOME"] = seal.user.home.String() - seal.env["USER"] = username - seal.env[shell] = config.Shell.String() - - seal.container.Place(container.AbsFHSEtc.Append("passwd"), - []byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Hakurei:"+seal.user.home.String()+":"+config.Shell.String()+"\n")) - seal.container.Place(container.AbsFHSEtc.Append("group"), - []byte("hakurei:x:"+mapgid.String()+":\n")) - } - - // pass TERM for proper terminal I/O in initial process - if t, ok := k.LookupEnv(term); ok { - seal.env[term] = t - } - - if config.Enablements.Unwrap()&system.EWayland != 0 { - // outer wayland socket (usually `/run/user/%d/wayland-%d`) - var socketPath *container.Absolute - if name, ok := k.LookupEnv(wayland.WaylandDisplay); !ok { - hlog.Verbose(wayland.WaylandDisplay + " is not set, assuming " + wayland.FallbackName) - socketPath = share.sc.RuntimePath.Append(wayland.FallbackName) - } else if a, err := container.NewAbs(name); err != nil { - socketPath = share.sc.RuntimePath.Append(name) - } else { - socketPath = a - } - - innerPath := innerRuntimeDir.Append(wayland.FallbackName) - seal.env[wayland.WaylandDisplay] = wayland.FallbackName - - if !config.DirectWayland { // set up security-context-v1 - appID := config.ID - if appID == "" { - // use instance ID in case app id is not set - appID = "app.hakurei." + seal.id.String() - } - // downstream socket paths - outerPath := share.instance().Append("wayland") - seal.sys.Wayland(&seal.sync, outerPath.String(), socketPath.String(), appID, seal.id.String()) - seal.container.Bind(outerPath, innerPath, 0) - } else { // bind mount wayland socket (insecure) - hlog.Verbose("direct wayland access, PROCEED WITH CAUTION") - share.ensureRuntimeDir() - seal.container.Bind(socketPath, innerPath, 0) - seal.sys.UpdatePermType(system.EWayland, socketPath.String(), acl.Read, acl.Write, acl.Execute) - } - } - - if config.Enablements.Unwrap()&system.EX11 != 0 { - if d, ok := k.LookupEnv(display); !ok { - return newWithMessage("DISPLAY is not set") - } else { - socketDir := container.AbsFHSTmp.Append(".X11-unix") - - // the socket file at `/tmp/.X11-unix/X%d` is typically owned by the priv user - // and not accessible by the target user - var socketPath *container.Absolute - if len(d) > 1 && d[0] == ':' { // `:%d` - if n, err := strconv.Atoi(d[1:]); err == nil && n >= 0 { - socketPath = socketDir.Append("X" + strconv.Itoa(n)) - } - } else if len(d) > 5 && strings.HasPrefix(d, "unix:") { // `unix:%s` - if a, err := container.NewAbs(d[5:]); err == nil { - socketPath = a - } - } - if socketPath != nil { - if _, err := k.Stat(socketPath.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: fmt.Sprintf("access X11 socket %q", socketPath), Err: err} - } - } else { - seal.sys.UpdatePermType(system.EX11, socketPath.String(), acl.Read, acl.Write, acl.Execute) - if !config.Container.HostAbstract { - d = "unix:" + socketPath.String() - } - } - } - - seal.sys.ChangeHosts("#" + seal.user.uid.String()) - seal.env[display] = d - seal.container.Bind(socketDir, socketDir, 0) - } - } - - if config.Enablements.Unwrap()&system.EPulse != 0 { - // PulseAudio runtime directory (usually `/run/user/%d/pulse`) - pulseRuntimeDir := share.sc.RuntimePath.Append("pulse") - // PulseAudio socket (usually `/run/user/%d/pulse/native`) - pulseSocket := pulseRuntimeDir.Append("native") - - if _, err := k.Stat(pulseRuntimeDir.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: fmt.Sprintf("access PulseAudio directory %q", pulseRuntimeDir), Err: err} - } - return newWithMessage(fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir)) - } - - if s, err := k.Stat(pulseSocket.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: fmt.Sprintf("access PulseAudio socket %q", pulseSocket), Err: err} - } - return newWithMessage(fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir)) - } else { - if m := s.Mode(); m&0o006 != 0o006 { - return newWithMessage(fmt.Sprintf("unexpected permissions on %q: %s", pulseSocket, m)) - } - } - - // hard link pulse socket into target-executable share - innerPulseRuntimeDir := share.runtime().Append("pulse") - innerPulseSocket := innerRuntimeDir.Append("pulse", "native") - seal.sys.Link(pulseSocket.String(), innerPulseRuntimeDir.String()) - seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0) - seal.env[pulseServer] = "unix:" + innerPulseSocket.String() - - // publish current user's pulse cookie for target user - var paCookiePath *container.Absolute - { - const paLocateStep = "locate PulseAudio cookie" - - // from environment - if p, ok := k.LookupEnv(pulseCookie); ok { - if a, err := container.NewAbs(p); err != nil { - return &hst.AppError{Step: paLocateStep, Err: err} - } else { - // this takes precedence, do not verify whether the file is accessible - paCookiePath = a - goto out - } - } - - // $HOME/.pulse-cookie - if p, ok := k.LookupEnv(home); ok { - if a, err := container.NewAbs(p); err != nil { - return &hst.AppError{Step: paLocateStep, Err: err} - } else { - paCookiePath = a.Append(".pulse-cookie") - } - - if s, err := k.Stat(paCookiePath.String()); err != nil { - paCookiePath = nil - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: "access PulseAudio cookie", Err: err} - } - // fallthrough - } else if s.IsDir() { - paCookiePath = nil - } else { - goto out - } - } - - // $XDG_CONFIG_HOME/pulse/cookie - if p, ok := k.LookupEnv(xdgConfigHome); ok { - if a, err := container.NewAbs(p); err != nil { - return &hst.AppError{Step: paLocateStep, Err: err} - } else { - paCookiePath = a.Append("pulse", "cookie") - } - if s, err := k.Stat(paCookiePath.String()); err != nil { - paCookiePath = nil - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: "access PulseAudio cookie", Err: err} - } - // fallthrough - } else if s.IsDir() { - paCookiePath = nil - } else { - goto out - } - } - out: - } - - if paCookiePath != nil { - innerDst := hst.AbsTmp.Append("/pulse-cookie") - seal.env[pulseCookie] = innerDst.String() - var payload *[]byte - seal.container.PlaceP(innerDst, &payload) - seal.sys.CopyFile(payload, paCookiePath.String(), 256, 256) - } else { - hlog.Verbose("cannot locate PulseAudio cookie (tried " + - "$PULSE_COOKIE, " + - "$XDG_CONFIG_HOME/pulse/cookie, " + - "$HOME/.pulse-cookie)") - } - } - - if config.Enablements.Unwrap()&system.EDBus != 0 { - // ensure dbus session bus defaults - if config.SessionBus == nil { - config.SessionBus = dbus.NewConfig(config.ID, true, true) - } - - // downstream socket paths - sessionPath, systemPath := share.instance().Append("bus"), share.instance().Append("system_bus_socket") - - // configure dbus proxy - if f, err := seal.sys.ProxyDBus( - config.SessionBus, config.SystemBus, - sessionPath.String(), systemPath.String(), - ); err != nil { - return err - } else { - seal.dbusMsg = f - } - - // share proxy sockets - sessionInner := innerRuntimeDir.Append("bus") - seal.env[dbusSessionBusAddress] = "unix:path=" + sessionInner.String() - seal.container.Bind(sessionPath, sessionInner, 0) - seal.sys.UpdatePerm(sessionPath.String(), acl.Read, acl.Write) - if config.SystemBus != nil { - systemInner := container.AbsFHSRun.Append("dbus/system_bus_socket") - seal.env[dbusSystemBusAddress] = "unix:path=" + systemInner.String() - seal.container.Bind(systemPath, systemInner, 0) - seal.sys.UpdatePerm(systemPath.String(), acl.Read, acl.Write) - } - } - - // mount root read-only as the final setup Op - seal.container.Remount(container.AbsFHSRoot, syscall.MS_RDONLY) - - // append ExtraPerms last - for _, p := range config.ExtraPerms { - if p == nil || p.Path == nil { - continue - } - - if p.Ensure { - seal.sys.Ensure(p.Path.String(), 0700) - } - - perms := make(acl.Perms, 0, 3) - if p.Read { - perms = append(perms, acl.Read) - } - if p.Write { - perms = append(perms, acl.Write) - } - if p.Execute { - perms = append(perms, acl.Execute) - } - seal.sys.UpdatePermType(system.User, p.Path.String(), perms...) - } - - // flatten and sort env for deterministic behaviour - seal.container.Env = make([]string, 0, len(seal.env)) - for k, v := range seal.env { - if strings.IndexByte(k, '=') != -1 { - return &hst.AppError{Step: "flatten environment", Err: syscall.EINVAL, - Msg: fmt.Sprintf("invalid environment variable %s", k)} - } - seal.container.Env = append(seal.container.Env, k+"="+v) - } - slices.Sort(seal.container.Env) - - if hlog.Load() { - hlog.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s, ops: %d", - seal.user.uid, seal.user.username, config.Groups, seal.container.Args, len(*seal.container.Ops)) - } - - return nil -} diff --git a/internal/sys/hsu.go b/internal/sys/hsu.go deleted file mode 100644 index 5ae12046..00000000 --- a/internal/sys/hsu.go +++ /dev/null @@ -1,88 +0,0 @@ -package sys - -import ( - "errors" - "fmt" - "log" - "os" - "os/exec" - "strconv" - "sync" - - "hakurei.app/container" - "hakurei.app/hst" - "hakurei.app/internal" - "hakurei.app/internal/hlog" -) - -// Hsu caches responses from cmd/hsu. -type Hsu struct { - idOnce sync.Once - idErr error - id int -} - -var ErrHsuAccess = errors.New("current user is not in the hsurc file") - -// ID returns the current user hsurc identifier. ErrHsuAccess is returned if the current user is not in hsurc. -func (h *Hsu) ID() (int, error) { - h.idOnce.Do(func() { - h.id = -1 - hsuPath := internal.MustHsuPath() - - cmd := exec.Command(hsuPath) - cmd.Path = hsuPath - cmd.Stderr = os.Stderr // pass through fatal messages - cmd.Env = make([]string, 0) - cmd.Dir = container.FHSRoot - var ( - p []byte - exitError *exec.ExitError - ) - - const step = "obtain uid from hsu" - if p, h.idErr = cmd.Output(); h.idErr == nil { - h.id, h.idErr = strconv.Atoi(string(p)) - if h.idErr != nil { - h.idErr = &hst.AppError{Step: step, Err: h.idErr, Msg: "invalid uid string from hsu"} - } - } else if errors.As(h.idErr, &exitError) && exitError != nil && exitError.ExitCode() == 1 { - // hsu prints an error message in this case - h.idErr = &hst.AppError{Step: step, Err: ErrHsuAccess} - } else if os.IsNotExist(h.idErr) { - h.idErr = &hst.AppError{Step: step, Err: os.ErrNotExist, - Msg: fmt.Sprintf("the setuid helper is missing: %s", hsuPath)} - } - }) - - return h.id, h.idErr -} - -func (h *Hsu) Uid(identity int) (int, error) { - id, err := h.ID() - if err == nil { - return 1000000 + id*10000 + identity, nil - } - return id, err -} - -// MustUid calls [State.Uid] and terminates on error. -func MustUid(s State, identity int) int { - uid, err := s.Uid(identity) - if err == nil { - return uid - } - - const fallback = "cannot obtain uid from setuid wrapper:" - if errors.Is(err, ErrHsuAccess) { - hlog.Verbose("*"+fallback, err) - os.Exit(1) - return -0xdeadbeef - } else if m, ok := container.GetErrorMessage(err); ok { - log.Fatal(m) - return -0xdeadbeef - } else { - log.Fatalln(fallback, err) - return -0xdeadbeef - } -} diff --git a/internal/sys/interface.go b/internal/sys/interface.go deleted file mode 100644 index f2465e62..00000000 --- a/internal/sys/interface.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package sys wraps OS interaction library functions. -package sys - -import ( - "io/fs" - "log" - "os/user" - "strconv" - - "hakurei.app/container" - "hakurei.app/hst" - "hakurei.app/internal/hlog" -) - -// State provides safe interaction with operating system state. -type State interface { - // Getuid provides [os.Getuid]. - Getuid() int - // Getgid provides [os.Getgid]. - Getgid() int - // LookupEnv provides [os.LookupEnv]. - LookupEnv(key string) (string, bool) - // TempDir provides [os.TempDir]. - TempDir() string - // LookPath provides exec.LookPath. - LookPath(file string) (string, error) - // MustExecutable provides [container.MustExecutable]. - MustExecutable() string - // LookupGroup provides [user.LookupGroup]. - LookupGroup(name string) (*user.Group, error) - // ReadDir provides [os.ReadDir]. - ReadDir(name string) ([]fs.DirEntry, error) - // Stat provides [os.Stat]. - Stat(name string) (fs.FileInfo, error) - // Open provides [os.Open]. - Open(name string) (fs.File, error) - // EvalSymlinks provides filepath.EvalSymlinks. - EvalSymlinks(path string) (string, error) - // Exit provides [os.Exit]. - Exit(code int) - - Println(v ...any) - Printf(format string, v ...any) - - // Paths returns a populated [hst.Paths] struct. - Paths() hst.Paths - // Uid invokes hsu and returns target uid. - // Any errors returned by Uid is already wrapped [hlog.BaseError]. - Uid(identity int) (int, error) -} - -// MustGetUserID obtains user id from hsu by querying uid of identity 0. -func MustGetUserID(os State) int { return (MustUid(os, 0) / 10000) - 100 } - -// CopyPaths is a generic implementation of [hst.Paths]. -func CopyPaths(os State, v *hst.Paths, userid int) { - if tempDir, err := container.NewAbs(os.TempDir()); err != nil { - log.Fatalf("invalid TMPDIR: %v", err) - } else { - v.TempDir = tempDir - } - - v.SharePath = v.TempDir.Append("hakurei." + strconv.Itoa(userid)) - hlog.Verbosef("process share directory at %q", v.SharePath) - - r, _ := os.LookupEnv(xdgRuntimeDir) - if a, err := container.NewAbs(r); err != 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 = a - v.RunDirPath = v.RuntimePath.Append("hakurei") - } - hlog.Verbosef("runtime directory at %q", v.RunDirPath) -} diff --git a/internal/sys/std.go b/internal/sys/std.go deleted file mode 100644 index 11334c72..00000000 --- a/internal/sys/std.go +++ /dev/null @@ -1,44 +0,0 @@ -package sys - -import ( - "io/fs" - "os" - "os/exec" - "os/user" - "path/filepath" - "sync" - - "hakurei.app/container" - "hakurei.app/hst" - "hakurei.app/internal" - "hakurei.app/internal/hlog" -) - -// Std implements System using the standard library. -type Std struct { - paths hst.Paths - pathsOnce sync.Once - Hsu -} - -func (s *Std) Getuid() int { return os.Getuid() } -func (s *Std) Getgid() int { return os.Getgid() } -func (s *Std) LookupEnv(key string) (string, bool) { return os.LookupEnv(key) } -func (s *Std) TempDir() string { return os.TempDir() } -func (s *Std) LookPath(file string) (string, error) { return exec.LookPath(file) } -func (s *Std) MustExecutable() string { return container.MustExecutable() } -func (s *Std) LookupGroup(name string) (*user.Group, error) { return user.LookupGroup(name) } -func (s *Std) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) } -func (s *Std) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) } -func (s *Std) Open(name string) (fs.File, error) { return os.Open(name) } -func (s *Std) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } -func (s *Std) Exit(code int) { internal.Exit(code) } -func (s *Std) Println(v ...any) { hlog.Verbose(v...) } -func (s *Std) Printf(format string, v ...any) { hlog.Verbosef(format, v...) } - -const xdgRuntimeDir = "XDG_RUNTIME_DIR" - -func (s *Std) Paths() hst.Paths { - s.pathsOnce.Do(func() { CopyPaths(s, &s.paths, MustGetUserID(s)) }) - return s.paths -} -- cgit v1.3.1