aboutsummaryrefslogtreecommitdiffhomepage
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/app/app.go26
-rw-r--r--cmd/app/app_merged.go15
-rw-r--r--cmd/app/app_sep.go15
-rw-r--r--cmd/app/app_test.go10
-rw-r--r--cmd/app/doc.go362
-rw-r--r--cmd/app/lock.go19
-rw-r--r--cmd/app/main.go137
-rw-r--r--cmd/dist/main.go14
-rw-r--r--cmd/hakurei/testsuite/configuration.nix252
-rw-r--r--cmd/hakurei/testsuite/default.nix81
-rw-r--r--cmd/hakurei/testsuite/flake.lock49
-rw-r--r--cmd/hakurei/testsuite/flake.nix75
-rw-r--r--cmd/hakurei/testsuite/hsu.nix23
-rw-r--r--cmd/hakurei/testsuite/nixos.nix407
-rw-r--r--cmd/hakurei/testsuite/options.nix364
-rw-r--r--cmd/hakurei/testsuite/package.nix145
-rw-r--r--cmd/hakurei/testsuite/sandbox/main.go409
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/device.go134
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/mapuid.go124
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/pdlike.go141
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/simple.go140
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/sum.go22
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go9
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go9
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/testdata.go125
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/tty.go145
-rw-r--r--cmd/hakurei/testsuite/sandbox/tester/main.go224
-rw-r--r--cmd/hakurei/testsuite/test.py315
-rw-r--r--cmd/mbf/cache.go8
-rw-r--r--cmd/mbf/info.go4
-rw-r--r--cmd/mbf/internal/ci/ci.go569
-rw-r--r--cmd/mbf/internal/ci/ci_test.go56
-rw-r--r--cmd/mbf/internal/pkgsite/api.go (renamed from cmd/mbf/internal/pkgserver/api.go)4
-rw-r--r--cmd/mbf/internal/pkgsite/api_test.go (renamed from cmd/mbf/internal/pkgserver/api_test.go)2
-rw-r--r--cmd/mbf/internal/pkgsite/index.go (renamed from cmd/mbf/internal/pkgserver/index.go)2
-rw-r--r--cmd/mbf/internal/pkgsite/index_test.go (renamed from cmd/mbf/internal/pkgserver/index_test.go)2
-rw-r--r--cmd/mbf/internal/pkgsite/search.go (renamed from cmd/mbf/internal/pkgserver/search.go)2
-rw-r--r--cmd/mbf/internal/pkgsite/ui/index.html (renamed from cmd/mbf/internal/pkgserver/ui/index.html)0
-rw-r--r--cmd/mbf/internal/pkgsite/ui/index.ts (renamed from cmd/mbf/internal/pkgserver/ui/index.ts)0
-rw-r--r--cmd/mbf/internal/pkgsite/ui/style.css (renamed from cmd/mbf/internal/pkgserver/ui/style.css)0
-rw-r--r--cmd/mbf/internal/pkgsite/ui/tsconfig.json (renamed from cmd/mbf/internal/pkgserver/ui/tsconfig.json)0
-rw-r--r--cmd/mbf/internal/pkgsite/ui/ui.go (renamed from cmd/mbf/internal/pkgserver/ui/ui.go)0
-rw-r--r--cmd/mbf/internal/pkgsite/ui/ui_full.go (renamed from cmd/mbf/internal/pkgserver/ui/ui_full.go)0
-rw-r--r--cmd/mbf/internal/pkgsite/ui/ui_stub.go (renamed from cmd/mbf/internal/pkgserver/ui/ui_stub.go)0
-rw-r--r--cmd/mbf/main.go68
-rw-r--r--cmd/sharefs/testsuite/main.go119
-rw-r--r--cmd/sharefs/testsuite/raceattr.go122
47 files changed, 4697 insertions, 52 deletions
diff --git a/cmd/app/app.go b/cmd/app/app.go
index 9679413f..ca809334 100644
--- a/cmd/app/app.go
+++ b/cmd/app/app.go
@@ -14,6 +14,24 @@ import (
"hakurei.app/hst"
)
+// base is the root of the persistent state directory.
+type base check.Absolute
+
+// append calls filepath.Join with b as the first element.
+func (b *base) append(elem ...string) *check.Absolute {
+ return (*check.Absolute)(b).Append(elem...)
+}
+
+// initial returns the pathname of the bottom layer.
+func (b *base) initial() *check.Absolute {
+ return b.append("initial")
+}
+
+// state returns the pathname of the home directory for id.
+func (b *base) state(id string) *check.Absolute {
+ return b.append("state", id)
+}
+
// parsePair parses a NUL-delimited quoted paths pair.
func parsePair(s string) (source, target *check.Absolute, err error) {
var p string
@@ -35,7 +53,7 @@ func parsePair(s string) (source, target *check.Absolute, err error) {
// corresponding [hst.Config].
func parse(
id string,
- base *check.Absolute,
+ base *base,
r io.Reader,
templateP *string,
) (*hst.Config, error) {
@@ -44,7 +62,7 @@ func parse(
root := hst.FSOverlay{
Target: fhs.AbsRoot,
- Lower: []*check.Absolute{base.Append("initial")},
+ Lower: []*check.Absolute{base.initial()},
}
c := hst.Config{
ID: id,
@@ -65,7 +83,7 @@ func parse(
{FilesystemConfig: &root},
{FilesystemConfig: &hst.FSBind{
Target: home,
- Source: base.Append("state", id),
+ Source: base.state(id),
Write: true,
Ensure: true,
}},
@@ -110,7 +128,7 @@ func parse(
*templateP = template
}
c.Identity = v
- root.Upper = base.Append("template", template)
+ root.Upper = base.template(template)
}
if err := scanOnce(); err != nil {
diff --git a/cmd/app/app_merged.go b/cmd/app/app_merged.go
new file mode 100644
index 00000000..e0a31f94
--- /dev/null
+++ b/cmd/app/app_merged.go
@@ -0,0 +1,15 @@
+//go:build merge
+
+package main
+
+import "hakurei.app/check"
+
+// template returns the pathname of an upperdir.
+func (b *base) template(template string) *check.Absolute {
+ return b.append("template", template, "upper")
+}
+
+// work returns the pathname of a workdir.
+func (b *base) work(template string) *check.Absolute {
+ return b.append("template", template, "work")
+}
diff --git a/cmd/app/app_sep.go b/cmd/app/app_sep.go
new file mode 100644
index 00000000..9ec98e41
--- /dev/null
+++ b/cmd/app/app_sep.go
@@ -0,0 +1,15 @@
+//go:build !merge
+
+package main
+
+import "hakurei.app/check"
+
+// template returns the pathname of an upperdir.
+func (b *base) template(template string) *check.Absolute {
+ return b.append("template", template)
+}
+
+// work returns the pathname of a workdir.
+func (b *base) work(template string) *check.Absolute {
+ return b.append("work", template)
+}
diff --git a/cmd/app/app_test.go b/cmd/app/app_test.go
index 1aab4759..90c0068e 100644
--- a/cmd/app/app_test.go
+++ b/cmd/app/app_test.go
@@ -13,7 +13,7 @@ import (
func TestParse(t *testing.T) {
t.Parallel()
- base := fhs.AbsProc.Append("nonexistent")
+ b := (*base)(fhs.AbsProc.Append("nonexistent"))
testCases := []struct {
name string
data string
@@ -74,13 +74,13 @@ talk com.canonical.Unity
{FilesystemConfig: &hst.FSOverlay{
Target: fhs.AbsRoot,
Lower: []*check.Absolute{
- base.Append("initial"),
+ b.initial(),
},
- Upper: base.Append("template", "nonfree"),
+ Upper: b.template("nonfree"),
}},
{FilesystemConfig: &hst.FSBind{
Target: hst.AbsPrivateTmp.Append("home"),
- Source: base.Append("state", "com.discordapp.Discord"),
+ Source: b.state("com.discordapp.Discord"),
Write: true,
Ensure: true,
}},
@@ -128,7 +128,7 @@ talk com.canonical.Unity
got, err := parse(
tc.name,
- base,
+ b,
strings.NewReader(tc.data),
nil,
)
diff --git a/cmd/app/doc.go b/cmd/app/doc.go
new file mode 100644
index 00000000..8c5397fd
--- /dev/null
+++ b/cmd/app/doc.go
@@ -0,0 +1,362 @@
+/*
+The app program is a proof-of-concept frontend for cmd/hakurei.
+
+This program is not covered by the compatibility promise. The command line
+interface and configuration syntax may change at any time.
+
+# Installation
+
+Compile cmd/app with these arguments:
+
+ go build -trimpath \
+ -ldflags='-s -w
+ -buildid=
+ -X hakurei.app/internal/info.hsuPath=/usr/local/bin/hsu' \
+ ./cmd/app
+
+Replace /usr/local/bin/hsu with the hakurei installation's absolute hsu
+pathname.
+
+# Sharing files
+
+Sharing files between apps is only possible with the permissionless shared
+filesystem ([cmd/sharefs]), as apps generally do not share credentials. It can
+be built without any special linker flags. Create a symlink at
+/usr/bin/mount.fuse.sharefs pointing to the sharefs binary when mounting sharefs
+via fstab:
+
+ sharefs /sdcard fuse.sharefs rw,noexec,nosuid,nodev,noatime,allow_other,mkdir,source=/var/lib/sdcard,setuid=1023,setgid=1023 0 0
+
+Replace /var/lib/sdcard with the location of the backing directory. User and
+group id must be specified in numerical form, it must own the backing directory.
+The mount point does not have to be /sdcard.
+
+# General setup
+
+The entire directory structure containing persistent app data must be created
+manually for now, due to the different ownership requirements being impossible
+to set up directly through cmd/hsu.
+
+The environment variable ROSA_APP_PATH should be set to the absolute pathname of
+the persistent state directory. This document will use $ROSA_APP_PATH to refer
+to the persistent state directory. The $ROSA_APP_PATH directory should be owned
+by the user declared in /etc/hsurc and invoking cmd/app, and must be readable
+and executable by all subordinate users. This can be done by making it
+world-readable and executable, or by adding a group directive to the common
+file.
+
+The $ROSA_APP_PATH/app directory contains app configuration files, named after
+their reverse-DNS style application identifier string. It should be owned by
+the user invoking cmd/app.
+
+The $ROSA_APP_PATH/initial directory contains the read-only base of every
+app template. Everything within it, including the directory itself, should be
+owned by the reserved user, its user and group id obtained by the command:
+
+ app id
+
+This document assumes a [Void Linux rootfs tarball] is unpacked here. It is
+possible to use other Linux distributions; Void linux is selected here because
+its package manager works correctly out of the box in the hakurei container
+environment. If the use case does not require glibc, [Alpine Linux] or
+[Chimera Linux] might be more suitable.
+
+Once the tarball is unpacked at $ROSA_APP_PATH/initial, create a directory named
+chronos in $ROSA_APP_PATH/initial/home, and directories block, bus, class, dev,
+and devices in $ROSA_APP_PATH/initial/sys. Then, recursively change ownerships
+of files and directories in $ROSA_APP_PATH/initial to the reserved user/group.
+
+The $ROSA_APP_PATH/lock directory contains lock files guarding entry into
+mutable and app containers of every template. It must be owned by the user
+invoking cmd/app.
+
+The $ROSA_APP_PATH/state directory contains app home directories, named after
+their reverse-DNS style application identifier string. The directory itself
+should be owned by the user invoking cmd/app; each directory inside must be
+owned by the subordinate user/group id of its app, obtained by the command:
+
+ app id "$APPID"
+
+where "$APPID" is the second component of the first line in the configuration
+file of the corresponding app. It is considered good practice, but not required,
+for these directory to have the permission bits set to 0700.
+
+The $ROSA_APP_PATH/template directory contains templates that apps are based
+on, which are all derived from the initial layer. The directory itself
+should be owned by the user invoking cmd/app, and each directory inside should
+be owned by the reserved user/group. Their names are used in the first component
+of the first line in the configuration file of each app, to specify that the
+file is derived from that template.
+
+The $ROSA_APP_PATH/work directory contains overlay work directories for mutable
+containers. It must contain one manually created, empty directory for each
+template, named after the template directory itself, owned by the reserved
+user/group. The directory itself and its contents should have the permission
+bits set to 0700.
+
+# Snapshots and cloning
+
+To reduce disk space used by templates, [ZFS clones] or an equivalent can be
+used. In such a setup, where each template occupies a different filesystem, the
+"merge" build tag is required, and the work directory can be omitted during
+directory creation. Instead, the individual template directories contain both
+upper and work directories.
+
+# Configuring the base template
+
+This section describes basic setup required for the typical desktop use case.
+Generally, multiple templates are created to mitigate the global nature of
+conventional package managers. The setup described in this section applies to
+all templates. For convenience, a "base" template should be created, containing
+this setup, and all future templates should be copied from the base template.
+This section assumes the template is named "base".
+
+Create an empty directory at $ROSA_APP_PATH/template/base, owned by the reserved
+user, with permission bits set to 0755. Create its corresponding work directory
+at $ROSA_APP_PATH/work/base, also owned by the reserved user, with permission
+bits set to 0700. Once this is complete, enter its mutable container with the
+command:
+
+ app enter --shell=/bin/sh base
+
+This command overrides the container configuration to use the shell program at
+/bin/sh. Normally, cmd/app uses zsh, which is not yet installed.
+
+Once in the container, populate /etc/resolv.conf with some well-known DNS
+service, for example:
+
+ nameserver 8.8.8.8
+ nameserver 8.8.4.4
+
+The mutable container does not bind the host /etc/resolv.conf. It is generally
+recommended to populate it with a well-known service here to avoid trouble in
+future template changes. A later section configures regular app containers to
+use host configuration.
+
+After populating nameserver configuration, install zsh using the package manager
+provided by the distribution unpacked earlier. On Void Linux, this is done by
+the command:
+
+ xbps-install zsh
+
+Wait for the package installation to complete, configure zsh if necessary, then
+exit from the shell, and re-enter the container without overriding the login
+shell:
+
+ app enter base
+
+Some packages are generally required in the container for the typical desktop
+use case: xdg-user-dirs to reconfigure "well known" user directories to point
+to the inner sharefs mount point, mesa to interact with the GPU, dconf to
+configure GTK, xdg-desktop-portal-gtk to have gtk talk to dconf. Fonts generally
+need to be installed as well. Additionally, a text editor can be installed for
+editing configuration files later on. On Void Linux, these packages are
+installed by the command:
+
+ xbps-install \
+ vim \
+ mesa \
+ mesa-dri \
+ mesa-intel-dri \
+ mesa-vaapi \
+ mesa-vulkan-intel \
+ mesa-vulkan-radeon \
+ mesa-vulkan-overlay-layer \
+ dconf \
+ xdg-user-dirs \
+ xdg-desktop-portal-gtk \
+ noto-fonts-ttf \
+ noto-fonts-ttf-variable \
+ noto-fonts-ttf-extra \
+ noto-fonts-emoji \
+ noto-fonts-cjk \
+ noto-fonts-cjk-variable \
+ noto-fonts-cjk-sans \
+ noto-fonts-cjk-sans-variable \
+ noto-fonts-cjk-serif \
+ noto-fonts-cjk-serif-variable
+
+Add -32bit variants of mesa packages if multilib support is required. Adjust
+the package selection based on use case.
+
+Edit /etc/xdg/user-dirs.defaults and point directories to the inner sharefs
+mount point as required. The resulting file should look like this:
+
+ # Default settings for user directories
+ #
+ # The values are relative pathnames from the home directory and
+ # will be translated on a per-path-element basis into the users locale
+ DESKTOP=../../sdcard/Desktop
+ DOWNLOAD=../../sdcard/Download
+ TEMPLATES=../../sdcard/Templates
+ PUBLICSHARE=../../sdcard/Public
+ DOCUMENTS=../../sdcard/Documents
+ MUSIC=../../sdcard/Music
+ PICTURES=../../sdcard/Pictures
+ VIDEOS=../../sdcard/Movies
+ PROJECTS=../../sdcard/Projects
+ # Another alternative is:
+ #MUSIC=Documents/Music
+ #PICTURES=Documents/Pictures
+ #VIDEOS=Documents/Videos
+
+If this is configured, it must be applied to the home directory of each app
+individually. This can be done for all apps via the shell expression:
+
+ app run | xargs -n 1 echo app run --command=xdg-user-dirs-update
+
+This must also be done for every newly created app.
+
+The /etc/dconf directory can be set up to provide defaults across all apps. To
+do so, edit /etc/dconf/profile/user:
+
+ user-db:user
+ system-db:local
+ system-db:site
+ system-db:distro
+
+Then, place files in /etc/dconf/db/local.d containing dconf configuration. For
+example:
+
+ [org/gnome/desktop/interface]
+ gtk-enable-primary-paste=true
+ color-scheme='prefer-dark'
+ gtk-theme='adw-gtk3-dark'
+ icon-theme='Papirus-Dark'
+
+Themes must be installed in the container. Change colour-scheme and themes
+accordingly. Setting gtk-enable-primary-paste restores clipboard behaviour that
+GNOME maintainers decided to break.
+
+After editing these configuration files, update dconf system databases:
+
+ dconf update
+
+# Common configuration
+
+The optional $ROSA_APP_PATH/common file contains common configuration included
+after the specific configuration of each app. Some bind mounts are required for
+almost every graphical program, and many widely used libraries are configured
+through the environment. For most setups, these directives are generally
+required:
+
+ ; for libudev
+ ro "/sys/block"
+ ro "/sys/bus"
+ ro "/sys/class"
+ ro "/sys/dev"
+ ro "/sys/devices"
+
+ env EDITOR=vim
+ ; for apps to play nice with xdg-dbus-proxy
+ ro+ "/etc/machine-id"
+ ; template must have a /etc/resolv.conf file
+ ro+ "/etc/resolv.conf"
+ ; group name of the sharefs group configured on host
+ group media_rw
+ ; for sharefs
+ rw "/sdcard"
+ ; refer to /usr/share/zoneinfo
+ env TZ=Asia/Tokyo
+
+ ; must be installed first
+ env XCURSOR_THEME=volantes_cursors
+ ; must be generated first if using glibc
+ env LANG=en_GB.UTF-8
+ env LC_COLLATE=C
+
+# Installing an app
+
+An app is defined by a configuration file in $ROSA_APP_PATH/app, and a
+persistent state directory in $ROSA_APP_PATH/state. Before creating an app, its
+identity must be decided. Different apps should generally not share an identity.
+The next unused identity can be found using the command:
+
+ app next
+
+If the -v argument is added before the "next" command, cmd/app will additionally
+show apps sharing the same identity.
+
+Once the identity is decided, the subordinate user/group id can be obtained by
+the command:
+
+ app id "$APPID"
+
+where "$APPID" is the identity of this app. A directory must be created under
+$ROSA_APP_PATH/state, owned by this user and group id. Its permission bits
+should be set to 0700. A configuration file with the same name, owned by the
+user invoking cmd/app, must be placed in $ROSA_APP_PATH/app. Its contents are
+described in the next section. The reverse-DNS style application identifier
+string is submitted to the Wayland display server, and used as part of the
+dbus preset if enabled, so the correct identifier must be obtained. If no such
+identifier exist, use the domain of the app home page.
+
+# Configuring an app
+
+The configuration file starts with two structural directives, and the remaining
+lines are freestanding directives applied in order.
+
+The first structural directive is a line containing two components separated by
+the ':' byte. The first component is the template used by the app. the second
+component is the decimal representation of the app identity. The second
+structural directive is a shell expression passed to the shell serving as the
+initial process of the app.
+
+After the structural directives, each line contains exactly one directive or
+comment. Comment lines begin with a ';' byte: these lines are not interpreted by
+cmd/app in any way.
+
+The following section documents currently available freestanding directives.
+
+# Directives
+
+ interactive start initial process as an interactive shell
+ gpu expose GPU devices to the container
+ system_bus enable system bus in the dbus proxy
+
+ wayland expose a Wayland pathname socket via security-context-v1
+ x11 expose the X11 pathname socket
+ dbus enable the per-container xdg-dbus-proxy daemon
+ pipewire expose a pipewire pathname socket via SecurityContext
+
+ multiarch unblock system calls required for multiarch to work on
+ multiarch-enabled targets (amd64, arm64)
+ devel unblock ptrace and friends
+ userns unblock userns creation and container setup syscalls
+ net enable network access
+ abstract enable access to external abstract unix sockets
+ tty unblock dangerous terminal I/O (faking input)
+ mapuid map the target user id to the user id of the user
+ invoking cmd/app in the container user namespace
+ device mount /dev/ from the init mount namespace as is in the
+ container mount namespace
+
+ share_runtime share XDG_RUNTIME_DIR between containers under the same
+ identity
+ share_tmpdir share TMPDIR between containers under the same identity
+
+ username <name> set username of the emulated user
+ hostname <name> set container hostname
+ env KEY=VALUE set an environment variable for the initial process
+
+ ro "pathname" make a host path available to the container; the string
+ must be presented in Go string literal syntax, to
+ specify a different inner pathname, end the outer
+ pathname with NUL and specify the inner pathname after
+ the NUL byte
+ rw "pathname" like ro, but the resulting mount entry is made writable
+ ro+ "pathname" like ro, but is skipped if pathname does not exist
+ rw+ "pathname" like ro+, but the resulting mount entry is made writable
+
+ own name add an own policy for the dbus proxy
+ own_system name like own, but for the system bus if enabled
+ talk name add a talk policy for the dbus proxy
+ talk_system name like talk, but for the system bus if enabled
+
+[cmd/sharefs]: https://pkg.go.dev/hakurei.app/cmd/sharefs
+[Void Linux rootfs tarball]: https://voidlinux.org/download
+[Alpine Linux]: https://alpinelinux.org
+[Chimera Linux]: https://chimera-linux.org
+[ZFS clones]: https://openzfs.github.io/openzfs-docs/man/v2.4/8/zfs-clone.8.html
+*/
+package main
diff --git a/cmd/app/lock.go b/cmd/app/lock.go
index 88068381..2a419ea1 100644
--- a/cmd/app/lock.go
+++ b/cmd/app/lock.go
@@ -8,7 +8,6 @@ import (
"strings"
"syscall"
- "hakurei.app/check"
"hakurei.app/fhs"
"hakurei.app/hst"
"hakurei.app/internal/env"
@@ -24,15 +23,15 @@ func (e MutationConflictError) Error() string {
}
// informTemplate guards intention of a template or its derivatives.
-func informTemplate(base *check.Absolute, name string, mutable bool) (func() error, error) {
- mu := lockedfile.MutexAt(base.Append("lock", name).String())
+func informTemplate(b *base, name string, mutable bool) (func() error, error) {
+ mu := lockedfile.MutexAt(b.append("lock", name).String())
if unlock, err := mu.Lock(); err != nil {
return nil, err
} else {
defer unlock()
}
- marker := base.Append("lock", "."+name)
+ marker := b.append("lock", "."+name)
if p, err := os.ReadFile(marker.String()); err == nil {
if _, err = os.Stat(fhs.AbsProc.Append(string(p)).String()); err == nil {
return nil, MutationConflictError(p)
@@ -72,8 +71,8 @@ func informTemplate(base *check.Absolute, name string, mutable bool) (func() err
if !root.Target.Is(fhs.AbsRoot) ||
len(root.Lower) != 1 ||
- !root.Lower[0].Is(base.Append("initial")) ||
- !root.Upper.Is(base.Append("template", name)) ||
+ !root.Lower[0].Is(b.initial()) ||
+ !root.Upper.Is(b.template(name)) ||
root.Work != nil {
continue
}
@@ -102,12 +101,12 @@ func informTemplate(base *check.Absolute, name string, mutable bool) (func() err
}
// acquireTemplate obtains exclusivity of a template.
-func acquireTemplate(base *check.Absolute, name string) (remove func() error, err error) {
- return informTemplate(base, name, true)
+func acquireTemplate(b *base, name string) (remove func() error, err error) {
+ return informTemplate(b, name, true)
}
// enterTemplate checks against exclusivity of a template.
-func enterTemplate(base *check.Absolute, name string) error {
- _, err := informTemplate(base, name, false)
+func enterTemplate(b *base, name string) error {
+ _, err := informTemplate(b, name, false)
return err
}
diff --git a/cmd/app/main.go b/cmd/app/main.go
index 00bceb57..9b51d601 100644
--- a/cmd/app/main.go
+++ b/cmd/app/main.go
@@ -1,24 +1,25 @@
-// The app program is a proof-of-concept frontend for cmd/hakurei.
-//
-// This program is not covered by the compatibility promise. The command line
-// interface and configuration syntax may change at any time.
package main
import (
"context"
"errors"
+ "fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
+ "slices"
+ "strconv"
+ "strings"
"syscall"
"hakurei.app/check"
"hakurei.app/command"
"hakurei.app/fhs"
"hakurei.app/hst"
+ "hakurei.app/internal/outcome"
"hakurei.app/message"
)
@@ -36,7 +37,7 @@ func main() {
flagBase string
flagInsecure bool
- base, template, initial *check.Absolute
+ b *base
)
c := command.New(os.Stderr, log.Printf, "app", func([]string) (err error) {
msg.SwapVerbose(flagVerbose)
@@ -44,14 +45,15 @@ func main() {
if flagBase == "" {
flagBase = "state"
}
+
+ var a *check.Absolute
if flagBase, err = filepath.Abs(flagBase); err != nil {
return
- } else if base, err = check.NewAbs(flagBase); err != nil {
+ } else if a, err = check.NewAbs(flagBase); err != nil {
return
}
+ b = (*base)(a)
- template = base.Append("template")
- initial = base.Append("initial")
return
}).Flag(
&flagVerbose,
@@ -76,7 +78,7 @@ func main() {
"enter", "Enter mutable state template",
func(args []string) error {
if len(args) != 1 {
- return list(template, true)
+ return list(b.append("template"), true)
}
config := hst.Config{
@@ -86,9 +88,9 @@ func main() {
Filesystem: []hst.FilesystemConfigJSON{
{FilesystemConfig: &hst.FSOverlay{
Target: fhs.AbsRoot,
- Lower: []*check.Absolute{initial},
- Upper: template.Append(args[0]),
- Work: base.Append("work", args[0]),
+ Lower: []*check.Absolute{b.initial()},
+ Upper: b.template(args[0]),
+ Work: b.work(args[0]),
}},
{FilesystemConfig: &hst.FSEphemeral{
Target: fhs.AbsTmp,
@@ -122,7 +124,7 @@ func main() {
config.Container.Home = a
}
- remove, err := acquireTemplate(base, args[0])
+ remove, err := acquireTemplate(b, args[0])
if err != nil {
return err
}
@@ -148,19 +150,19 @@ func main() {
"run", "Start the named application",
func(args []string) error {
if len(args) < 1 {
- return list(base.Append("app"), false)
+ return list(b.append("app"), false)
}
var config *hst.Config
var r io.Reader
- f, err := os.Open(base.Append("app", args[0]).String())
+ f, err := os.Open(b.append("app", args[0]).String())
if err != nil {
return err
}
r = f
var common *os.File
- if common, err = os.Open(base.Append("common").String()); err != nil {
+ if common, err = os.Open(b.append("common").String()); err != nil {
if !errors.Is(err, os.ErrNotExist) {
_ = f.Close()
return err
@@ -170,7 +172,7 @@ func main() {
}
var name string
- config, err = parse(args[0], base, r, &name)
+ config, err = parse(args[0], b, r, &name)
if closeErr := f.Close(); err == nil {
err = closeErr
}
@@ -187,7 +189,7 @@ func main() {
config.Container.Args[2] = flagCommand
}
- if err = enterTemplate(base, name); err != nil {
+ if err = enterTemplate(b, name); err != nil {
return err
}
return run(ctx, msg, flagInsecure, config, args[1:]...)
@@ -200,6 +202,105 @@ func main() {
)
}
+ c.NewCommand(
+ "id", "Show user/group id of the specified appid",
+ func(args []string) error {
+ var appid int
+ switch len(args) {
+ case 0:
+ log.Println("appid not specified, assuming reserved user")
+ break
+
+ case 1:
+ var err error
+ appid, err = strconv.Atoi(args[0])
+ if err != nil {
+ return os.ErrInvalid
+ }
+ break
+
+ default:
+ return errors.New("id requires 1 argument")
+ }
+
+ fmt.Println(hst.ToUser(outcome.Info().User, appid))
+ return nil
+ },
+ )
+
+ c.NewCommand(
+ "next", "Find next unused identity",
+ func([]string) error {
+ var names []string
+ if dents, err := os.ReadDir(b.append("app").String()); err != nil {
+ return err
+ } else {
+ names = make([]string, 0, len(dents))
+ for _, dent := range dents {
+ name := dent.Name()
+ if dent.IsDir() || (len(name) > 0 && name[0] == '.') {
+ continue
+ }
+ names = append(names, name)
+ }
+ }
+
+ apps := make([]*hst.Config, len(names))
+ for i, name := range names {
+ r, err := os.Open(b.append("app", name).String())
+ if err != nil {
+ return err
+ }
+
+ apps[i], err = parse(name, b, r, nil)
+ if closeErr := r.Close(); err == nil {
+ err = closeErr
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ p := make(map[int][]*hst.Config)
+ for _, config := range apps {
+ p[config.Identity] = append(p[config.Identity], config)
+ }
+
+ identities := make([]int, 0, len(p))
+ for identity, a := range p {
+ identities = append(identities, identity)
+ if msg.IsVerbose() && len(a) != 1 {
+ ids := make([]string, len(a))
+ for i, config := range a {
+ ids[i] = config.ID
+ }
+ slices.Sort(ids)
+ msg.Verbosef(
+ "%s shares identity %d",
+ strings.Join(ids, ", "), identity,
+ )
+ }
+ }
+
+ if len(identities) == 0 {
+ // 0 is the reserved identity
+ fmt.Println(1)
+ return nil
+ }
+
+ slices.Sort(identities)
+ next := identities[0] - 1
+ for _, identity := range identities {
+ if identity != next+1 {
+ break
+ }
+ next = identity
+ }
+ fmt.Println(next + 1)
+ return nil
+ },
+ )
+
c.MustParse(os.Args[1:], func(err error) {
if e, ok := errors.AsType[*exec.ExitError](err); ok && e != nil {
os.Exit(e.ExitCode())
diff --git a/cmd/dist/main.go b/cmd/dist/main.go
index 8e1c57c1..1034f282 100644
--- a/cmd/dist/main.go
+++ b/cmd/dist/main.go
@@ -56,6 +56,7 @@ func main() {
verbose := os.Getenv("VERBOSE") != ""
runTests := os.Getenv("HAKUREI_DIST_MAKE") == ""
+ race := os.Getenv("HAKUREI_RACE") != ""
version = getenv("HAKUREI_VERSION", strings.TrimSpace(version))
prefix := getenv("PREFIX", "/usr/local")
destdir := getenv("DESTDIR", "dist")
@@ -64,6 +65,10 @@ func main() {
log.Println()
}
+ if race {
+ version += "-race"
+ }
+
if err := os.MkdirAll(destdir, 0755); err != nil {
log.Fatal(err)
}
@@ -95,12 +100,17 @@ func main() {
verboseFlag = "-buildvcs=false"
}
+ raceFlag := "-race"
+ if !race {
+ raceFlag = "-buildvcs=false"
+ }
+
log.Printf("Building hakurei %s for %s/%s.", version, runtime.GOOS, runtime.GOARCH)
mustRun(ctx, nil, "go", "generate", "./...")
mustRun(
ctx, nil, "go", "build",
"-trimpath",
- verboseFlag, "-o", s,
+ verboseFlag, raceFlag, "-o", s,
"-ldflags=-s -w "+
"-buildid= -linkmode external -extldflags=-static "+
"-X hakurei.app/internal/info.buildVersion="+version+" "+
@@ -125,7 +135,7 @@ func main() {
if runTests {
log.Println("##### Testing Hakurei.")
mustRun(
- ctx, nil, "go", "test",
+ ctx, nil, "go", "test", raceFlag,
"-ldflags=-buildid= -linkmode external -extldflags=-static",
"./...",
)
diff --git a/cmd/hakurei/testsuite/configuration.nix b/cmd/hakurei/testsuite/configuration.nix
new file mode 100644
index 00000000..62d8239d
--- /dev/null
+++ b/cmd/hakurei/testsuite/configuration.nix
@@ -0,0 +1,252 @@
+{
+ lib,
+ pkgs,
+ config,
+ ...
+}:
+{
+ users.users = {
+ alice = {
+ isNormalUser = true;
+ description = "Alice Foobar";
+ password = "foobar";
+ uid = 1000;
+ };
+ untrusted = {
+ isNormalUser = true;
+ description = "Untrusted user";
+ password = "foobar";
+ uid = 1001;
+
+ # For deny unmapped uid test:
+ packages = [ config.environment.hakurei.package ];
+ };
+ };
+
+ home-manager.users.alice.home.stateVersion = "24.11";
+
+ # Automatically login on tty1 as a normal user:
+ services.getty.autologinUser = "alice";
+
+ security.pam.loginLimits = [
+ {
+ domain = "@users";
+ item = "rtprio";
+ type = "-";
+ value = 1;
+ }
+ ];
+
+ environment = {
+ systemPackages = with pkgs; [
+ # For D-Bus tests:
+ mako
+ libnotify
+ ];
+
+ variables = {
+ SWAYSOCK = "/tmp/sway-ipc.sock";
+ WLR_RENDERER = "pixman";
+ };
+
+ # To help with OCR:
+ etc."xdg/foot/foot.ini".text = lib.generators.toINI { } {
+ main = {
+ font = "inconsolata:size=14";
+ };
+ colors = rec {
+ foreground = "000000";
+ background = "ffffff";
+ regular2 = foreground;
+ };
+ };
+ };
+
+ fonts.packages = [ pkgs.inconsolata ];
+
+ # Automatically configure and start Sway when logging in on tty1:
+ programs.bash.loginShellInit = ''
+ if [ "$(tty)" = "/dev/tty1" ]; then
+ set -e
+
+ mkdir -p ~/.config/sway
+ (sed s/Mod4/Mod1/ /etc/sway/config &&
+ echo 'output * bg ${pkgs.nixos-artwork.wallpapers.simple-light-gray.gnomeFilePath} fill' &&
+ echo 'output Virtual-1 res 1680x1050') > ~/.config/sway/config
+
+ sway --validate
+ systemd-cat --identifier=session sway && touch /tmp/sway-exit-ok
+ fi
+ '';
+
+ programs.sway.enable = true;
+
+ # For PulseAudio tests:
+ security.rtkit.enable = true;
+ services.pipewire = {
+ enable = true;
+ alsa.enable = true;
+ alsa.support32Bit = true;
+ pulse.enable = true;
+ jack.enable = true;
+ };
+
+ virtualisation = {
+ # Hopefully reduces spurious test failures:
+ memorySize = if pkgs.stdenv.hostPlatform.is32bit then 2046 else 8192;
+
+ qemu.options = [
+ # Need to switch to a different GPU driver than the default one (-vga std) so that Sway can launch:
+ "-vga none -device virtio-gpu-pci"
+
+ # Increase Go test compiler performance:
+ "-smp 16"
+ ];
+ };
+
+ # Disk image is too small for some tests:
+ boot.tmp.useTmpfs = true;
+
+ environment.hakurei = {
+ enable = true;
+ stateDir = "/var/lib/hakurei";
+ users.alice = 0;
+
+ extraHomeConfig =
+ { config, ... }:
+ {
+ # To test merge deduplication:
+ options._hakurei.stateVersion = lib.mkOption { type = lib.types.str; };
+
+ config = {
+ home = { inherit (config._hakurei) stateVersion; };
+ _hakurei.stateVersion = "23.05";
+ };
+ };
+
+ commonPaths = [
+ {
+ type = "bind";
+ src = "/var/tmp";
+ write = true;
+ }
+ ];
+
+ apps = {
+ "cat.gensokyo.extern.bash.linger-timeout" = {
+ name = "hakurei-check-linger-timeout";
+ identity = 9999;
+ share = pkgs.bash;
+ packages = [ pkgs.bash ];
+ command = ''
+ sleep infinity & disown
+ exit
+ '';
+ wait_delay = 1;
+ enablements = {
+ wayland = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.foot.noEnablements" = {
+ name = "ne-foot";
+ identity = 1;
+ shareUid = true;
+ verbose = true;
+ share = pkgs.foot;
+ packages = with pkgs; [
+ foot
+
+ # For wayland-info:
+ wayland-utils
+ ];
+ command = "foot";
+ enablements = {
+ dbus = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.foot.noEnablements.immediate" = {
+ name = "ne-foot-immediate";
+ identity = 1;
+ shareUid = true;
+ verbose = true;
+ wait_delay = -1;
+ share = pkgs.foot;
+ packages = [ ];
+ command = "foot";
+ enablements = {
+ dbus = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.foot.pulseaudio" = {
+ name = "pa-foot";
+ identity = 2;
+ verbose = true;
+ share = pkgs.foot;
+ packages = [ pkgs.foot ];
+ command = "foot";
+ enablements.dbus = false;
+ };
+
+ "cat.gensokyo.extern.Alacritty.x11" = {
+ name = "x11-alacritty";
+ identity = 1;
+ shareUid = true;
+ verbose = true;
+ share = pkgs.alacritty;
+ packages = with pkgs; [
+ # For X11 terminal emulator:
+ alacritty
+
+ # For glinfo:
+ mesa-demos
+ ];
+ command = "alacritty";
+ enablements = {
+ wayland = false;
+ x11 = true;
+ dbus = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.foot.directWayland" = {
+ name = "da-foot";
+ identity = 4;
+ verbose = true;
+ insecureWayland = true;
+ share = pkgs.foot;
+ packages = with pkgs; [
+ foot
+
+ # For wayland-info:
+ wayland-utils
+ ];
+ command = "foot";
+ enablements = {
+ dbus = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.strace.wantFail" = {
+ name = "strace-failure";
+ identity = 5;
+ verbose = true;
+ share = pkgs.strace;
+ command = "strace true";
+ enablements = {
+ wayland = false;
+ x11 = false;
+ dbus = false;
+ pipewire = false;
+ };
+ };
+ };
+ };
+}
diff --git a/cmd/hakurei/testsuite/default.nix b/cmd/hakurei/testsuite/default.nix
new file mode 100644
index 00000000..81daa0a2
--- /dev/null
+++ b/cmd/hakurei/testsuite/default.nix
@@ -0,0 +1,81 @@
+{
+ lib,
+ testers,
+ buildFHSEnv,
+ writeShellScriptBin,
+
+ system,
+ self,
+ withRace ? false,
+}:
+
+testers.nixosTest {
+ name = "hakurei" + (if withRace then "-race" else "");
+ nodes.machine =
+ { options, pkgs, ... }:
+ let
+ fhs =
+ let
+ hakurei = options.environment.hakurei.package.default;
+ in
+ buildFHSEnv {
+ pname = "hakurei-fhs";
+ inherit (hakurei) version;
+ targetPkgs = _: hakurei.targetPkgs;
+ extraOutputsToInstall = [ "dev" ];
+ profile = ''
+ export PKG_CONFIG_PATH="/usr/share/pkgconfig:$PKG_CONFIG_PATH"
+ '';
+ };
+ in
+ {
+ environment.systemPackages = [
+ # For go tests:
+ (writeShellScriptBin "hakurei-test" ''
+ # Assert hst CGO_ENABLED=0: ${
+ with pkgs;
+ runCommand "hakurei-hst-cgo" { nativeBuildInputs = [ self.packages.${system}.hakurei.go ]; } ''
+ cp -r ${options.environment.hakurei.package.default.src} "$out"
+ chmod -R +w "$out"
+ cp ${writeText "hst_cgo_test.go" ''package hakurei_test;import("testing";"hakurei.app/hst");func TestTemplate(t *testing.T){hst.Template()}''} "$out/hst_cgo_test.go"
+ (cd "$out" && HOME="$(mktemp -d)" CGO_ENABLED=0 go test .)
+ ''
+ }
+
+ cd ${self.packages.${system}.hakurei.src}
+ ${fhs}/bin/hakurei-fhs -c \
+ 'CC="clang -O3 -Werror" go test --tags=noskip ${if withRace then "-race" else "-count 16"} ./...' \
+ &> /tmp/hakurei-test.log && \
+ touch /tmp/hakurei-test-ok
+ touch /tmp/hakurei-test-done
+ '')
+ ];
+
+ # Run with Go race detector:
+ environment.hakurei = lib.mkIf withRace rec {
+ # race detector does not support static linking
+ package = (pkgs.callPackage ./package.nix { }).overrideAttrs (previousAttrs: {
+ env = previousAttrs.env // {
+ GOFLAGS = previousAttrs.env.GOFLAGS + " -race";
+ };
+ });
+ hsuPackage = options.environment.hakurei.hsuPackage.default.override { hakurei = package; };
+ };
+
+ imports = [
+ ./configuration.nix
+
+ self.nixosModules.hakurei
+ self.inputs.home-manager.nixosModules.home-manager
+ ];
+ };
+
+ # adapted from nixos sway integration tests
+
+ # testScriptWithTypes:49: error: Cannot call function of unknown type
+ # (machine.succeed if succeed else machine.execute)(
+ # ^
+ # Found 1 error in 1 file (checked 1 source file)
+ skipTypeCheck = true;
+ testScript = builtins.readFile ./test.py;
+}
diff --git a/cmd/hakurei/testsuite/flake.lock b/cmd/hakurei/testsuite/flake.lock
new file mode 100644
index 00000000..5537506a
--- /dev/null
+++ b/cmd/hakurei/testsuite/flake.lock
@@ -0,0 +1,49 @@
+{
+ "nodes": {
+ "home-manager": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1787146702,
+ "narHash": "sha256-YbRcLdU/yK4gWsQg7V8WTKZHfXL33g8+wSFUX3wyevs=",
+ "owner": "nix-community",
+ "repo": "home-manager",
+ "rev": "173b7e8d40fdc8c296a9c99854314f17a3a1704c",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-community",
+ "ref": "release-26.05",
+ "repo": "home-manager",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1787101114,
+ "narHash": "sha256-gwrPcFf/rDjHPaVflbDZ040ZDmBTRj/7+s8ZmE2SaIM=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "b18a4b905f8d028dc4476412e6d6891728695379",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-26.05",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "home-manager": "home-manager",
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/cmd/hakurei/testsuite/flake.nix b/cmd/hakurei/testsuite/flake.nix
new file mode 100644
index 00000000..e1df8990
--- /dev/null
+++ b/cmd/hakurei/testsuite/flake.nix
@@ -0,0 +1,75 @@
+{
+ description = "hakurei container tool and nixos module";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
+
+ home-manager = {
+ url = "github:nix-community/home-manager/release-26.05";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs =
+ {
+ self,
+ nixpkgs,
+ home-manager,
+ }:
+ let
+ supportedSystems = [ "x86_64-linux" ];
+
+ forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
+ nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
+ in
+ {
+ nixosModules.hakurei = import ./nixos.nix self.packages;
+
+ checks = forAllSystems (
+ system:
+ let
+ pkgs = nixpkgsFor.${system};
+
+ inherit (pkgs) callPackage;
+ in
+ {
+ hakurei = callPackage ./. { inherit system self; };
+ race = callPackage ./. {
+ inherit system self;
+ withRace = true;
+ };
+ }
+ );
+
+ packages = forAllSystems (
+ system:
+ let
+ inherit (self.packages.${system}) hakurei hsu;
+ pkgs = nixpkgsFor.${system};
+ in
+ {
+ default = hakurei;
+ hakurei = pkgs.pkgsStatic.callPackage ./package.nix {
+ inherit (pkgs)
+ # passthru.buildInputs
+ go_1_27
+ clang
+
+ # nativeBuildInputs
+ pkg-config
+ wayland-scanner
+ makeBinaryWrapper
+
+ # appPackages
+ glibc
+ xdg-dbus-proxy
+
+ # for check
+ nettools
+ ;
+ };
+ hsu = pkgs.callPackage ./hsu.nix { inherit (self.packages.${system}) hakurei; };
+ }
+ );
+ };
+}
diff --git a/cmd/hakurei/testsuite/hsu.nix b/cmd/hakurei/testsuite/hsu.nix
new file mode 100644
index 00000000..d1ddcb77
--- /dev/null
+++ b/cmd/hakurei/testsuite/hsu.nix
@@ -0,0 +1,23 @@
+{
+ lib,
+ buildGoModule,
+ hakurei ? abort "hakurei package required",
+}:
+
+buildGoModule {
+ pname = "${hakurei.pname}-hsu";
+ inherit (hakurei) version;
+
+ src = ../../hsu;
+ inherit (hakurei) vendorHash;
+ env.CGO_ENABLED = 0;
+
+ preBuild = ''
+ go mod init hsu >& /dev/null
+ '';
+
+ ldflags = lib.attrsets.foldlAttrs (
+ ldflags: name: value:
+ ldflags ++ [ "-X main.${name}=${value}" ]
+ ) [ "-s -w" ] { hakureiPath = "${hakurei}/libexec/hakurei"; };
+}
diff --git a/cmd/hakurei/testsuite/nixos.nix b/cmd/hakurei/testsuite/nixos.nix
new file mode 100644
index 00000000..49bfffb6
--- /dev/null
+++ b/cmd/hakurei/testsuite/nixos.nix
@@ -0,0 +1,407 @@
+packages:
+{
+ lib,
+ pkgs,
+ config,
+ ...
+}:
+
+let
+ inherit (lib)
+ lists
+ attrsets
+ mkMerge
+ mkIf
+ mapAttrs
+ foldlAttrs
+ optional
+ optionals
+ ;
+
+ cfg = config.environment.hakurei;
+
+ # userid*userOffset + appStart + appid
+ getsubuid = userid: appid: userid * 100000 + 10000 + appid;
+ getsubname = userid: appid: "u${toString userid}_a${toString appid}";
+ getsubhome = userid: appid: "${cfg.stateDir}/u${toString userid}/a${toString appid}";
+
+ mountpoints = {
+ ${cfg.sharefs.name} = mkIf (cfg.sharefs.source != null) {
+ depends = [ cfg.sharefs.source ];
+ device = "sharefs";
+ fsType = "fuse.sharefs";
+ noCheck = true;
+ options = [
+ "rw"
+ "noexec"
+ "nosuid"
+ "nodev"
+ "noatime"
+ "allow_other"
+ "mkdir"
+ "source=${cfg.sharefs.source}"
+ "setuid=${toString config.users.users.${cfg.sharefs.user}.uid}"
+ "setgid=${toString config.users.groups.${cfg.sharefs.group}.gid}"
+ ];
+ };
+ };
+in
+
+{
+ imports = [ (import ./options.nix packages) ];
+
+ options = {
+ # Forward declare a dummy option for VM filesystems since the real one won't exist
+ # unless the VM module is actually imported.
+ virtualisation.fileSystems = lib.mkOption { };
+ };
+
+ config = mkIf cfg.enable {
+ assertions = [
+ (
+ let
+ conflictingApps = foldlAttrs (
+ acc: id: app:
+ (
+ acc
+ ++ foldlAttrs (
+ acc': id': app':
+ if id == id' || app.shareUid && app'.shareUid || app.identity != app'.identity then acc' else acc' ++ [ id ]
+ ) [ ] cfg.apps
+ )
+ ) [ ] cfg.apps;
+ in
+ {
+ assertion = (lists.length conflictingApps) == 0;
+ message = "the following hakurei apps have conflicting identities: " + (builtins.concatStringsSep ", " conflictingApps);
+ }
+ )
+ ];
+
+ security.wrappers.hsu = {
+ source = "${cfg.hsuPackage}/bin/hsu";
+ setuid = true;
+ owner = "root";
+ group = "root";
+ };
+
+ environment.etc.hsurc = {
+ mode = "0400";
+ text = foldlAttrs (
+ acc: username: fid:
+ "${toString config.users.users.${username}.uid} ${toString fid}\n" + acc
+ ) "" cfg.users;
+ };
+
+ environment.systemPackages = optional (cfg.sharefs.source != null) cfg.sharefs.package;
+ fileSystems = mountpoints;
+ virtualisation.fileSystems = mountpoints;
+
+ home-manager =
+ let
+ privPackages = mapAttrs (_: userid: {
+ home.packages = foldlAttrs (
+ acc: id: app:
+ [
+ (
+ let
+ extendDBusDefault = id: ext: {
+ filter = true;
+
+ talk = [ "org.freedesktop.Notifications" ] ++ ext.talk;
+ own = [
+ "${id}.*"
+ "org.mpris.MediaPlayer2.${id}.*"
+ ]
+ ++ ext.own;
+
+ inherit (ext) call broadcast;
+ };
+ dbusConfig =
+ let
+ default = {
+ talk = [ ];
+ own = [ ];
+ call = { };
+ broadcast = { };
+ };
+ in
+ {
+ session_bus = if app.dbus.session != null then (app.dbus.session (extendDBusDefault id)) else (extendDBusDefault id default);
+ system_bus = app.dbus.system;
+ };
+ command = if app.command == null then app.name else app.command;
+ script = if app.script == null then ("exec " + command + " $@") else app.script;
+ isGraphical = if app.gpu != null then app.gpu else app.enablements.wayland || app.enablements.x11;
+
+ conf = {
+ inherit id;
+ inherit (app) identity enablements;
+ inherit (dbusConfig) session_bus system_bus;
+ direct_wayland = app.insecureWayland;
+ sched_policy = app.schedPolicy;
+ sched_priority = app.schedPriority;
+ groups = app.groups ++ optional (cfg.sharefs.source != null) cfg.sharefs.group;
+
+ container = {
+ inherit (app)
+ wait_delay
+ devel
+ userns
+ device
+ tty
+ multiarch
+ env
+ ;
+ map_real_uid = app.mapRealUid;
+ host_net = app.hostNet;
+ host_abstract = app.hostAbstract;
+ share_runtime = app.shareRuntime;
+ share_tmpdir = app.shareTmpdir;
+
+ filesystem =
+ let
+ bind = src: {
+ type = "bind";
+ inherit src;
+ };
+ optBind = src: {
+ type = "bind";
+ inherit src;
+ optional = true;
+ };
+ optDevBind = src: {
+ type = "bind";
+ inherit src;
+ dev = true;
+ optional = true;
+ };
+ in
+ [
+ (bind "/bin")
+ (bind "/usr/bin")
+ (bind "/nix/store")
+ (optBind "/sys/block")
+ (optBind "/sys/bus")
+ (optBind "/sys/class")
+ (optBind "/sys/dev")
+ (optBind "/sys/devices")
+ ]
+ ++ optionals app.nix [
+ (bind "/nix/var")
+ ]
+ ++ optionals isGraphical [
+ (optDevBind "/dev/dri")
+ (optDevBind "/dev/nvidiactl")
+ (optDevBind "/dev/nvidia-modeset")
+ (optDevBind "/dev/nvidia-uvm")
+ (optDevBind "/dev/nvidia-uvm-tools")
+ (optDevBind "/dev/nvidia0")
+ ]
+ ++ optionals app.useCommonPaths cfg.commonPaths
+ ++ app.extraPaths
+ ++ [
+ {
+ type = "bind";
+ dst = "/etc/";
+ src = "/etc/";
+ special = true;
+ }
+ {
+ type = "link";
+ dst = "/run/current-system";
+ linkname = "/run/current-system";
+ dereference = true;
+ }
+ ]
+ ++ optionals (isGraphical && config.hardware.graphics.enable) (
+ [
+ {
+ type = "link";
+ dst = "/run/opengl-driver";
+ linkname = config.systemd.tmpfiles.settings.graphics-driver."/run/opengl-driver"."L+".argument;
+ }
+ ]
+ ++ optionals (app.multiarch && config.hardware.graphics.enable32Bit) [
+ {
+ type = "link";
+ dst = "/run/opengl-driver-32";
+ linkname = config.systemd.tmpfiles.settings.graphics-driver."/run/opengl-driver-32"."L+".argument;
+ }
+ ]
+ )
+ ++ [
+ {
+ type = "bind";
+ src = getsubhome userid app.identity;
+ write = true;
+ ensure = true;
+ }
+ ];
+
+ username = getsubname userid app.identity;
+ inherit (cfg) shell;
+ home = getsubhome userid app.identity;
+
+ path =
+ if app.path == null then
+ pkgs.writeScript "${app.name}-start" ''
+ #!${pkgs.zsh}${pkgs.zsh.shellPath}
+ ${script}
+ ''
+ else
+ app.path;
+ args = if app.args == null then [ "${app.name}-start" ] else app.args;
+ };
+ };
+
+ checkedConfig =
+ name: value:
+ let
+ file = pkgs.writeText name (builtins.toJSON value);
+ in
+ pkgs.runCommand "checked-${name}" { nativeBuildInputs = [ cfg.package ]; } ''
+ ln -vs ${file} "$out"
+ hakurei show --no-store ${file}
+ '';
+ in
+ pkgs.writeShellScriptBin app.name ''
+ exec hakurei${if app.verbose then " -v" else ""}${if app.insecureWayland then " --insecure" else ""} run ${checkedConfig "hakurei-app-${app.name}.json" conf} $@
+ ''
+ )
+ ]
+ ++ (
+ let
+ pkg = if app.share != null then app.share else pkgs.${app.name};
+ copy = source: "[ -d '${source}' ] && cp -Lrv '${source}' $out/share || true";
+ in
+ optional (app.enablements.wayland || app.enablements.x11) (
+ pkgs.runCommand "${app.name}-share" { } ''
+ mkdir -p $out/share
+ ${copy "${pkg}/share/applications"}
+ ${copy "${pkg}/share/pixmaps"}
+ ${copy "${pkg}/share/icons"}
+ ${copy "${pkg}/share/man"}
+
+ if test -d "$out/share/applications"; then
+ substituteInPlace $out/share/applications/* \
+ --replace-warn '${pkg}/bin/' "" \
+ --replace-warn '${pkg}/libexec/' ""
+ fi
+ ''
+ )
+ )
+ ++ acc
+ ) [ cfg.package ] cfg.apps;
+ }) cfg.users;
+ in
+ {
+ useUserPackages = false; # prevent users.users entries from being added
+
+ users =
+ mkMerge
+ (foldlAttrs
+ (
+ acc: _: fid:
+ foldlAttrs
+ (
+ acc: _: app:
+ (
+ let
+ key = getsubname fid app.identity;
+ in
+ {
+ usernames = acc.usernames // {
+ ${key} = true;
+ };
+ merge = acc.merge ++ [
+ {
+ ${key} = mkMerge (
+ [
+ app.extraConfig
+ { home.packages = app.packages; }
+ ]
+ ++ lib.optional (!attrsets.hasAttrByPath [ key ] acc.usernames) cfg.extraHomeConfig
+ );
+ }
+ ];
+ }
+ )
+ )
+ {
+ inherit (acc) usernames;
+ merge = acc.merge ++ [ { ${getsubname fid 0} = cfg.extraHomeConfig; } ];
+ }
+ cfg.apps
+ )
+ {
+ usernames = { };
+ merge = [ privPackages ];
+ }
+ cfg.users
+ ).merge;
+ };
+
+ users =
+ let
+ getuser = userid: appid: {
+ isSystemUser = true;
+ createHome = true;
+ description = "Hakurei subordinate user ${toString appid} (u${toString userid})";
+ group = getsubname userid appid;
+ home = getsubhome userid appid;
+ uid = getsubuid userid appid;
+ };
+ getgroup = userid: appid: { gid = getsubuid userid appid; };
+ in
+ {
+ users = mkMerge (
+ foldlAttrs
+ (
+ acc: username: fid:
+ acc
+ ++
+ foldlAttrs
+ (
+ acc': _: app:
+ acc' ++ [ { ${getsubname fid app.identity} = getuser fid app.identity; } ]
+ )
+ [
+ {
+ ${getsubname fid 0} = getuser fid 0;
+ ${username}.extraGroups = [ cfg.sharefs.group ];
+ }
+ ]
+ cfg.apps
+ )
+ (optional (cfg.sharefs.source != null) {
+ ${cfg.sharefs.user} = {
+ uid = lib.mkDefault 1023;
+ inherit (cfg.sharefs) group;
+ isSystemUser = true;
+ home = cfg.sharefs.source;
+ };
+ })
+ cfg.users
+ );
+
+ groups = mkMerge (
+ foldlAttrs
+ (
+ acc: _: fid:
+ acc
+ ++ foldlAttrs (
+ acc': _: app:
+ acc' ++ [ { ${getsubname fid app.identity} = getgroup fid app.identity; } ]
+ ) [ { ${getsubname fid 0} = getgroup fid 0; } ] cfg.apps
+ )
+ (optional (cfg.sharefs.source != null) {
+ ${cfg.sharefs.group} = {
+ gid = lib.mkDefault 1023;
+ };
+ })
+ cfg.users
+ );
+ };
+ };
+}
diff --git a/cmd/hakurei/testsuite/options.nix b/cmd/hakurei/testsuite/options.nix
new file mode 100644
index 00000000..f624b6f5
--- /dev/null
+++ b/cmd/hakurei/testsuite/options.nix
@@ -0,0 +1,364 @@
+packages:
+{
+ lib,
+ pkgs,
+ config,
+ ...
+}:
+
+let
+ inherit (lib) types mkOption mkEnableOption;
+
+ cfg = config.environment.hakurei;
+in
+
+{
+ options = {
+ environment.hakurei = {
+ enable = mkEnableOption "hakurei";
+
+ package = mkOption {
+ type = types.package;
+ default = packages.${pkgs.stdenv.hostPlatform.system}.hakurei;
+ description = "The hakurei package to use.";
+ };
+
+ hsuPackage = mkOption {
+ type = types.package;
+ default = packages.${pkgs.stdenv.hostPlatform.system}.hsu;
+ description = "The hsu package to use.";
+ };
+
+ users = mkOption {
+ type =
+ let
+ inherit (types) attrsOf ints;
+ in
+ attrsOf (ints.between 0 99);
+ description = ''
+ Users allowed to spawn hakurei apps and their corresponding hakurei identity.
+ '';
+ };
+
+ extraHomeConfig = mkOption {
+ type = types.anything;
+ description = ''
+ Extra home-manager configuration to merge with all target users.
+ '';
+ };
+
+ sharefs = {
+ package = mkOption {
+ type = types.package;
+ default = pkgs.linkFarm "sharefs" {
+ "bin/sharefs" = "${cfg.package}/libexec/sharefs";
+ "bin/mount.fuse.sharefs" = "${cfg.package}/libexec/sharefs";
+ };
+ description = "The sharefs package to use.";
+ };
+
+ user = mkOption {
+ type = types.str;
+ default = "sharefs";
+ description = ''
+ Name of the user to run the sharefs daemon as.
+ '';
+ };
+
+ group = mkOption {
+ type = types.str;
+ default = "sharefs";
+ description = ''
+ Name of the group to run the sharefs daemon as.
+ '';
+ };
+
+ name = mkOption {
+ type = types.str;
+ default = "/sdcard";
+ description = ''
+ Host path to mount sharefs on.
+ '';
+ };
+
+ source = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = ''
+ Writable backing directory. Setting this to null disables sharefs.
+ '';
+ };
+ };
+
+ apps = mkOption {
+ type =
+ let
+ inherit (types)
+ int
+ ints
+ str
+ bool
+ enum
+ package
+ anything
+ submodule
+ listOf
+ attrsOf
+ nullOr
+ functionTo
+ ;
+ in
+ attrsOf (submodule {
+ options = {
+ name = mkOption {
+ type = str;
+ description = ''
+ Name of the app's launcher script.
+ '';
+ };
+
+ verbose = mkEnableOption "launchers with verbose output";
+
+ identity = mkOption {
+ type = ints.between 1 9999;
+ description = ''
+ Application identity. Identity 0 is reserved for system services.
+ '';
+ };
+ shareUid = mkEnableOption "sharing identity with another application";
+
+ packages = mkOption {
+ type = listOf package;
+ default = [ ];
+ description = ''
+ List of extra packages to install via home-manager.
+ '';
+ };
+
+ extraConfig = mkOption {
+ type = anything;
+ default = { };
+ description = ''
+ Extra home-manager configuration.
+ '';
+ };
+
+ path = mkOption {
+ type = nullOr str;
+ default = null;
+ description = ''
+ Custom executable path.
+ Setting this to null will default to the start script.
+ '';
+ };
+
+ args = mkOption {
+ type = nullOr (listOf str);
+ default = null;
+ description = ''
+ Custom args.
+ Setting this to null will default to script name.
+ '';
+ };
+
+ script = mkOption {
+ type = nullOr str;
+ default = null;
+ description = ''
+ Application launch script.
+ '';
+ };
+
+ command = mkOption {
+ type = nullOr str;
+ default = null;
+ description = ''
+ Command to run as the target user.
+ Setting this to null will default command to launcher name.
+ Has no effect when script is set.
+ '';
+ };
+
+ groups = mkOption {
+ type = listOf str;
+ default = [ ];
+ description = ''
+ List of groups to inherit from the privileged user.
+ '';
+ };
+
+ shareRuntime = mkEnableOption "sharing of XDG_RUNTIME_DIR between containers under the same identity";
+ shareTmpdir = mkEnableOption "sharing of TMPDIR between containers under the same identity";
+
+ dbus = {
+ session = mkOption {
+ type = nullOr (functionTo anything);
+ default = null;
+ description = ''
+ D-Bus session bus custom configuration.
+ Setting this to null will enable built-in defaults.
+ '';
+ };
+
+ system = mkOption {
+ type = nullOr anything;
+ default = null;
+ description = ''
+ D-Bus system bus custom configuration.
+ Setting this to null will disable the system bus proxy.
+ '';
+ };
+ };
+
+ env = mkOption {
+ type = nullOr (attrsOf str);
+ default = null;
+ description = ''
+ Environment variables to set for the initial process in the sandbox.
+ '';
+ };
+
+ wait_delay = mkOption {
+ type = nullOr int;
+ default = null;
+ description = ''
+ Duration to wait for after interrupting a container's initial process in nanoseconds.
+ A negative value causes the container to be terminated immediately on cancellation.
+ Setting this to null defaults to five seconds.
+ '';
+ };
+
+ devel = mkEnableOption "debugging-related kernel interfaces";
+ userns = mkEnableOption "user namespace creation";
+ tty = mkEnableOption "access to the controlling terminal";
+ multiarch = mkEnableOption "multiarch kernel-level support";
+
+ hostNet = mkEnableOption "share host net namespace" // {
+ default = true;
+ };
+ hostAbstract = mkEnableOption "share abstract unix socket scope";
+
+ schedPolicy = mkOption {
+ type = nullOr (enum [
+ "fifo"
+ "rr"
+ "batch"
+ "idle"
+ "deadline"
+ "ext"
+ ]);
+ default = null;
+ description = ''
+ Scheduling policy to set for the container.
+ The zero value retains the current scheduling policy.
+ '';
+ };
+ schedPriority = mkOption {
+ type = nullOr (ints.between 1 99);
+ default = null;
+ description = ''
+ Scheduling priority to set for the container.
+ '';
+ };
+
+ nix = mkEnableOption "nix daemon access";
+ mapRealUid = mkEnableOption "mapping to priv-user uid";
+ device = mkEnableOption "access to all devices";
+ insecureWayland = mkEnableOption "direct access to the Wayland socket";
+
+ gpu = mkOption {
+ type = nullOr bool;
+ default = null;
+ description = ''
+ Target process GPU and driver access.
+ Setting this to null will enable GPU whenever X or Wayland is enabled.
+ '';
+ };
+
+ useCommonPaths = mkEnableOption "common extra paths" // {
+ default = true;
+ };
+
+ extraPaths = mkOption {
+ type = listOf (attrsOf anything);
+ default = [ ];
+ description = ''
+ Extra paths to make available to the container.
+ '';
+ };
+
+ enablements = {
+ wayland = mkOption {
+ type = nullOr bool;
+ default = true;
+ description = ''
+ Whether to share the Wayland server via security-context-v1.
+ '';
+ };
+
+ x11 = mkOption {
+ type = nullOr bool;
+ default = false;
+ description = ''
+ Whether to share the X11 socket and allow connection.
+ '';
+ };
+
+ dbus = mkOption {
+ type = nullOr bool;
+ default = true;
+ description = ''
+ Whether to proxy D-Bus.
+ '';
+ };
+
+ pipewire = mkOption {
+ type = nullOr bool;
+ default = true;
+ description = ''
+ Whether to share the PipeWire server via pipewire-pulse on a SecurityContext socket.
+ '';
+ };
+ };
+
+ share = mkOption {
+ type = nullOr package;
+ default = null;
+ description = ''
+ Package containing share files.
+ Setting this to null will default package name to wrapper name.
+ '';
+ };
+ };
+ });
+ default = { };
+ description = ''
+ Declaratively configured hakurei apps.
+ '';
+ };
+
+ commonPaths = mkOption {
+ type = types.listOf (types.attrsOf types.anything);
+ default = [ ];
+ description = ''
+ Common extra paths to make available to the container.
+ '';
+ };
+
+ shell = mkOption {
+ type = types.str;
+ default = "/run/current-system/sw/bin/bash";
+ description = ''
+ Absolute path to preferred shell.
+ '';
+ };
+
+ stateDir = mkOption {
+ type = types.str;
+ description = ''
+ The state directory where app home directories are stored.
+ '';
+ };
+ };
+ };
+}
diff --git a/cmd/hakurei/testsuite/package.nix b/cmd/hakurei/testsuite/package.nix
new file mode 100644
index 00000000..12196cf6
--- /dev/null
+++ b/cmd/hakurei/testsuite/package.nix
@@ -0,0 +1,145 @@
+{
+ lib,
+ stdenv,
+ buildGo127Module,
+ makeBinaryWrapper,
+ xdg-dbus-proxy,
+ pkg-config,
+ libffi,
+ libseccomp,
+ acl,
+ wayland,
+ wayland-protocols,
+ wayland-scanner,
+
+ libxcb,
+ libxau,
+ libxdmcp,
+
+ # for sharefs
+ fuse3,
+
+ # for passthru.buildInputs
+ go_1_27,
+ clang,
+ xorgproto,
+
+ # for check
+ util-linux,
+ nettools,
+
+ glibc, # for ldd
+ withStatic ? stdenv.hostPlatform.isStatic,
+}:
+
+buildGo127Module rec {
+ pname = "hakurei";
+ version = with lib.strings; removePrefix "v" (trim (builtins.readFile ../../dist/VERSION));
+
+ srcFiltered = builtins.path {
+ name = "${pname}-src";
+ path = lib.cleanSource ../../../.;
+ filter = path: type: !(type == "regular" && (lib.hasSuffix ".nix" path || lib.hasSuffix ".py" path)) && !(type == "directory" && lib.hasSuffix "/test" path) && !(type == "directory" && lib.hasSuffix "/cmd/hsu" path);
+ };
+ vendorHash = null;
+
+ src = stdenv.mkDerivation {
+ name = "${pname}-src-full";
+ inherit version;
+ enableParallelBuilding = true;
+ src = srcFiltered;
+
+ buildInputs = [
+ wayland
+ wayland-protocols
+ ];
+
+ nativeBuildInputs = [
+ go_1_27
+ pkg-config
+ wayland-scanner
+ ];
+
+ buildPhase = "GOCACHE=$(mktemp -d) go generate ./...";
+ installPhase = "cp -r . $out";
+ };
+
+ ldflags =
+ lib.attrsets.foldlAttrs
+ (
+ ldflags: name: value:
+ ldflags ++ [ "-X hakurei.app/internal/info.${name}=${value}" ]
+ )
+ (
+ [ "-s -w" ]
+ ++ lib.optionals withStatic [
+ "-linkmode external"
+ "-extldflags \"-static\""
+ ]
+ )
+ {
+ buildVersion = "v${version}";
+ hakureiPath = "${placeholder "out"}/libexec/hakurei";
+ hsuPath = "/run/wrappers/bin/hsu";
+ };
+
+ env = {
+ # use clang instead of gcc
+ CC = "clang -O3 -Werror";
+ };
+
+ buildInputs = [
+ libffi
+ libseccomp
+ fuse3
+ acl
+ wayland
+ util-linux
+
+ libxcb
+ libxau
+ libxdmcp
+ ];
+
+ nativeBuildInputs = [
+ pkg-config
+ makeBinaryWrapper
+
+ # for container example
+ nettools
+ ];
+
+ postInstall =
+ let
+ appPackages = [
+ glibc
+ xdg-dbus-proxy
+ ];
+ in
+ ''
+ install -D --target-directory=$out/share/zsh/site-functions cmd/dist/comp/*
+
+ mkdir "$out/libexec"
+ mv "$out"/bin/* "$out/libexec/"
+
+ makeBinaryWrapper "$out/libexec/hakurei" "$out/bin/hakurei" \
+ --inherit-argv0 --prefix PATH : ${lib.makeBinPath appPackages}
+ '';
+
+ passthru = {
+ go = go_1_27;
+
+ targetPkgs = [
+ go_1_27
+ clang
+ xorgproto
+ util-linux
+
+ # for go generate
+ wayland-protocols
+ wayland-scanner
+ ]
+ ++ buildInputs
+ ++ nativeBuildInputs;
+ };
+}
diff --git a/cmd/hakurei/testsuite/sandbox/main.go b/cmd/hakurei/testsuite/sandbox/main.go
new file mode 100644
index 00000000..820b7e2f
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/main.go
@@ -0,0 +1,409 @@
+//go:build testsuite
+
+// The sandbox test program runs cmd/hakurei with configurations simulating
+// several common workloads and inspects the resulting container states.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+
+ "hakurei.app/check"
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/store"
+ "hakurei.app/internal/testsuite"
+
+ "hakurei.app/cmd/hakurei/testsuite/sandbox/testdata"
+)
+
+// mustScanFor continuously scans the proc filesystem and calls f for each entry
+// visited.
+func mustScanFor(f func(ps *testsuite.StatScanner) bool) int {
+ var ps testsuite.StatScanner
+
+ for ps.Scan() {
+ if f(&ps) {
+ break
+ }
+ }
+ if err := ps.Err(); err != nil {
+ log.Fatal(err)
+ }
+ return ps.Stat().PID
+}
+
+// mustStart starts a hakurei container and returns the pid of a process within
+// the container. This process must be terminated by the caller.
+func mustStart(
+ ctx context.Context,
+ serial uint64,
+ cred *syscall.Credential,
+ files ...*os.File,
+) (pid int, done <-chan error) {
+ _serial := strconv.FormatUint(serial, 10)
+ _, done = testsuite.MustStartWith(
+ ctx, cred, nil, files,
+ "hakurei", "exec",
+ "sleep", "infinity", _serial,
+ )
+
+ var stat syscall.Stat_t
+ pid = mustScanFor(func(s *testsuite.StatScanner) bool {
+ select {
+ case err := <-done:
+ if err == nil {
+ log.Fatalf("test process %d terminated unexpectedly", serial)
+ }
+ log.Fatalf("test process %d terminated unexpectedly: %v", serial, err)
+ default:
+ break
+ }
+
+ if s.Stat().Comm != "sleep" {
+ return false
+ }
+
+ if args, err := s.Stat().Args(); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ } else if !slices.Equal(args, []string{
+ "sleep",
+ "infinity",
+ _serial,
+ }) {
+ return false
+ }
+
+ if err := s.Stat().Stat(&stat); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ }
+
+ id := hst.ToUser[uint32](0, 0)
+ if stat.Uid != id || stat.Gid != id {
+ return false
+ }
+
+ return true
+ })
+ return
+}
+
+func main() {
+ go testsuite.ReceiveSignals()
+
+ // the signal handler does not wait for termination
+ ctx := context.Background()
+
+ cred := syscall.Credential{Uid: 1000, Gid: 100}
+ if err := os.MkdirAll("/opt/test-helper/bin", 0755); err != nil {
+ log.Fatal(err)
+ }
+
+ var testHelperDone <-chan error
+ {
+ cmd := exec.Command(
+ "go", "build",
+ "-o", "/opt/test-helper/bin",
+ "-tags=tester",
+ "-trimpath",
+ "./cmd/hakurei/testsuite/sandbox/tester",
+ )
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ testHelperDone = testsuite.MustStart(cmd)
+ }
+
+ var wg sync.WaitGroup
+ defer wg.Wait()
+
+ var serial atomic.Uint64
+ newSerial := func() uint64 { serial.Add(1); return serial.Load() }
+
+ testsuite.MustRun(
+ &cred, nil,
+ "hakurei", "exec", "capsh", "--print",
+ )
+ wg.Go(func() {
+ defer log.Println("validated capabilities/securebits in user namespace")
+
+ testsuite.MustRun(
+ &cred, nil,
+ "hakurei", "exec", "capsh", "--has-no-new-privs",
+ )
+
+ for _, p := range []byte{'a', 'b', 'i', 'p'} {
+ testsuite.MustFail(
+ &cred, nil,
+ "hakurei", "exec", "capsh", "--has-"+string(p)+"=CAP_SYS_ADMIN",
+ )
+ }
+ testsuite.MustFail(
+ &cred, nil,
+ "hakurei", "exec", "umount", "-R", "/dev",
+ )
+ })
+
+ wg.Go(func() {
+ defer log.Println("validated pd seccomp outcome")
+
+ c, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ pid, done := mustStart(c, newSerial(), &cred)
+ testsuite.MustCheckFilter(pid, testdata.SumPD)
+ if err := testsuite.FilterTerminated(<-done); err != nil {
+ log.Fatal(err)
+ }
+ })
+
+ wg.Go(func() {
+ defer log.Println("validated fd leak")
+
+ c, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ pid, done := mustStart(c, newSerial(), &cred, os.Stdin, os.Stdout, os.Stderr)
+ prefix := filepath.Join(fhs.Proc, strconv.Itoa(pid), "fd")
+
+ var fail bool
+ if entries, err := os.ReadDir(prefix); err != nil {
+ log.Fatal(err.Error())
+ } else {
+ for _, ent := range entries {
+ var fd int
+ if fd, err = strconv.Atoi(ent.Name()); err != nil {
+ log.Fatal(err.Error())
+ }
+
+ // skip standard streams
+ if fd <= 2 {
+ continue
+ }
+ fail = true
+
+ var d string
+ if d, err = os.Readlink(filepath.Join(
+ prefix,
+ ent.Name(),
+ )); err != nil {
+ log.Fatal(err.Error())
+ }
+ log.Printf("extra fd %d -> %s", fd, d)
+ }
+ }
+ if fail {
+ log.Fatal("file descriptors leaked")
+ }
+
+ if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
+ log.Fatalf("cannot terminate anchor: %v", err)
+ } else if err = testsuite.FilterTerminated(<-done); err != nil {
+ log.Fatal(err)
+ }
+ })
+
+ if err := os.MkdirAll(testsuite.XDGRuntimeDir, 0700); err != nil {
+ log.Fatal(err)
+ } else if err = os.Chown(testsuite.XDGRuntimeDir, 1000, 1000); err != nil {
+ log.Fatal(err)
+ }
+
+ var swg sync.WaitGroup
+ defer swg.Wait()
+ dbusEnv := testsuite.MustStartSessionBus(&cred)
+ testsuite.MustStartSway(&swg, &cred, dbusEnv)
+ defer testsuite.TerminateSway(&cred)
+ testsuite.MustStartPipeWire(&cred, dbusEnv)
+
+ if err := <-testHelperDone; err != nil {
+ log.Fatalf("cannot compile test helper: %v", err)
+ }
+ log.Println("created test helper")
+
+ s := store.New(check.MustAbs("/tmp/hakurei.0/state"))
+ for name, tc := range testdata.All() {
+ wg.Go(func() {
+ cmd := exec.Command(
+ "script", "/dev/null",
+ "-E", "always",
+ "-qec",
+ "hakurei run "+
+ "--identifier-fd=5"+
+ " 4 1>&3",
+ )
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGTERM,
+ Credential: &cred,
+ }
+ var output bytes.Buffer
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, &output, &output
+ cmd.Env = []string{
+ "PATH=" + os.Getenv("PATH"),
+ "TERM=xterm",
+ testsuite.XDGRuntimeEnv,
+ testsuite.WaylandEnv,
+ "DISPLAY=:0",
+ dbusEnv,
+ }
+
+ var err error
+ var notify, _notify, _conf, conf, ident, _ident *os.File
+ if notify, _notify, err = os.Pipe(); err != nil {
+ log.Fatal(err)
+ }
+ cmd.ExtraFiles = append(cmd.ExtraFiles, _notify)
+ if _conf, conf, err = os.Pipe(); err != nil {
+ log.Fatal(err)
+ }
+ cmd.ExtraFiles = append(cmd.ExtraFiles, _conf)
+ if ident, _ident, err = os.Pipe(); err != nil {
+ log.Fatal(err)
+ }
+ cmd.ExtraFiles = append(cmd.ExtraFiles, _ident)
+
+ done := testsuite.MustStart(cmd)
+ wg.Go(func() {
+ _err := <-done
+ log.Printf("completed test case %s\n%s", name, output.String())
+ if _err != nil {
+ log.Fatalf("test case %s: %v", name, _err)
+ }
+ })
+
+ if err = json.NewEncoder(conf).Encode(&tc.Hakurei); err != nil {
+ log.Fatal(err)
+ } else if err = conf.Close(); err != nil {
+ log.Fatal(err)
+ }
+
+ var id hst.ID
+ if _, err = io.ReadFull(ident, id[:]); err != nil {
+ log.Fatal(err)
+ } else if err = ident.Close(); err != nil {
+ log.Fatal(err)
+ }
+
+ if _, err = io.ReadFull(notify, make([]byte, 8)); err != nil {
+ log.Fatal(err)
+ } else if err = notify.Close(); err != nil {
+ log.Fatal(err)
+ }
+
+ var (
+ ok bool
+ p hst.State
+ )
+ entries, copyError := s.All()
+ for entry := range entries {
+ if entry.ID == id {
+ ok = true
+ if _, err = entry.Load(&p, nil); err != nil {
+ log.Fatal(err)
+ }
+ break
+ }
+ }
+ if err = copyError(); err != nil {
+ log.Fatal(err)
+ }
+ if !ok {
+ log.Fatalf("instance %s is not present in store", id)
+ }
+
+ var stat syscall.Stat_t
+ pid := mustScanFor(func(ps *testsuite.StatScanner) bool {
+ select {
+ case err = <-done:
+ if err == nil {
+ log.Fatal("test process terminated unexpectedly")
+ }
+ log.Fatal(err)
+ default:
+ break
+ }
+
+ if ps.Stat().Comm != "test-helper" {
+ return false
+ }
+
+ var args []string
+ if args, err = ps.Stat().Args(); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ } else if !slices.Equal(args, tc.Hakurei.Container.Args) {
+ return false
+ }
+
+ if err = ps.Stat().Stat(&stat); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ }
+
+ uid := hst.ToUser[uint32](0, uint32(tc.Hakurei.Identity))
+ if stat.Uid != uid || stat.Gid != uid {
+ return false
+ }
+
+ var t []byte
+ if t, err = os.ReadFile(filepath.Join(
+ fhs.Proc,
+ strconv.Itoa(ps.Stat().PPID),
+ "stat",
+ )); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ }
+
+ var _stat testsuite.Stat
+ if err = _stat.UnmarshalText(t); err != nil {
+ log.Fatal(err)
+ }
+ if _stat.PPID != p.ShimPID {
+ return false
+ }
+
+ return true
+ })
+
+ testsuite.MustCheckFilter(
+ pid,
+ tc.Sum,
+ )
+ })
+ }
+
+ wg.Wait()
+
+ if dents, err := os.ReadDir("/tmp"); err != nil {
+ log.Fatal(err)
+ } else {
+ for _, dent := range dents {
+ if name := dent.Name(); strings.HasPrefix(name, ".hakurei-shim-") {
+ log.Fatalf("leftover shim work dir %q", name)
+ }
+ }
+ }
+}
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/device.go b/cmd/hakurei/testsuite/sandbox/testdata/device.go
new file mode 100644
index 00000000..5de9f550
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/device.go
@@ -0,0 +1,134 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+ "syscall"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.device",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus | hst.EX11),
+ Identity: 4,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-device",
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a4",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "device"},
+
+ Flags: hst.FDevice | hst.FShareTmpdir,
+ },
+ },
+
+ // 0, PresetStrict
+ Sum: sumSimple,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
+ "DISPLAY=unix:/tmp/.X11-unix/X0",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a4",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/65534",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/65534/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ // unstable host dev
+ "dev": {Mode: os.ModeDir | 0755},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a4:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:65534:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "65534": {Mode: os.ModeDir | 0700, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | 0770, Dir: dir{
+ ".X11-unix": {Mode: os.ModeDir | 0755, Dir: dir{
+ "X0": {Mode: os.ModeSocket | 0775},
+ }},
+ }},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110003,gid=110003,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110003,gid=110003,inode64"),
+
+ // host /dev in testing environment
+ r("/", "/dev", "rw,nosuid", "tmpfs", "tmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,gid=100004,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/kvm", "/dev/kvm", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/fuse", "/dev/fuse", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/shm", "rw,nosuid,nodev,noexec,relatime", "tmpfs", "shm", ignore),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110003,gid=110003,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110003,gid=110003,inode64"),
+ r("/tmp/hakurei.0/tmpdir/4", "/tmp", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110003,gid=110003,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110003,gid=110003,inode64"),
+ r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/tmp/.X11-unix", "/tmp/.X11-unix", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+ ErrnoAbstract: syscall.ECONNREFUSED,
+}.register("device")
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/mapuid.go b/cmd/hakurei/testsuite/sandbox/testdata/mapuid.go
new file mode 100644
index 00000000..218b035d
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/mapuid.go
@@ -0,0 +1,124 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+ "syscall"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.mapuid",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus),
+ Identity: 3,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-mapuid",
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a3",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "mapuid"},
+
+ Flags: hst.FMapRealUID | hst.FShareRuntime | hst.FShareTmpdir,
+ },
+ },
+
+ // 0, PresetStrict
+ Sum: sumSimple,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a3",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/1000",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/1000/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ // unstable host dev
+ "dev": {Mode: os.ModeDir | 0755},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a3:x:1000:100:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:100:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "1000": {Mode: os.ModeDir | 0770, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | 0770, Dir: dir{}},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110002,gid=110002,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110002,gid=110002,inode64"),
+ r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110002,gid=110002,inode64"),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110002,gid=110002,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110002,gid=110002,inode64"),
+ r("/tmp/hakurei.0/runtime/3", "/run/user/1000", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/tmp/hakurei.0/tmpdir/3", "/tmp", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110002,gid=110002,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110002,gid=110002,inode64"),
+ r(ignore, "/run/user/1000/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/1000/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/1000/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+ ErrnoAbstract: syscall.ECONNREFUSED,
+ ErrnoPathname: syscall.ENOENT,
+}.register("mapuid")
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/pdlike.go b/cmd/hakurei/testsuite/sandbox/testdata/pdlike.go
new file mode 100644
index 00000000..5b58ed38
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/pdlike.go
@@ -0,0 +1,141 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+ "syscall"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.pdlike",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus),
+ Identity: 5,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-pdlike",
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a5",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "pdlike"},
+
+ Flags: hst.FHostNet | hst.FTty | hst.FUserns | hst.FShareRuntime | hst.FShareTmpdir,
+ },
+ },
+
+ // 0, PresetExt | PresetDenyDevel
+ Sum: SumPD,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a5",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/65534",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/65534/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ "dev": {Mode: os.ModeDir | 0755, Dir: dir{
+ "core": {Mode: os.ModeSymlink | 0777},
+ "fd": {Mode: os.ModeSymlink | 0777},
+ "full": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "mqueue": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "null": {Mode: os.ModeDevice | os.ModeCharDevice | 0666, Data: new("")},
+ "ptmx": {Mode: os.ModeSymlink | 0777},
+ "pts": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ptmx": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+ "random": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "shm": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "stderr": {Mode: os.ModeSymlink | 0777},
+ "stdin": {Mode: os.ModeSymlink | 0777},
+ "stdout": {Mode: os.ModeSymlink | 0777},
+ "tty": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "urandom": {Mode: os.ModeDevice | os.ModeCharDevice | 0444},
+ "zero": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a5:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:65534:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "65534": {Mode: os.ModeDir | 0770, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | 0770, Dir: dir{}},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110004,gid=110004,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110004,gid=110004,inode64"),
+ r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110004,gid=110004,inode64"),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110004,gid=110004,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110004,gid=110004,inode64"),
+ r("/tmp/hakurei.0/runtime/5", "/run/user/65534", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/tmp/hakurei.0/tmpdir/5", "/tmp", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110004,gid=110004,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110004,gid=110004,inode64"),
+ r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+ ErrnoAbstract: syscall.EPERM,
+ ErrnoPathname: syscall.ENOENT,
+}.register("pdlike")
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/simple.go b/cmd/hakurei/testsuite/sandbox/testdata/simple.go
new file mode 100644
index 00000000..edb7c350
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/simple.go
@@ -0,0 +1,140 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+ "syscall"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.simple",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus),
+ Identity: 1,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-simple",
+ Env: map[string]string{"HAKUREI_SAMPLE": "1"},
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a1",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "simple"},
+ },
+ },
+
+ // 0, PresetStrict
+ Sum: sumSimple,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
+ "HAKUREI_SAMPLE=1",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a1",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/65534",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/65534/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ "dev": {Mode: os.ModeDir | 0755, Dir: dir{
+ "core": {Mode: os.ModeSymlink | 0777},
+ "fd": {Mode: os.ModeSymlink | 0777},
+ "full": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "mqueue": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "null": {Mode: os.ModeDevice | os.ModeCharDevice | 0666, Data: new("")},
+ "ptmx": {Mode: os.ModeSymlink | 0777},
+ "pts": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ptmx": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+ "random": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "shm": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "stderr": {Mode: os.ModeSymlink | 0777},
+ "stdin": {Mode: os.ModeSymlink | 0777},
+ "stdout": {Mode: os.ModeSymlink | 0777},
+ "tty": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "urandom": {Mode: os.ModeDevice | os.ModeCharDevice | 0444},
+ "zero": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a1:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:65534:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "65534": {Mode: os.ModeDir | 0700, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110000,gid=110000,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110000,gid=110000,inode64"),
+ r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110000,gid=110000,inode64"),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110000,gid=110000,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110000,gid=110000,inode64"),
+ r("/", "/tmp", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110000,gid=110000,inode64"),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110000,gid=110000,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110000,gid=110000,inode64"),
+ r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+ ErrnoAbstract: syscall.ECONNREFUSED,
+ ErrnoPathname: syscall.ENOENT,
+}.register("simple")
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/sum.go b/cmd/hakurei/testsuite/sandbox/testdata/sum.go
new file mode 100644
index 00000000..e4e8643a
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/sum.go
@@ -0,0 +1,22 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "crypto/sha512"
+ "encoding/base64"
+ "strconv"
+)
+
+// sum decodes s as [base64.StdEncoding] and panics if it is invalid or
+// unexpectedly sized.
+func sum(s string) [sha512.Size]byte {
+ p, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ panic(err)
+ }
+ if len(p) != sha512.Size {
+ panic("unexpected checksum sized " + strconv.Itoa(len(p)))
+ }
+ return ([sha512.Size]byte)(p)
+}
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go b/cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go
new file mode 100644
index 00000000..bd751105
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go
@@ -0,0 +1,9 @@
+//go:build testsuite || tester
+
+package testdata
+
+var (
+ SumPD = sum("xpiwgf+Vev4XptlDdFN9N/KmP2+d112nVGVCQHqeMkduvaMxK6d4XX9hhUK8+vJ8on3MLd26hSBp0ovP6MrTmg==")
+ sumSimple = sum("6IApjfK9Z1HQBA/CG8DtTAD5XcDXulBsJE2LjPaGbbqO9KMylvKHtmzMwdeOlwJll/hMx97BVz4UiWD701zXNQ==")
+ sumTTY = sum("C3YAdHbByeJdv2dMKf32CaFlanAGPkkydlThtTYK09oG4aPjK/gOlhxVFq2D1Lnn6b3odqk3l+J2J9JVXCWFiw==")
+)
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go b/cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go
new file mode 100644
index 00000000..1691828f
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go
@@ -0,0 +1,9 @@
+//go:build testsuite || tester
+
+package testdata
+
+var (
+ SumPD = sum("QzzpuREoLW3MgCkxn7ebgWtg1aeV7I/JQ0TdAnYU1o8CMWapG7iB+q7u3Sbj2JR04UHlppqX6TuJhMqPFJmZgA==")
+ sumSimple = sum("eTGFOKPchRMUtr2W8Q1YYayyqn4Ty43gYZ0PanZwnWfwHvP9Z+GVhisC+XEeW3abxNHrT8DfxBpyPInJaKkylw==")
+ sumTTY = sum("zx9NyHQ2uo7JXSaLZjpjl7sLSlrGTYVX5sxSnYsPb2Xa06krYu0p2F7unG3eEmd1ek0PhgMuikXKG86t+jTPXg==")
+)
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/testdata.go b/cmd/hakurei/testsuite/sandbox/testdata/testdata.go
new file mode 100644
index 00000000..bdb5178e
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/testdata.go
@@ -0,0 +1,125 @@
+//go:build testsuite || tester
+
+// Package testdata holds sandbox inspection test cases.
+package testdata
+
+import (
+ "crypto/sha512"
+ "iter"
+ "log"
+ "strconv"
+ "syscall"
+
+ "hakurei.app/check"
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+// A TestCase represents a named test case that may be requested by the caller.
+type TestCase struct {
+ // Configuration of the inspected container.
+ Hakurei hst.Config
+ // Checksum of expected seccomp filter program.
+ Sum [sha512.Size]byte
+
+ // Expected environment. Skipped if nil.
+ Env []string `json:"env,omitempty"`
+ // Expected root filesystem. Skipped if nil.
+ FS *testsuite.FS `json:"fs,omitempty"`
+ // Expected mountinfo records. Skipped if nil.
+ Mount []*mountinfo.Entry `json:"mount,omitempty"`
+ // Whether to run seccomp checks.
+ Seccomp bool `json:"seccomp,omitempty"`
+
+ // Name of pathname and abstract sockets to attempt.
+ TrySocket string `json:"try_socket,omitempty"`
+ // Errno to expect attempting to reach the abstract socket.
+ ErrnoAbstract syscall.Errno `json:"errno_abstract,omitempty"`
+ // Errno to expect attempting to reach the pathname socket.
+ ErrnoPathname syscall.Errno `json:"errno_pathname,omitempty"`
+}
+
+// testCases hold all named test cases.
+var testCases map[string]TestCase
+
+// fc returns c wrapped in its JSON adapter.
+func fc(c hst.FilesystemConfig) hst.FilesystemConfigJSON {
+ return hst.FilesystemConfigJSON{
+ FilesystemConfig: c,
+ }
+}
+
+// ignore is the magic string for a mountinfo field to be ignored.
+const ignore = "//ignore"
+
+type dir = map[string]*testsuite.FS
+
+// r returns the address of a [mountinfo.Entry].
+func r(
+ root, target, vfsOptstr string,
+ fsType, source, fsOptstr string,
+) *mountinfo.Entry {
+ return &mountinfo.Entry{
+ ID: -1,
+ Parent: -1,
+ Root: root,
+ Target: target,
+ VfsOptstr: vfsOptstr,
+ FsType: fsType,
+ Source: source,
+ FsOptstr: fsOptstr,
+ }
+}
+
+var (
+ // fcLinker is the dynamic linker symlink.
+ fcLinker = fc(&hst.FSLink{
+ Target: fhs.AbsRoot.Append("lib64", "ld-linux-x86-64.so.2"),
+ Linkname: "../lib/x86_64-linux-gnu/ld-linux-x86-64.so.2",
+ })
+ // fcLib is the dynamic library bind mount.
+ fcLib = fc(&hst.FSBind{Source: fhs.AbsRoot.Append("lib")})
+
+ // absTestHelper is the absolute pathname of the test helper program.
+ absTestHelper = hst.AbsPrivateTmp.Append("test-helper")
+ // fcTestHelper is the test helper bind mount.
+ fcTestHelper = fc(&hst.FSBind{
+ Target: absTestHelper,
+ Source: check.MustAbs("/opt/test-helper/bin/tester"),
+ })
+)
+
+// register adds a test case to testCases.
+func (c TestCase) register(name string) (_ struct{}) {
+ if testCases == nil {
+ testCases = make(map[string]TestCase)
+ }
+
+ if _, ok := testCases[name]; ok {
+ panic("attempting to register " + strconv.Quote(name) + " twice")
+ }
+ testCases[name] = c
+ return
+}
+
+// Get returns the named test case, or terminates the program if name is invalid.
+func Get(name string) TestCase {
+ tc, ok := testCases[name]
+ if !ok {
+ log.Fatalf("invalid test case %q", name)
+ }
+ return tc
+}
+
+// All returns an iterator over all named test cases.
+func All() iter.Seq2[string, TestCase] {
+ return func(yield func(string, TestCase) bool) {
+ for name, tc := range testCases {
+ if !yield(name, tc) {
+ return
+ }
+ }
+ }
+}
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/tty.go b/cmd/hakurei/testsuite/sandbox/testdata/tty.go
new file mode 100644
index 00000000..2a3ba76e
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/tty.go
@@ -0,0 +1,145 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.tty",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus | hst.EX11),
+ Identity: 2,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-tty",
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a2",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "tty"},
+
+ Flags: hst.FHostNet | hst.FHostAbstract |
+ hst.FTty | hst.FShareRuntime,
+ },
+ },
+
+ // 0, PresetExt | PresetDenyNS | PresetDenyDevel
+ Sum: sumTTY,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
+ "DISPLAY=:0",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a2",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/65534",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/65534/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ "dev": {Mode: os.ModeDir | 0755, Dir: dir{
+ "core": {Mode: os.ModeSymlink | 0777},
+ "fd": {Mode: os.ModeSymlink | 0777},
+ "full": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "mqueue": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "null": {Mode: os.ModeDevice | os.ModeCharDevice | 0666, Data: new("")},
+ "ptmx": {Mode: os.ModeSymlink | 0777},
+ "pts": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ptmx": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+ "random": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "shm": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "stderr": {Mode: os.ModeSymlink | 0777},
+ "stdin": {Mode: os.ModeSymlink | 0777},
+ "stdout": {Mode: os.ModeSymlink | 0777},
+ "tty": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "urandom": {Mode: os.ModeDevice | os.ModeCharDevice | 0444},
+ "zero": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a2:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:65534:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "65534": {Mode: os.ModeDir | 0770, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{
+ ".X11-unix": {Mode: os.ModeDir | 0755, Dir: dir{
+ "X0": {Mode: os.ModeSocket | 0775},
+ }},
+ }},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110001,gid=110001,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110001,gid=110001,inode64"),
+ r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110001,gid=110001,inode64"),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110001,gid=110001,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110001,gid=110001,inode64"),
+ r("/tmp/hakurei.0/runtime/2", "/run/user/65534", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/", "/tmp", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110001,gid=110001,inode64"),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110001,gid=110001,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110001,gid=110001,inode64"),
+ r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/tmp/.X11-unix", "/tmp/.X11-unix", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+}.register("tty")
diff --git a/cmd/hakurei/testsuite/sandbox/tester/main.go b/cmd/hakurei/testsuite/sandbox/tester/main.go
new file mode 100644
index 00000000..452712d9
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/tester/main.go
@@ -0,0 +1,224 @@
+//go:build tester
+
+// The sandbox tester runs within a cmd/hakurei container and validates its
+// state. Since the test environment is relatively predictable, the tester can
+// make various assumptions about the host.
+package main
+
+import (
+ "errors"
+ "log"
+ "net"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+
+ "hakurei.app/cmd/hakurei/testsuite/sandbox/testdata"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+//#include <sys/quota.h>
+import "C"
+
+// mustAbs returns s, or terminates the program if s is not absolute.
+func mustAbs(s string) string {
+ if !filepath.IsAbs(s) {
+ log.Fatalf("%q is not absolute", s)
+ }
+ return s
+}
+
+func main() {
+ log.SetFlags(0)
+ log.SetPrefix("tester: ")
+
+ if len(os.Args) != 2 {
+ log.Fatal("tester requires 1 argument")
+ }
+ want := testdata.Get(os.Args[1])
+ log.SetPrefix("tester: " + os.Args[1] + " ")
+
+ checkWritableDirPaths := []string{
+ "/dev/shm",
+ "/tmp",
+ os.Getenv("XDG_RUNTIME_DIR"),
+ }
+ for _, a := range checkWritableDirPaths {
+ pathname := filepath.Join(mustAbs(a), ".hakurei-check")
+ if err := os.WriteFile(pathname, make([]byte, 1<<8), 0600); err != nil {
+ log.Fatalf("[FAIL] %s", err)
+ } else if err = os.Remove(pathname); err != nil {
+ log.Fatalf("[FAIL] %s", err)
+ } else {
+ log.Printf("[ OK ] %s is writable", a)
+ }
+ }
+
+ if want.Env != nil {
+ var (
+ fail bool
+ i int
+ got string
+ )
+ for i, got = range os.Environ() {
+ if i == len(want.Env) {
+ log.Fatalf("got more than %d environment variables", len(want.Env))
+ }
+ if got != want.Env[i] {
+ fail = true
+ log.Printf("[FAIL] %s", got)
+ } else {
+ log.Printf("[ OK ] %s", got)
+ }
+ }
+
+ i++
+ if i != len(want.Env) {
+ log.Fatalf("got %d environment variables, want %d", i, len(want.Env))
+ }
+
+ if fail {
+ log.Fatalf("[FAIL] some environment variables did not match")
+ }
+ } else {
+ log.Printf("[SKIP] skipping environ check")
+ }
+
+ if want.FS != nil {
+ if err := want.FS.Compare(log.Printf, ".", os.DirFS("/")); err != nil {
+ log.Fatalf("%v", err)
+ }
+ } else {
+ log.Printf("[SKIP] skipping fs check")
+ }
+
+ if want.Mount != nil {
+ var fail bool
+
+ m, err := mountinfo.Open("")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ i := 0
+ var ent mountinfo.Entry
+ for m.Next() {
+ m.Copy(&ent)
+
+ if i == len(want.Mount) {
+ log.Fatalf("got more than %d entries", i)
+ }
+ if !ent.EqualWithIgnore(want.Mount[i], "//ignore") {
+ fail = true
+ log.Printf("[FAIL] %s", &ent)
+ } else {
+ log.Printf("[ OK ] %s", &ent)
+ }
+
+ i++
+ }
+ if err = m.Err(); err != nil {
+ log.Fatalf("%v", err)
+ }
+
+ if i != len(want.Mount) {
+ log.Fatalf("got %d entries, want %d", i, len(want.Mount))
+ }
+
+ if fail {
+ log.Fatalf("[FAIL] some mount points did not match")
+ }
+ } else {
+ log.Printf("[SKIP] skipping mounts check")
+ }
+
+ if want.Seccomp {
+ const NULL = 0
+
+ for _, tc := range []struct {
+ name string
+ errno syscall.Errno
+
+ trap, a1, a2, a3, a4, a5, a6 uintptr
+ }{
+ {"syslog", syscall.EPERM, syscall.SYS_SYSLOG, 0, NULL, NULL, NULL, NULL, NULL},
+ {"acct", syscall.EPERM, syscall.SYS_ACCT, 0, NULL, NULL, NULL, NULL, NULL},
+ {"quotactl", syscall.EPERM, syscall.SYS_QUOTACTL, C.Q_GETQUOTA, NULL, uintptr(os.Getuid()), NULL, NULL, NULL},
+ {"add_key", syscall.EPERM, syscall.SYS_ADD_KEY, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"keyctl", syscall.EPERM, syscall.SYS_KEYCTL, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"request_key", syscall.EPERM, syscall.SYS_REQUEST_KEY, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"move_pages", syscall.EPERM, syscall.SYS_MOVE_PAGES, uintptr(os.Getpid()), NULL, NULL, NULL, NULL, NULL},
+ {"mbind", syscall.EPERM, syscall.SYS_MBIND, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"get_mempolicy", syscall.EPERM, syscall.SYS_GET_MEMPOLICY, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"set_mempolicy", syscall.EPERM, syscall.SYS_SET_MEMPOLICY, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"migrate_pages", syscall.EPERM, syscall.SYS_MIGRATE_PAGES, NULL, NULL, NULL, NULL, NULL, NULL},
+ } {
+ if _, _, errno := syscall.Syscall6(tc.trap, tc.a1, tc.a2, tc.a3, tc.a4, tc.a5, tc.a6); errno != tc.errno {
+ log.Fatalf("[FAIL] %s: %v, want %v", tc.name, errno, tc.errno)
+ }
+ log.Printf("[ OK ] %s: %v", tc.name, tc.errno)
+ }
+ } else {
+ log.Printf("[SKIP] skipping seccomp check")
+ }
+
+ if want.TrySocket != "" {
+ retry:
+ abstractConn, abstractErr := net.Dial("unix", "@"+want.TrySocket)
+ pathnameConn, pathnameErr := net.Dial("unix", want.TrySocket)
+ ok := true
+
+ if abstractErr == nil {
+ if err := abstractConn.Close(); err != nil {
+ ok = false
+ log.Printf("Close: %v", err)
+ }
+ }
+ if pathnameErr == nil {
+ if err := pathnameConn.Close(); err != nil {
+ ok = false
+ log.Printf("Close: %v", err)
+ }
+ }
+
+ if errors.Is(
+ abstractErr,
+ syscall.EAGAIN,
+ ) || errors.Is(
+ pathnameErr,
+ syscall.EAGAIN,
+ ) {
+ goto retry
+ }
+
+ abstractWantErr := error(want.ErrnoAbstract)
+ pathnameWantErr := error(want.ErrnoPathname)
+ if want.ErrnoAbstract == 0 {
+ abstractWantErr = nil
+ }
+ if want.ErrnoPathname == 0 {
+ pathnameWantErr = nil
+ }
+
+ if !errors.Is(abstractErr, abstractWantErr) {
+ ok = false
+ log.Printf("abstractErr: %v, want %v", abstractErr, abstractWantErr)
+ }
+ if !errors.Is(pathnameErr, pathnameWantErr) {
+ ok = false
+ log.Printf("pathnameErr: %v, want %v", pathnameErr, pathnameWantErr)
+ }
+
+ if !ok {
+ os.Exit(1)
+ }
+ }
+
+ s := make(chan os.Signal, 1)
+ signal.Notify(s, syscall.SIGTERM)
+ if _, err := os.Stdout.Write(make([]byte, 8)); err != nil {
+ log.Fatalf("cannot notify testsuite: %v", err)
+ }
+ <-s
+}
diff --git a/cmd/hakurei/testsuite/test.py b/cmd/hakurei/testsuite/test.py
new file mode 100644
index 00000000..98f08275
--- /dev/null
+++ b/cmd/hakurei/testsuite/test.py
@@ -0,0 +1,315 @@
+import json
+import shlex
+
+q = shlex.quote
+NODE_GROUPS = ["nodes", "floating_nodes"]
+
+
+def swaymsg(command: str = "", succeed=True, type="command"):
+ assert command != "" or type != "command", "Must specify command or type"
+ shell = q(f"swaymsg -t {q(type)} -- {q(command)}")
+ with machine.nested(f"sending swaymsg {shell!r}" + " (allowed to fail)" * (not succeed)):
+ ret = (machine.succeed if succeed else machine.execute)(
+ f"su - alice -c {shell}"
+ )
+
+ # execute also returns a status code, but disregard.
+ if not succeed:
+ _, ret = ret
+
+ if not succeed and not ret:
+ return None
+
+ parsed = json.loads(ret)
+ return parsed
+
+
+def walk(tree):
+ yield tree
+ for group in NODE_GROUPS:
+ for node in tree.get(group, []):
+ yield from walk(node)
+
+
+def wait_for_window(pattern):
+ def func(last_chance):
+ nodes = (node["name"] for node in walk(swaymsg(type="get_tree")))
+
+ if last_chance:
+ nodes = list(nodes)
+ machine.log(f"Last call! Current list of windows: {nodes}")
+
+ return any(pattern in name for name in nodes)
+
+ retry(func)
+
+
+def collect_state_ui(name):
+ swaymsg(f"exec hakurei ps > '/tmp/{name}.ps'")
+ machine.wait_for_file(f"/tmp/{name}.ps")
+ machine.copy_from_vm(f"/tmp/{name}.ps", "")
+ swaymsg(f"exec hakurei --json ps > '/tmp/{name}.json'")
+ machine.wait_for_file(f"/tmp/{name}.json")
+ machine.copy_from_vm(f"/tmp/{name}.json", "")
+ machine.screenshot(name)
+
+
+def check_state(name, enablements):
+ instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei --json ps"))
+ if len(instances) != 1:
+ raise Exception(f"unexpected state length {len(instances)}")
+ instance = instances[0]
+
+ command = f"{name}-start"
+ if not (instance['container']['path'].startswith("/nix/store/")) or not (instance['container']['path'].endswith(command)):
+ raise Exception(f"unexpected path {instance['path']}")
+
+ if len(instance['container']['args']) != 1 or instance['container']['args'][0] != command:
+ raise Exception(f"unexpected args {instance['args']}")
+
+ if instance['enablements'] != enablements:
+ raise Exception(f"unexpected enablements {instance['enablements']['enablements']}")
+
+
+def hakurei(command):
+ swaymsg(f"exec hakurei {command}")
+
+
+start_all()
+machine.wait_for_unit("multi-user.target")
+
+# To check hakurei's version:
+print(machine.succeed("sudo -u alice -i hakurei version"))
+
+# Wait for Sway to complete startup:
+machine.wait_for_file("/run/user/1000/wayland-1")
+machine.wait_for_file("/tmp/sway-ipc.sock")
+
+# Run hakurei Go tests outside of nix build in the background:
+swaymsg("exec hakurei-test")
+
+# Deny unmapped uid:
+denyOutput = machine.fail("sudo -u untrusted -i hakurei exec &>/dev/stdout")
+print(denyOutput)
+denyOutputVerbose = machine.fail("sudo -u untrusted -i hakurei -v exec &>/dev/stdout")
+print(denyOutputVerbose)
+
+# Direct hsu call:
+userid = machine.succeed("sudo -u alice -i hsu")
+if userid != "0":
+ raise Exception(f"unexpected userid: {userid}")
+
+# Verify hsu fault behaviour:
+if denyOutput != "hsu: uid 1001 is not in the hsurc file\n":
+ raise Exception(f"unexpected deny output:\n{denyOutput}")
+if denyOutputVerbose != "hsu: uid 1001 is not in the hsurc file\nhakurei: *cannot retrieve user id from setuid wrapper: current user is not in the hsurc file\n":
+ raise Exception(f"unexpected deny verbose output:\n{denyOutputVerbose}")
+
+# Verify timeout behaviour:
+machine.succeed('sudo -u alice -i hakurei-check-linger-timeout > /var/tmp/linger-stdout 2> /var/tmp/linger-stderr || (cat /var/tmp/linger-stderr; false)')
+linger_stdout = machine.succeed("cat /var/tmp/linger-stdout")
+linger_stderr = machine.succeed("cat /var/tmp/linger-stderr")
+if linger_stdout != "":
+ raise Exception(f"unexpected stdout: {linger_stdout}")
+if linger_stderr != "init: timeout exceeded waiting for lingering processes\n":
+ raise Exception(f"unexpected stderr: {linger_stderr}")
+
+check_offset = 0
+
+
+def hakurei_identity(offset):
+ return 1+check_offset+offset
+
+
+# Start hakurei permissive defaults outside Wayland session:
+print(machine.succeed("sudo -u alice -i hakurei -v exec -a 0 touch /tmp/pd-bare-ok"))
+machine.wait_for_file("/tmp/hakurei.0/tmpdir/0/pd-bare-ok")
+
+# Verify silent output permissive defaults:
+output = machine.succeed("sudo -u alice -i hakurei exec -a 0 true &>/dev/stdout")
+if output != "":
+ raise Exception(f"unexpected output\n{output}")
+
+# Verify silent output permissive defaults signal:
+def silent_output_interrupt(flags):
+ swaymsg("exec foot")
+ wait_for_window("alice@machine")
+ # identity 0 does not have home-manager
+ machine.send_chars(f"exec hakurei exec {flags}-a 0 sh -c 'export PATH=/run/current-system/sw/bin:$PATH && touch /tmp/pd-silent-ready && sleep infinity' &>/tmp/pd-silent\n")
+ machine.wait_for_file("/tmp/hakurei.0/tmpdir/0/pd-silent-ready")
+ machine.succeed("rm /tmp/hakurei.0/tmpdir/0/pd-silent-ready")
+ machine.send_key("ctrl-c")
+ machine.wait_until_fails("pgrep foot")
+ machine.wait_until_fails(f"pgrep -u alice -f 'hakurei exec {flags}-a 0 '")
+ output = machine.succeed("cat /tmp/pd-silent && rm /tmp/pd-silent")
+ if output != "":
+ raise Exception(f"unexpected output\n{output}")
+
+
+silent_output_interrupt("")
+silent_output_interrupt("--dbus ") # this one is especially painful as it maintains a helper
+silent_output_interrupt("--wayland -X --dbus --pulse ")
+
+# Verify graceful failure on bad Wayland display name:
+print(machine.fail("sudo -u alice -i hakurei -v exec --wayland true"))
+
+# Start hakurei permissive defaults within Wayland session:
+hakurei('-v exec --wayland --dbus --dbus-log notify-send -a "NixOS Tests" "Test notification" "Notification from within sandbox." && touch /tmp/dbus-ok')
+machine.wait_for_file("/tmp/dbus-ok")
+collect_state_ui("dbus_notify_exited")
+# not in pid namespace, verify termination
+machine.wait_until_fails("pgrep xdg-dbus-proxy")
+machine.succeed("pkill -9 mako")
+
+# Check revert type selection:
+hakurei("-v exec --wayland -X --dbus --pulse -u p0 foot && touch /tmp/p0-exit-ok")
+wait_for_window("p0@machine")
+print(machine.succeed("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000"))
+hakurei("-v exec --wayland -X --dbus --pulse -u p1 foot && touch /tmp/p1-exit-ok")
+wait_for_window("p1@machine")
+print(machine.succeed("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000"))
+machine.send_chars("exit\n")
+machine.wait_for_file("/tmp/p1-exit-ok")
+# Verify acl is kept alive:
+print(machine.succeed("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000"))
+machine.send_chars("exit\n")
+machine.wait_for_file("/tmp/p0-exit-ok")
+machine.fail("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000")
+
+# Check invalid identifier fd behaviour:
+machine.fail('echo \'{"container":{"shell":"/proc/nonexistent","home":"/proc/nonexistent","path":"/proc/nonexistent"}}\' | sudo -u alice -i hakurei -v run --identifier-fd 32767 - 2>&1 | tee > /tmp/invalid-identifier-fd')
+machine.wait_for_file("/tmp/invalid-identifier-fd")
+print(machine.succeed('grep "^hakurei: cannot write identifier: bad file descriptor$" /tmp/invalid-identifier-fd'))
+
+# Check interrupt shim behaviour:
+swaymsg("exec sh -c 'ne-foot; echo -n $? > /tmp/monitor-exit-code'")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.succeed("pkill -INT -f 'hakurei -v run '")
+machine.wait_until_fails("pgrep foot")
+machine.wait_for_file("/tmp/monitor-exit-code")
+interrupt_exit_code = int(machine.succeed("cat /tmp/monitor-exit-code"))
+if interrupt_exit_code != 230:
+ raise Exception(f"unexpected exit code {interrupt_exit_code}")
+
+# Check interrupt shim behaviour immediate termination:
+swaymsg("exec sh -c 'ne-foot-immediate; echo -n $? > /tmp/monitor-exit-code'")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.succeed("pkill -INT -f 'hakurei -v run '")
+machine.wait_until_fails("pgrep foot")
+machine.wait_for_file("/tmp/monitor-exit-code")
+interrupt_exit_code = int(machine.succeed("cat /tmp/monitor-exit-code"))
+if interrupt_exit_code != 254:
+ raise Exception(f"unexpected exit code {interrupt_exit_code}")
+
+# Check shim SIGCONT from unexpected process behaviour:
+swaymsg("exec sh -c 'ne-foot &> /tmp/shim-cont-unexpected-pid'")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.succeed("pkill -CONT -f 'hakurei shim'")
+machine.succeed("pkill -INT -f 'hakurei -v run '")
+machine.wait_until_fails("pgrep foot")
+machine.wait_for_file("/tmp/shim-cont-unexpected-pid")
+print(machine.succeed('grep "shim: got SIGCONT from unexpected process$" /tmp/shim-cont-unexpected-pid'))
+
+# Check setscheduler:
+sched_unset = int(machine.succeed("sudo -u alice -i hakurei -v exec cat /proc/self/sched | grep '^policy' | tr -d ' ' | cut -d ':' -f 2"))
+if sched_unset != 0:
+ raise Exception(f"unexpected unset policy: {sched_unset}")
+sched_idle = int(machine.succeed("sudo -u alice -i hakurei -v exec --policy=idle cat /proc/self/sched | grep '^policy' | tr -d ' ' | cut -d ':' -f 2"))
+if sched_idle != 5:
+ raise Exception(f"unexpected idle policy: {sched_idle}")
+sched_rr = int(machine.succeed("sudo -u alice -i hakurei -v exec --policy=rr cat /proc/self/sched | grep '^policy' | tr -d ' ' | cut -d ':' -f 2"))
+if sched_rr != 2:
+ raise Exception(f"unexpected round-robin policy: {sched_idle}")
+
+# Start app (foot) with Wayland enablement:
+swaymsg("exec ne-foot")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.send_chars("clear; wayland-info && touch /var/tmp/client-ok\n")
+machine.wait_for_file("/var/tmp/client-ok")
+collect_state_ui("foot_wayland")
+check_state("ne-foot", {"wayland": True})
+# Verify lack of acl on XDG_RUNTIME_DIR:
+machine.fail(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(0) + 10000}")
+machine.send_chars("exit\n")
+machine.wait_until_fails("pgrep foot")
+machine.fail(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(0) + 10000}")
+
+# Test pipewire-pulse:
+swaymsg("exec pa-foot")
+wait_for_window(f"u0_a{hakurei_identity(1)}@machine")
+machine.send_chars("clear; pactl info && touch /var/tmp/pulse-ok\n")
+machine.wait_for_file("/var/tmp/pulse-ok")
+collect_state_ui("pulse_wayland")
+check_state("pa-foot", {"wayland": True, "pipewire": True})
+machine.fail("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")
+machine.send_chars("exit\n")
+machine.wait_until_fails("pgrep foot")
+machine.wait_until_fails("pgrep -x hakurei")
+machine.succeed("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")
+# Test PipeWire SecurityContext:
+machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v exec --pulse pactl info")
+machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v exec --pulse pactl set-sink-mute @DEFAULT_SINK@ toggle")
+# Test PipeWire direct access:
+machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 pw-dump")
+machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v exec --pipewire pw-dump")
+
+# Test XWayland (foot does not support X):
+swaymsg("exec x11-alacritty")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.send_chars("clear; glinfo && touch /var/tmp/x11-ok\n")
+machine.wait_for_file("/var/tmp/x11-ok")
+collect_state_ui("alacritty_x11")
+check_state("x11-alacritty", {"x11": True})
+machine.send_chars("exit\n")
+machine.wait_until_fails("pgrep alacritty")
+
+# Start app (foot) with direct Wayland access:
+swaymsg("exec da-foot")
+wait_for_window(f"u0_a{hakurei_identity(3)}@machine")
+machine.send_chars("clear; wayland-info && touch /var/tmp/direct-ok\n")
+collect_state_ui("foot_direct")
+machine.wait_for_file("/var/tmp/direct-ok")
+check_state("da-foot", {"wayland": True})
+# Verify acl on XDG_RUNTIME_DIR:
+print(machine.succeed(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(3) + 10000}"))
+machine.send_chars("exit\n")
+machine.wait_until_fails("pgrep foot")
+# Verify acl cleanup on XDG_RUNTIME_DIR:
+machine.wait_until_fails(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(3) + 10000}")
+
+# Test syscall filter:
+print(machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 strace-failure"))
+
+# Start app (foot) with Wayland enablement from a terminal:
+swaymsg("exec foot $SHELL -c '(ne-foot) & disown && exec $SHELL'")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.send_chars("clear; wayland-info && touch /var/tmp/term-ok\n")
+machine.wait_for_file("/var/tmp/term-ok")
+machine.send_key("alt-h")
+machine.send_chars("clear; hakurei show $(hakurei ps --short) && touch /tmp/ps-show-ok && exec cat\n")
+machine.wait_for_file("/tmp/ps-show-ok")
+collect_state_ui("foot_wayland_term")
+check_state("ne-foot", {"wayland": True})
+machine.send_key("alt-l")
+machine.send_chars("exit\n")
+wait_for_window("alice@machine")
+machine.send_key("ctrl-c")
+machine.wait_until_fails("pgrep foot")
+
+# Exit Sway and verify process exit status 0:
+machine.wait_until_fails("pgrep -x hakurei")
+swaymsg("exit", succeed=False)
+machine.wait_for_file("/tmp/sway-exit-ok")
+
+# Print hakurei share and rundir contents:
+print(machine.succeed("find /tmp/hakurei.0 "
+ + "-path '/tmp/hakurei.0/runtime/*/*' -prune -o "
+ + "-path '/tmp/hakurei.0/tmpdir/*/*' -prune -o "
+ + "-print"))
+print(machine.succeed("find /run/user/1000/hakurei"))
+machine.succeed("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")
+
+# Verify go test status:
+machine.wait_for_file("/tmp/hakurei-test-done")
+print(machine.succeed("cat /tmp/hakurei-test.log"))
+machine.wait_for_file("/tmp/hakurei-test-ok")
diff --git a/cmd/mbf/cache.go b/cmd/mbf/cache.go
index 0e20ca99..166e1c45 100644
--- a/cmd/mbf/cache.go
+++ b/cmd/mbf/cache.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "errors"
"net/http"
"os"
"path/filepath"
@@ -14,6 +15,9 @@ import (
"hakurei.app/pkg"
)
+// poisonOpen is whether cache.open is poisoned. This enables running as root.
+var _, poisonOpen = os.LookupEnv("MBF_POISON_OPEN")
+
// cache refers to an instance of [pkg.Cache] that might be open.
type cache struct {
ctx context.Context
@@ -49,6 +53,10 @@ func writeTitle(msg message.Msg, s string) {
// open opens the underlying [pkg.Cache].
func (cache *cache) open() (err error) {
+ if poisonOpen {
+ return errors.New("attempting to open cache")
+ }
+
if cache.c != nil {
return os.ErrInvalid
}
diff --git a/cmd/mbf/info.go b/cmd/mbf/info.go
index a4bc7c02..d8691f60 100644
--- a/cmd/mbf/info.go
+++ b/cmd/mbf/info.go
@@ -56,9 +56,9 @@ func commandInfo(
mustPrintln("website : " +
strings.TrimSuffix(meta.Website, "/"))
}
- if len(meta.Dependencies) > 0 {
+ if len(meta.Runtimes) > 0 {
mustPrint("depends on :")
- for _, d := range meta.Dependencies {
+ for _, d := range meta.Runtimes {
_meta, _ := rosa.MustLoad(d)
s := _meta.Name
if _meta.Version != rosa.Unversioned {
diff --git a/cmd/mbf/internal/ci/ci.go b/cmd/mbf/internal/ci/ci.go
new file mode 100644
index 00000000..b1ee6a8c
--- /dev/null
+++ b/cmd/mbf/internal/ci/ci.go
@@ -0,0 +1,569 @@
+// Package ci implements the CI service and client.
+package ci
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/ed25519"
+ "encoding/binary"
+ "errors"
+ "io"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "sync"
+ "syscall"
+ "time"
+ "unique"
+ "unsafe"
+ _ "unsafe" // for go:linkname
+
+ "hakurei.app/internal/rosa"
+ "hakurei.app/message"
+ "hakurei.app/pkg"
+)
+
+// setSource is made available here to accept prepared hakurei tarballs.
+//
+//go:linkname setSource hakurei.app/internal/rosa.(*S).setSource
+func setSource(s *rosa.S, p []byte, version string)
+
+// inotifyInit returns a new inotify instance.
+func inotifyInit() (*os.File, error) {
+ fd, err := syscall.InotifyInit1(syscall.IN_NONBLOCK | syscall.IN_CLOEXEC)
+ if err != nil {
+ return nil, os.NewSyscallError("inotify_init1", err)
+ }
+ return os.NewFile(uintptr(fd), "inotify"), nil
+}
+
+// inotifyAddWatch adds pathname to in.
+func inotifyAddWatch(
+ in *os.File,
+ pathname string,
+ mask uint32,
+) (watchdesc int, err error) {
+ sc, _err := in.SyscallConn()
+ if _err != nil {
+ return -1, _err
+ }
+ if _err = sc.Control(func(fd uintptr) {
+ watchdesc, err = syscall.InotifyAddWatch(int(fd), pathname, mask)
+ }); _err != nil {
+ return -1, _err
+ }
+ return
+}
+
+// Follow reads from the file at pathname and writes its contents and any new
+// contents to w. follow returns if a read, write or inotify error occurs, or
+// the context is cancelled.
+func Follow(ctx context.Context, pathname string, w io.Writer) (err error) {
+ var in *os.File
+ if in, err = inotifyInit(); err != nil {
+ return
+ }
+ defer func() {
+ if _err := in.Close(); err == nil {
+ err = _err
+ }
+ }()
+
+ if _, err = inotifyAddWatch(in, pathname, syscall.IN_MODIFY); err != nil {
+ return
+ }
+ var r *os.File
+ if r, err = os.Open(pathname); err != nil {
+ return
+ }
+ defer func() {
+ if _err := r.Close(); err == nil {
+ err = _err
+ }
+ }()
+
+ done := make(chan struct{})
+ defer close(done)
+ go func() {
+ select {
+ case <-ctx.Done():
+ now := time.Now()
+ _ = in.SetDeadline(now)
+ return
+
+ case <-done:
+ return
+ }
+ }()
+
+ if _, err = io.Copy(w, r); err != nil {
+ return
+ }
+
+ buf := make([]byte, os.Getpagesize())
+ for {
+ if _, err = io.Copy(w, r); err != nil {
+ return
+ }
+ if _, err = in.Read(buf); err != nil {
+ if errors.Is(err, os.ErrDeadlineExceeded) {
+ err = nil
+ }
+ return
+ }
+ }
+}
+
+// archiveTime is the hardcoded time written to every header time field.
+var archiveTime = time.Unix(0, 0)
+
+// Path wraps the [pkg.Cache] pathname.
+type Path string
+
+// String returns the value of p.
+func (p Path) String() string { return string(p) }
+
+// append is [filepath.Join] with p as the first element.
+func (p Path) append(elem ...string) string {
+ return filepath.Join(append([]string{p.String()}, elem...)...)
+}
+
+// New returns a new [Path].
+func New(c *pkg.Cache) Path { return Path(c.Path().String()) }
+
+// name returns the CI socket pathname.
+func (p Path) name() string { return p.append("ci") }
+
+// client returns the CI http client.
+func (p Path) client() *http.Client {
+ var d net.Dialer
+ addr := net.UnixAddr{
+ Net: "unix",
+ Name: p.name(),
+ }
+
+ return &http.Client{Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return d.DialUnix(ctx, "unix", nil, &addr)
+ },
+ }}
+}
+
+// cure writes the identifier of the pending artifact, cures the artifact, and
+// writes the cure whence. For an unsuccessful cure, a negative whence is
+// written, followed by a user-facing error string.
+func cure(c *pkg.Cache, a pkg.Artifact, w http.ResponseWriter) error {
+ h := w.Header()
+ h.Set("Content-Type", "application/octet-stream")
+ h.Set("Cache-Control", "no-cache")
+
+ f, ok := w.(http.Flusher)
+ if !ok {
+ _, _ = w.Write([]byte{0})
+ return errors.ErrUnsupported
+ }
+
+ id := c.Ident(a).Value()
+ if _, err := w.Write(id[:]); err != nil {
+ return err
+ }
+ f.Flush()
+
+ _, _, whence, err := c.CureWhence(a)
+ if err != nil {
+ whence = -1
+ }
+ if _, _err := w.Write(
+ binary.LittleEndian.AppendUint64(nil, uint64(whence)),
+ ); _err != nil {
+ return _err
+ }
+
+ if err != nil {
+ if _, _err := io.WriteString(w, err.Error()); _err != nil {
+ return _err
+ }
+ }
+ f.Flush()
+ return err
+}
+
+// spool holds reusable [rosa.S] instances.
+var spool = sync.Pool{New: func() any { return rosa.New() }}
+
+// getS returns the address of a populated [rosa.S]. Its hakurei-source may be
+// clobbered and must be replaced using setSource before use.
+func getS() *rosa.S { return spool.Get().(*rosa.S) }
+
+// putS returns s to spool.
+func putS(s *rosa.S) { spool.Put(s) }
+
+// versionSize is the maximum size of the specified version string, plus its
+// deliminator byte.
+const versionSize = 8 + 16 + 6 + 2
+
+// errBadVersion is returned by readSource if a header does not contain
+// the deliminator byte.
+var errBadVersion = errors.New("unterminated version string")
+
+// readSource reads a version string and compressed source tarball from r and
+// returns the address of a [rosa.S] with this source tarball. The resulting
+// [rosa.S] must be returned via putS.
+func readSource(w http.ResponseWriter, r *http.Request) (*rosa.S, error) {
+ var header [versionSize]byte
+ _, err := io.ReadFull(r.Body, header[:])
+ if err != nil {
+ _ = r.Body.Close()
+ http.Error(w, "bad header", http.StatusBadRequest)
+ return nil, err
+ }
+
+ var version string
+ if i := bytes.IndexByte(header[:], 0); i < 0 {
+ _ = r.Body.Close()
+ http.Error(w, "unterminated version string", http.StatusBadRequest)
+ return nil, errBadVersion
+ } else {
+ version = unsafe.String(&header[0], i)
+ }
+
+ var p []byte
+ if p, err = io.ReadAll(r.Body); err != nil {
+ _ = r.Body.Close()
+ http.Error(w, "cannot receive payload", http.StatusInternalServerError)
+ return nil, err
+ }
+
+ s := getS()
+ setSource(s, p, version)
+ return s, r.Body.Close()
+}
+
+// ErrDaemonError is returned by writeSource generally if cure could not flush
+// on the connection to notify completion.
+var ErrDaemonError = errors.New("CI service could not process the request")
+
+// writeSource writes a source tarball to the specified endpoint of the CI
+// backend servicing the cache referred to by cm.
+func (p Path) writeSource(
+ ctx context.Context,
+ w io.Writer,
+ endpoint, source, version string,
+) (*pkg.ID, error) {
+ if len(version) >= versionSize {
+ return nil, syscall.ENOMEM
+ }
+ var header [versionSize]byte
+ copy(header[:], version[:])
+
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gw)
+
+ if err := filepath.WalkDir(source, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if d.IsDir() && d.Name() == ".git" {
+ return fs.SkipDir
+ }
+
+ var fi fs.FileInfo
+ if fi, err = d.Info(); err != nil {
+ return err
+ }
+
+ var linkname string
+ if fi.Mode()&fs.ModeSymlink != 0 {
+ if linkname, err = os.Readlink(path); err != nil {
+ return err
+ }
+ }
+
+ var h *tar.Header
+ if h, err = tar.FileInfoHeader(fi, linkname); err != nil {
+ return err
+ }
+ h.AccessTime, h.ChangeTime, h.ModTime = archiveTime, archiveTime, archiveTime
+ h.Name = path
+
+ var isVersion bool
+ if dir, file := filepath.Split(path); filepath.Base(dir) == "dist" &&
+ file == "VERSION" && fi.Mode().IsRegular() {
+ isVersion = true
+ h.Size = int64(len(version))
+ }
+
+ if err = tw.WriteHeader(h); err != nil {
+ return err
+ }
+
+ if isVersion {
+ _, err = io.WriteString(tw, version)
+ return err
+ }
+
+ if fi.Mode().IsRegular() {
+ var f io.ReadCloser
+ if f, err = os.Open(path); err != nil {
+ return err
+ }
+
+ _, err = io.Copy(tw, f)
+ if _err := f.Close(); err == nil {
+ err = _err
+ }
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ if err := tw.Close(); err != nil {
+ return nil, err
+ }
+ if err := gw.Close(); err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ "http://"+path.Join("_", endpoint),
+ io.MultiReader(bytes.NewReader(header[:]), bytes.NewReader(buf.Bytes())),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ var resp *http.Response
+ resp, err = p.client().Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ var id pkg.ID
+ _, err = io.ReadFull(resp.Body, id[:])
+ if err != nil {
+ _ = resp.Body.Close()
+ if errors.Is(err, io.ErrUnexpectedEOF) {
+ return nil, ErrDaemonError
+ }
+ return nil, err
+ }
+
+ c, cancel := context.WithCancel(ctx)
+ done := make(chan error, 1)
+ var whence int
+ go func() {
+ defer cancel()
+ var wbuf [8]byte
+ _, _err := io.ReadFull(resp.Body, wbuf[:])
+ whence = int(binary.LittleEndian.Uint64(wbuf[:]))
+ done <- _err
+ }()
+
+ if w != nil {
+ retry:
+ err = Follow(c, filepath.Join(p.String(), "status", pkg.Encode(id)), w)
+ if err != nil {
+ if ctx.Err() == nil && errors.Is(err, os.ErrNotExist) {
+ goto retry
+ }
+
+ _ = resp.Body.Close()
+ return nil, err
+ }
+ }
+
+ err = <-done
+ if err != nil {
+ _ = resp.Body.Close()
+ return nil, err
+ }
+
+ if whence < 0 {
+ var m []byte
+ if m, err = io.ReadAll(resp.Body); err != nil {
+ _ = resp.Body.Close()
+ return nil, err
+ } else if err = resp.Body.Close(); err != nil {
+ return nil, err
+ }
+ return nil, errors.New(unsafe.String(unsafe.SliceData(m), len(m)))
+ }
+ return &id, resp.Body.Close()
+}
+
+// The stubKey is used by the mirror service exposed by serve where
+// authentication is unnecessary.
+var stubKey = ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize))
+
+// fetch fetches the outcome of id and writes it to the specified directory.
+func (p Path) fetch(ctx context.Context, id *pkg.ID, output string) error {
+ c := p.client()
+ r, err := rosa.NewRemote(
+ c, "http://_",
+ stubKey.Public().(ed25519.PublicKey),
+ )
+ if err != nil {
+ return err
+ }
+
+ var sum *pkg.Checksum
+ if sum, err = r.Artifact(ctx, unique.Make(*id)); err != nil {
+ return err
+ }
+
+ var req *http.Request
+ if req, err = http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ "http://"+path.Join("_", "outcome", pkg.Encode(*sum)),
+ nil,
+ ); err != nil {
+ return err
+ }
+
+ var resp *http.Response
+ if resp, err = c.Do(req); err != nil {
+ return err
+ }
+
+ err = pkg.Extract(resp.Body, output, nil)
+ if closeErr := resp.Body.Close(); err == nil {
+ err = closeErr
+ }
+ return err
+}
+
+const (
+ // endpointDist accepts a Path.writeSource stream and produces a
+ // distribution for the submitted source.
+ endpointDist = "/dist"
+
+ // endpointRace is like endpointDist, but the resulting distribution is
+ // instrumented with the data race detector.
+ endpointRace = "/race"
+)
+
+// MakeDist creates a hakurei distribution using the CI service.
+func (p Path) MakeDist(
+ ctx context.Context,
+ w io.Writer,
+ output, source, version string,
+) error {
+ id, err := p.writeSource(ctx, w, endpointDist, source, version)
+ if err != nil {
+ return err
+ }
+ return p.fetch(ctx, id, output)
+}
+
+// MakeDistRace creates a hakurei distribution (race) using the CI service.
+func (p Path) MakeDistRace(
+ ctx context.Context,
+ w io.Writer,
+ output, source, version string,
+) error {
+ id, err := p.writeSource(ctx, w, endpointRace, source, version)
+ if err != nil {
+ return err
+ }
+ return p.fetch(ctx, id, output)
+}
+
+// Serve services CI workload dispatched to c.
+func Serve(ctx context.Context, msg message.Msg, c *pkg.Cache) error {
+ const shutdownTimeout = 15 * time.Second
+ p := New(c)
+ addr := net.UnixAddr{
+ Net: "unix",
+ Name: p.name(),
+ }
+
+ var mux http.ServeMux
+ mux.HandleFunc("POST "+endpointDist, func(w http.ResponseWriter, r *http.Request) {
+ s, err := readSource(w, r)
+ if err != nil {
+ msg.Verbose(err)
+ return
+ }
+ defer putS(s)
+
+ _, a := s.Std().MustLoad(rosa.H("hakurei-dist"))
+ if err = cure(c, a, w); err != nil {
+ msg.Verbose(err)
+ return
+ }
+ if msg.IsVerbose() {
+ msg.Verbosef(
+ "satisfied distribution %s",
+ pkg.Encode(c.Ident(a).Value()),
+ )
+ }
+ })
+ mux.HandleFunc("POST "+endpointRace, func(w http.ResponseWriter, r *http.Request) {
+ s, err := readSource(w, r)
+ if err != nil {
+ msg.Verbose(err)
+ return
+ }
+ defer putS(s)
+
+ _, a := s.Std().MustLoad(rosa.H("hakurei-dist-race"))
+ if err = cure(c, a, w); err != nil {
+ msg.Verbose(err)
+ return
+ }
+ if msg.IsVerbose() {
+ msg.Verbosef(
+ "satisfied distribution %s (race)",
+ pkg.Encode(c.Ident(a).Value()),
+ )
+ }
+ })
+
+ if r, err := os.OpenRoot(p.String()); err != nil {
+ return err
+ } else {
+ defer func() {
+ if err = r.Close(); err != nil {
+ msg.Verbose(err)
+ }
+ }()
+ rosa.NewMirror(msg, r.FS(), stubKey).Register(&mux)
+ }
+
+ server := http.Server{Handler: &mux}
+ go func() {
+ <-ctx.Done()
+ cc, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if _err := server.Shutdown(cc); _err != nil {
+ log.Fatal(_err)
+ }
+ }()
+
+ ul, err := net.ListenUnix("unix", &addr)
+ if err != nil {
+ return err
+ }
+ ul.SetUnlinkOnClose(true)
+ msg.Verbosef("listening on %s", addr.Name)
+
+ err = server.Serve(ul)
+ if errors.Is(err, http.ErrServerClosed) {
+ err = nil
+ }
+ return err
+}
diff --git a/cmd/mbf/internal/ci/ci_test.go b/cmd/mbf/internal/ci/ci_test.go
new file mode 100644
index 00000000..447c4ab1
--- /dev/null
+++ b/cmd/mbf/internal/ci/ci_test.go
@@ -0,0 +1,56 @@
+package ci_test
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "hakurei.app/cmd/mbf/internal/ci"
+)
+
+func TestFollow(t *testing.T) {
+ t.Parallel()
+
+ pathname := filepath.Join(t.TempDir(), "f")
+ w, err := os.Create(pathname)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var buf bytes.Buffer
+ ctx, cancel := context.WithCancel(t.Context())
+
+ var want string
+ go func() {
+ defer cancel()
+ for _, s := range []string{
+ "\xde\xad\xbe\xef",
+ "\xff\xff\xff\xff",
+ "\x00\x00",
+ } {
+ want += s
+ if _, _err := w.WriteString(s); _err != nil {
+ panic(_err)
+ }
+ }
+ }()
+
+retry:
+ if err = ci.Follow(ctx, pathname, &buf); err != nil {
+ t.Fatal(err)
+ }
+ <-ctx.Done()
+
+ // the inotify event takes time to arrive, and there is no way to
+ // synchronise for this cleanly
+ if buf.Len() != len(want) {
+ buf.Reset()
+ goto retry
+ }
+
+ if got := buf.String(); got != want {
+ t.Fatalf("follow: %q, want %q", got, want)
+ }
+}
diff --git a/cmd/mbf/internal/pkgserver/api.go b/cmd/mbf/internal/pkgsite/api.go
index 68ccd471..a3094359 100644
--- a/cmd/mbf/internal/pkgserver/api.go
+++ b/cmd/mbf/internal/pkgsite/api.go
@@ -1,5 +1,5 @@
-// Package pkgserver implements the package metadata service backend.
-package pkgserver
+// Package pkgsite implements the package metadata website.
+package pkgsite
import (
"context"
diff --git a/cmd/mbf/internal/pkgserver/api_test.go b/cmd/mbf/internal/pkgsite/api_test.go
index 1552fec9..d2c21d81 100644
--- a/cmd/mbf/internal/pkgserver/api_test.go
+++ b/cmd/mbf/internal/pkgsite/api_test.go
@@ -1,4 +1,4 @@
-package pkgserver
+package pkgsite
import (
"net/http"
diff --git a/cmd/mbf/internal/pkgserver/index.go b/cmd/mbf/internal/pkgsite/index.go
index 2a9b6de5..760806c0 100644
--- a/cmd/mbf/internal/pkgserver/index.go
+++ b/cmd/mbf/internal/pkgsite/index.go
@@ -1,4 +1,4 @@
-package pkgserver
+package pkgsite
import (
"cmp"
diff --git a/cmd/mbf/internal/pkgserver/index_test.go b/cmd/mbf/internal/pkgsite/index_test.go
index 8f3b5530..abf78876 100644
--- a/cmd/mbf/internal/pkgserver/index_test.go
+++ b/cmd/mbf/internal/pkgsite/index_test.go
@@ -1,4 +1,4 @@
-package pkgserver
+package pkgsite
import (
"bytes"
diff --git a/cmd/mbf/internal/pkgserver/search.go b/cmd/mbf/internal/pkgsite/search.go
index 15947804..ef7cd76f 100644
--- a/cmd/mbf/internal/pkgserver/search.go
+++ b/cmd/mbf/internal/pkgsite/search.go
@@ -1,4 +1,4 @@
-package pkgserver
+package pkgsite
import (
"cmp"
diff --git a/cmd/mbf/internal/pkgserver/ui/index.html b/cmd/mbf/internal/pkgsite/ui/index.html
index 6a5d72b4..6a5d72b4 100644
--- a/cmd/mbf/internal/pkgserver/ui/index.html
+++ b/cmd/mbf/internal/pkgsite/ui/index.html
diff --git a/cmd/mbf/internal/pkgserver/ui/index.ts b/cmd/mbf/internal/pkgsite/ui/index.ts
index 0784b81f..0784b81f 100644
--- a/cmd/mbf/internal/pkgserver/ui/index.ts
+++ b/cmd/mbf/internal/pkgsite/ui/index.ts
diff --git a/cmd/mbf/internal/pkgserver/ui/style.css b/cmd/mbf/internal/pkgsite/ui/style.css
index b4f281ac..b4f281ac 100644
--- a/cmd/mbf/internal/pkgserver/ui/style.css
+++ b/cmd/mbf/internal/pkgsite/ui/style.css
diff --git a/cmd/mbf/internal/pkgserver/ui/tsconfig.json b/cmd/mbf/internal/pkgsite/ui/tsconfig.json
index 24df4936..24df4936 100644
--- a/cmd/mbf/internal/pkgserver/ui/tsconfig.json
+++ b/cmd/mbf/internal/pkgsite/ui/tsconfig.json
diff --git a/cmd/mbf/internal/pkgserver/ui/ui.go b/cmd/mbf/internal/pkgsite/ui/ui.go
index 3fc0d89c..3fc0d89c 100644
--- a/cmd/mbf/internal/pkgserver/ui/ui.go
+++ b/cmd/mbf/internal/pkgsite/ui/ui.go
diff --git a/cmd/mbf/internal/pkgserver/ui/ui_full.go b/cmd/mbf/internal/pkgsite/ui/ui_full.go
index 564787dc..564787dc 100644
--- a/cmd/mbf/internal/pkgserver/ui/ui_full.go
+++ b/cmd/mbf/internal/pkgsite/ui/ui_full.go
diff --git a/cmd/mbf/internal/pkgserver/ui/ui_stub.go b/cmd/mbf/internal/pkgsite/ui/ui_stub.go
index daf010f8..daf010f8 100644
--- a/cmd/mbf/internal/pkgserver/ui/ui_stub.go
+++ b/cmd/mbf/internal/pkgsite/ui/ui_stub.go
diff --git a/cmd/mbf/main.go b/cmd/mbf/main.go
index 4f471d6a..dd5ee624 100644
--- a/cmd/mbf/main.go
+++ b/cmd/mbf/main.go
@@ -43,8 +43,9 @@ import (
"hakurei.app/message"
"hakurei.app/pkg"
- "hakurei.app/cmd/mbf/internal/pkgserver"
- "hakurei.app/cmd/mbf/internal/pkgserver/ui"
+ "hakurei.app/cmd/mbf/internal/ci"
+ "hakurei.app/cmd/mbf/internal/pkgsite"
+ "hakurei.app/cmd/mbf/internal/pkgsite/ui"
)
// builtin contains native and embedded [rosa.Artifact] registrations.
@@ -70,7 +71,7 @@ func main() {
log.SetPrefix("mbf: ")
msg := message.New(log.Default())
- if os.Geteuid() == 0 {
+ if !poisonOpen && os.Geteuid() == 0 {
log.Fatal("this program must not run as root")
}
@@ -343,7 +344,7 @@ func main() {
var mux http.ServeMux
ui.Register(&mux)
- if err = pkgserver.Register(ctx, &mux, r); err != nil {
+ if err = pkgsite.Register(ctx, &mux, r); err != nil {
return
}
@@ -504,7 +505,7 @@ func main() {
c.NewCommand(
"daemon",
"Service artifact IR with Rosa OS extensions",
- func(args []string) error {
+ func([]string) error {
ul, err := net.ListenUnix("unix", &addr)
if err != nil {
return err
@@ -514,6 +515,63 @@ func main() {
},
)
+ _ci := c.New("ci", command.UsageInternal)
+ _ci.NewCommand(
+ "daemon",
+ "Service CI workload dispatched through the socket",
+ func([]string) error {
+ return cm.Do(func(cache *pkg.Cache) error {
+ return ci.Serve(ctx, msg, cache)
+ })
+ },
+ )
+ {
+ var flagOutput string
+ _ci.NewCommand(
+ "dist",
+ "Request distribution tarball for the specified source directory",
+ func(args []string) error {
+ if len(args) != 2 {
+ return errors.New("dist requires 2 arguments")
+ }
+
+ return ci.Path(cm.base).MakeDist(
+ ctx,
+ os.Stdout,
+ flagOutput,
+ args[0], args[1],
+ )
+ },
+ ).Flag(
+ &flagOutput,
+ "o", command.StringFlag("."),
+ "Write the resulting distribution to the named directory",
+ )
+ }
+ {
+ var flagOutput string
+ _ci.NewCommand(
+ "race",
+ "Request distribution tarball (race) for the specified source directory",
+ func(args []string) error {
+ if len(args) != 2 {
+ return errors.New("race requires 2 arguments")
+ }
+
+ return ci.Path(cm.base).MakeDistRace(
+ ctx,
+ os.Stdout,
+ flagOutput,
+ args[0], args[1],
+ )
+ },
+ ).Flag(
+ &flagOutput,
+ "o", command.StringFlag("."),
+ "Write the resulting distribution to the named directory",
+ )
+ }
+
c.NewCommand(
"keygen",
"Create keypair for local cache",
diff --git a/cmd/sharefs/testsuite/main.go b/cmd/sharefs/testsuite/main.go
new file mode 100644
index 00000000..167875a1
--- /dev/null
+++ b/cmd/sharefs/testsuite/main.go
@@ -0,0 +1,119 @@
+//go:build testsuite
+
+// The sharefs test program checks cli behaviour and exercises the filesystem
+// implemented by cmd/sharefs using fs_mark.
+package main
+
+import (
+ "errors"
+ "log"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+
+ "hakurei.app/internal/testsuite"
+)
+
+// checkBadOpts invokes cmd/sharefs with the specified options and compares
+// the resulting error message.
+func checkBadOpts(cred *syscall.Credential, opts, want string) {
+ var buf strings.Builder
+ buf.Grow(len(want))
+
+ cmd := exec.Command(
+ "sharefs",
+ "-f",
+ "-o", "source=/etc,"+opts,
+ "/mnt",
+ )
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGKILL,
+ Credential: cred,
+ }
+ cmd.Stderr = &buf
+ err := cmd.Run()
+ if err == nil {
+ log.Fatalf("opts=%q, unexpected success", opts)
+ }
+ if e, ok := errors.AsType[*exec.ExitError](err); !ok {
+ log.Fatal(err)
+ } else if !e.Exited() {
+ log.Fatal(e)
+ }
+
+ if got := buf.String(); got != want {
+ log.Fatalf("opts=%q\n\t got:%q\n\twant:%q", opts, got, want)
+ }
+}
+
+func main() {
+ go testsuite.ReceiveSignals()
+
+ cred := syscall.Credential{Uid: 1000, Gid: 100}
+ if err := os.Mkdir("result", 0755); err != nil {
+ log.Fatal(err)
+ }
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+
+ testsuite.MustRun(
+ nil, nil,
+ "fs_mark",
+ "-v",
+ "-d", "/sdcard/fs_mark",
+ "-l", "result/fs_mark.log",
+ )
+ }()
+
+ log.Println("checking malformed setuid/setgid representation")
+ checkBadOpts(&cred, "setuid=ff", "sharefs: invalid value for option setuid\n")
+ checkBadOpts(&cred, "setgid=ff", "sharefs: invalid value for option setgid\n")
+
+ log.Println("checking bounds check for setuid/setgid")
+ checkBadOpts(&cred, "setuid=0", "sharefs: invalid value for option setuid\n")
+ checkBadOpts(&cred, "setgid=0", "sharefs: invalid value for option setgid\n")
+ checkBadOpts(&cred, "setuid=-1", "sharefs: invalid value for option setuid\n")
+ checkBadOpts(&cred, "setgid=-1", "sharefs: invalid value for option setgid\n")
+
+ log.Println("checking non-root setuid/setgid")
+ checkBadOpts(&cred, "setuid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
+ checkBadOpts(&cred, "setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
+ checkBadOpts(&cred, "setuid=1023,setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
+ checkBadOpts(&cred, "mkdir", "sharefs: mkdir has no effect when not starting as root\n")
+
+ log.Println("checking root without setuid/setgid")
+ checkBadOpts(nil, "allow_other", "sharefs: setuid and setgid must not be 0\n")
+ checkBadOpts(nil, "setuid=1023", "sharefs: setuid and setgid must not be 0\n")
+ checkBadOpts(nil, "setgid=1023", "sharefs: setuid and setgid must not be 0\n")
+
+ log.Println("verifying mount point")
+ if err := os.Remove("/mnt"); err != nil {
+ log.Fatal(err)
+ }
+
+ log.Println("checking unprivileged mount/unmount")
+ testsuite.MustRun(&cred, nil, "mkdir", "/tmp/sdcard", "/tmp/persistent")
+ testsuite.MustRun(&cred, nil, "sharefs", "-o", "source=/tmp/persistent", "/tmp/sdcard")
+ testsuite.MustRun(&cred, nil, "touch", "/tmp/sdcard/check")
+ testsuite.MustRun(&cred, nil, "umount", "/tmp/sdcard")
+ testsuite.MustRun(&cred, nil, "rm", "/tmp/persistent/check")
+ testsuite.MustRun(&cred, nil, "rmdir", "/tmp/sdcard", "/tmp/persistent")
+
+ log.Println("waiting for fs_mark to complete")
+ <-done
+
+ const backingDir = "/var/lib/sdcard"
+ sharefsCred := syscall.Credential{Uid: 1023, Gid: 1023}
+ log.Println("checking permissions")
+ testsuite.MustRun(&sharefsCred, nil, "touch", backingDir+"/fs_mark/.check")
+ testsuite.MustRun(&sharefsCred, nil, "rm", backingDir+"/fs_mark/.check")
+ testsuite.MustRun(&cred, nil, "rm", "-rf", "/sdcard/fs_mark")
+ if _, err := os.ReadDir(backingDir + "/fs_mark"); err == nil {
+ log.Fatal("fs_mark directory was not removed")
+ } else if !errors.Is(err, os.ErrNotExist) {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/sharefs/testsuite/raceattr.go b/cmd/sharefs/testsuite/raceattr.go
new file mode 100644
index 00000000..412cb2b3
--- /dev/null
+++ b/cmd/sharefs/testsuite/raceattr.go
@@ -0,0 +1,122 @@
+//go:build raceattr
+
+// The raceattr program reproduces vfs inode file attribute race.
+//
+// Even though libfuse high-level API presents the address of a struct stat
+// alongside struct fuse_context, file attributes are actually inherent to the
+// inode, instead of the specific call from userspace. The kernel implementation
+// in fs/fuse/xattr.c appears to make stale data in the inode (set by a previous
+// call) impossible or very unlikely to reach userspace via the stat family of
+// syscalls. However, when using default_permissions to have the VFS check
+// permissions, this race still happens, despite the resulting struct stat being
+// correct when overriding the check via capabilities otherwise.
+//
+// This program reproduces the failure, but because of its continuous nature, it
+// is provided independent of the vm integration test suite.
+package main
+
+import (
+ "context"
+ "flag"
+ "log"
+ "os"
+ "os/signal"
+ "runtime"
+ "sync"
+ "sync/atomic"
+ "syscall"
+)
+
+func newStatAs(
+ ctx context.Context, cancel context.CancelFunc,
+ n *atomic.Uint64, ok *atomic.Bool,
+ uid uint32, pathname string,
+ continuous bool,
+) func() {
+ return func() {
+ runtime.LockOSThread()
+ defer cancel()
+
+ if _, _, errno := syscall.Syscall(
+ syscall.SYS_SETUID, uintptr(uid),
+ 0, 0,
+ ); errno != 0 {
+ cancel()
+ log.Printf("cannot set uid to %d: %s", uid, errno)
+ }
+
+ var stat syscall.Stat_t
+ for {
+ if ctx.Err() != nil {
+ return
+ }
+
+ if err := syscall.Lstat(pathname, &stat); err != nil {
+ // SHAREFS_PERM_DIR not world executable, or
+ // SHAREFS_PERM_REG not world readable
+ if !continuous {
+ cancel()
+ }
+ ok.Store(true)
+ log.Printf("uid %d: %v", uid, err)
+ } else if stat.Uid != uid {
+ // appears to be unreachable
+ if !continuous {
+ cancel()
+ }
+ ok.Store(true)
+ log.Printf("got uid %d instead of %d", stat.Uid, uid)
+ }
+ n.Add(1)
+ }
+ }
+}
+
+func main() {
+ log.SetFlags(0)
+ log.SetPrefix("raceattr: ")
+
+ p := flag.String("target", "/sdcard/raceattr", "pathname of test file")
+ u0 := flag.Int("uid0", 1<<10-1, "first uid")
+ u1 := flag.Int("uid1", 1<<10-2, "second uid")
+ count := flag.Int("count", 1, "threads per uid")
+ continuous := flag.Bool("continuous", false, "keep running even after reproduce")
+ flag.Parse()
+
+ if os.Geteuid() != 0 {
+ log.Fatal("this program must run as root")
+ }
+
+ ctx, cancel := signal.NotifyContext(
+ context.Background(),
+ syscall.SIGINT,
+ syscall.SIGTERM,
+ syscall.SIGHUP,
+ )
+
+ if err := os.WriteFile(*p, nil, 0); err != nil {
+ log.Fatal(err)
+ }
+
+ var (
+ wg sync.WaitGroup
+
+ n atomic.Uint64
+ ok atomic.Bool
+ )
+
+ if *count < 1 {
+ *count = 1
+ }
+ for range *count {
+ wg.Go(newStatAs(ctx, cancel, &n, &ok, uint32(*u0), *p, *continuous))
+ if *u1 >= 0 {
+ wg.Go(newStatAs(ctx, cancel, &n, &ok, uint32(*u1), *p, *continuous))
+ }
+ }
+
+ wg.Wait()
+ if !*continuous && ok.Load() {
+ log.Printf("reproduced after %d calls", n.Load())
+ }
+}