1
0
forked from rosa/hakurei

222 Commits

Author SHA1 Message Date
a394971dd7 cmd/mbf: do not abort cache acquisition during testing
This can sometimes fire during testing due to how short the test is.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-17 02:06:51 +09:00
9daba60809 cmd/mbf: daemon command
This services internal/pkg artifact IR with Rosa OS extensions originating from another process.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-17 02:05:59 +09:00
bcd79a22ff cmd/mbf: do not open cache for IR encoding
This can now be allocated independently.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-17 01:04:39 +09:00
0ff7ab915b internal/pkg: move IR primitives out of cache
These are memory management and caching primitives. Having them as part of Cache is cumbersome and requires a temporary directory that is never used. This change isolates them from Cache to enable independent use.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-17 01:02:13 +09:00
823575acac cmd/mbf: move info command
This is cleaner with less shared state.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-16 17:43:52 +09:00
136bc0917b cmd/mbf: optionally open cache
Some commands do not require the cache. This change also makes acquisition of locked cache cancelable.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-16 15:59:34 +09:00
d6b082dd0b internal/rosa/ninja: bootstrap with verbose output
This otherwise outputs nothing, and appears to hang until the (fully single-threaded) bootstrap completes.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:19:05 +09:00
89d6d9576b internal/rosa/make: optionally format value as is
This enables correct formatting for awkward configure scripts.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:17:58 +09:00
fafce04a5d internal/rosa/kernel: firmware 20260309 to 20260410
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:16:47 +09:00
5d760a1db9 internal/rosa/kernel: 6.12.80 to 6.12.81
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:16:30 +09:00
d197e40b2a internal/rosa/python: mako 1.3.10 to 1.3.11
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:21:54 +09:00
2008902247 internal/rosa/python: packaging 26.0 to 26.1
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:15:18 +09:00
30ac985fd2 internal/rosa/meson: 1.10.2 to 1.11.0
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:14:52 +09:00
e9fec368f8 internal/rosa/nss: 3.122 to 3.122.1
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:13:45 +09:00
46add42f58 internal/rosa/openssl: disable building docs
These take very long and are never used in the Rosa OS environment.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:13:18 +09:00
377b61e342 internal/rosa/openssl: do not double test job count
The test suite is racy, this reduces flakiness.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 22:12:36 +09:00
520c36db6d internal/rosa: respect preferred job count
This discontinues use of nproc, and also overrides detection behaviour in ninja.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 18:49:36 +09:00
3352bb975b internal/pkg: job count in container environment
This exposes preferred job count to the container initial process.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 15:49:21 +09:00
f7f48d57e9 internal/pkg: pass impure job count
This is cleaner than checking cpu count during cure, it is impossible to avoid impurity in both situations but this is configurable.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-15 15:36:44 +09:00
5c2345128e internal/rosa/llvm: autodetect stage0 target
This is fine, now that stages beyond stage0 have explicit target.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-14 03:10:26 +09:00
78f9676b1f internal/rosa/llvm: centralise llvm source
This avoids having to sidestep the NewPackage name formatting machinery to take the cache fast path.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-14 03:03:06 +09:00
5b5b676132 internal/rosa/cmake: remove variant
This has no effect outside formatting of name and is a remnant of the old llvm helpers.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-14 02:57:47 +09:00
78383fb6e8 internal/rosa/llvm: migrate libclc
This eliminates newLLVMVariant.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-14 02:40:13 +09:00
e97f6a393f internal/rosa/llvm: migrate runtimes and clang
This eliminates most newLLVM family of functions.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-14 02:07:13 +09:00
eeffefd22b internal/rosa/llvm: migrate compiler-rt helper
This also removes unused dependencies.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-14 01:12:56 +09:00
ac825640ab internal/rosa/llvm: migrate musl
This removes the pointless special treatment given to musl.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-14 00:35:42 +09:00
a7f7ce1795 internal/rosa/llvm: migrate compiler-rt
The newLLVM family of functions predate the package system. This change migrates compiler-rt without changing any resulting artifacts.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-14 00:19:33 +09:00
38c639e35c internal/rosa/llvm: remove project/runtime helper
More remnants from early days, these are not reusable at all but that was not known at the time.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-14 00:03:23 +09:00
b2cb13e94c internal/rosa/llvm: centralise patches
This enables easier reuse of the patchset.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 23:52:44 +09:00
46f98d12d6 internal/rosa/llvm: remove conditional flags in helper
The llvm helper is a remnant from very early days, and ended up not being very useful, but was never removed. This change begins its removal, without changing the resulting artifacts for now.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 23:38:11 +09:00
503c7f953c internal/rosa/x: libpciaccess artifact
Required by userspace gpu drivers.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 19:04:38 +09:00
15c9f6545d internal/rosa/perl: populate anitya identifiers
These are also tracked by Anitya.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 18:44:43 +09:00
83b0e32c55 internal/rosa: helpers for common url formats
This cleans up call site of NewPackage.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 18:02:57 +09:00
eeaf26e7a2 internal/rosa: wrapper around git helper
This results in much cleaner call site for the majority of use cases.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 15:20:51 +09:00
b587caf2e8 internal/rosa: assume file source is xz-compressed
XZ happens to be the only widely-used format that is awful to deal with, everything else is natively supported.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 15:07:30 +09:00
f1c2ca4928 internal/rosa/mesa: libdrm artifact
Required by mesa.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 03:27:09 +09:00
0ca301219f internal/rosa/python: pyyaml artifact
Mesa unfortunately requires this horrible format.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 03:18:47 +09:00
e2199e1276 internal/rosa/python: mako artifact
This unfortunately pulls in platform-specific package.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 03:11:38 +09:00
86eacb3208 cmd/mbf: checksum command
This computes and encodes sha384 checksum of data streamed from standard input.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 03:09:21 +09:00
8541bdd858 internal/rosa: wrap per-arch values
This is cleaner syntax in some specific cases.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 02:59:55 +09:00
46be0b0dc8 internal/rosa/nss: buildcatrust 0.4.0 to 0.5.1
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 02:18:21 +09:00
cbe37e87e7 internal/rosa/python: pytest 9.0.2 to 9.0.3
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 02:18:02 +09:00
66d741fb07 internal/rosa/python: pygments 2.19.2 to 2.20.0
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 02:13:04 +09:00
0d449011f6 internal/rosa/python: use predictable URLs
This is much cleaner and more maintainable than specifying URL prefix manually. This change also populates Anitya project identifiers.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 02:08:22 +09:00
46428ed85d internal/rosa/python: url pip wheel helper
This enables a cleaner higher-level helper.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-13 01:59:28 +09:00
081d6b463c internal/rosa/llvm: libclc artifact
This is built independently of llvm build system to avoid having to build llvm again.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-12 22:57:04 +09:00
11b3171180 internal/rosa/glslang: glslang artifact
Required by mesa.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-12 22:34:17 +09:00
adbb84c3dd internal/rosa/glslang: spirv-tools artifact
Required by glslang.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-12 22:27:49 +09:00
1084e31d95 internal/rosa/glslang: spirv-headers artifact
Required by spirv-tools.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-12 22:16:29 +09:00
27a1b8fe0a internal/rosa/mesa: libglvnd artifact
Required by mesa.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-12 21:27:30 +09:00
b2141a41d7 internal/rosa/dbus: xdg-dbus-proxy artifact
This is currently a hakurei runtime dependency, but will eventually be removed.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-12 19:41:49 +09:00
c0dff5bc87 internal/rosa/gnu: gcc set with-multilib-list as needed
This breaks riscv64.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-12 18:03:45 +09:00
04513c0510 internal/rosa/gnu: gmp explicit CC
The configure script is hard coded to use gcc without fallback on riscv64.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-12 17:25:15 +09:00
28ebf973d6 nix: add sharefs supplementary group
This works around vfs inode file attribute race.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-11 23:28:18 +09:00
41aeb404ec internal/rosa/hakurei: 0.3.7 to 0.4.0
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-11 10:53:29 +09:00
0b1009786f release: 0.4.0
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-11 10:49:43 +09:00
b390640376 internal/landlock: relocate from package container
This is not possible to use directly, so remove it from the public API.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 23:56:45 +09:00
ad2c9f36cd container: unexport PR_SET_NO_NEW_PRIVS wrapper
This is subtle to use correctly. It also does not make sense as part of the container API.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 23:45:51 +09:00
67db3fbb8d check: use encoding interfaces
This turned out not to require specific treatment, so the shared interfaces are cleaner.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 22:11:53 +09:00
560cb626a1 hst: remove enablement json adapter
The go116 behaviour of built-in new function makes this cleaner.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 20:47:30 +09:00
c33a6a5b7e hst: optionally reject insecure options
This prevents inadvertent use of insecure compatibility features.

Closes #30.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 19:34:02 +09:00
952082bd9b internal/rosa/python: 3.14.3 to 3.14.4
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 02:38:22 +09:00
24a9b24823 internal/rosa/openssl: 3.6.1 to 3.6.2
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 02:38:02 +09:00
c2e61e7987 internal/rosa/libcap: 2.77 to 2.78
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 02:37:04 +09:00
86787b3bc5 internal/rosa/tamago: 1.26.1 to 1.26.2
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 02:31:57 +09:00
cdfcfe6ce0 internal/rosa/go: 1.26.1 to 1.26.2
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 02:18:27 +09:00
68a2f0c240 internal/rosa/llvm: remove unused field
This change also renames confusingly named flags field and corrects its doc comment.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 02:13:26 +09:00
7319c7adf9 internal/rosa/llvm: use latest version on arm64
This also removes arch-specific patches because they were not useful.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 01:07:25 +09:00
e9c890cbb2 internal/rosa/llvm: enable cross compilation
This now passes the test suite.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 00:59:14 +09:00
6f924336fc internal/rosa/llvm: increase stack size
Some aarch64 regression tests fail intermittently on the default size.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-10 00:56:51 +09:00
bd88f10524 internal/rosa/llvm: 22.1.2 to 22.1.3
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-09 17:36:23 +09:00
e34e3b917e internal/kobject: process uevent message
This deals with environment variables generally present in every message.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-08 18:00:04 +09:00
b0ba165107 cmd/sharefs: group-accessible permission bits
This works around the race in vfs via supplementary group.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-08 16:14:47 +09:00
351d6c5a35 cmd/sharefs: reproduce vfs inode file attribute race
This happens in the vfs permissions check only and stale data appears to never reach userspace.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-08 15:51:36 +09:00
f23f73701c cmd/mbf: optional host abstract
This works around kernels with Landlock LSM disabled. Does not affect cure outcome.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 18:15:49 +09:00
876917229a internal/rosa/go: enable riscv64 bootstrap path
This is quite expensive, but no other option, unfortunately.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 18:11:42 +09:00
0558032c2d container: do not set static deadline
This usually ends up in the buffer, or completes well before the deadline, however this can still timeout on a very slow system.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 17:00:20 +09:00
c61cdc505f internal/params: relocate from package container
This does not make sense as part of the public API, so make it internal.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 16:37:44 +09:00
062edb3487 container: remove setup pipe helper
The API forces use of finalizer to close the read end of the setup pipe, which is no longer considered acceptable. Exporting this as part of package container also imposes unnecessary maintenance burden.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 16:05:33 +09:00
e4355279a1 all: optionally forbid degrading in tests
This enables transparently degradable tests to be forced on in environments known to support them.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 15:22:52 +09:00
289fdebead container: transparently degrade landlock in tests
Explicitly requiring landlock in tests will be supported in a future change.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 15:03:48 +09:00
9c9e190db9 ldd: remove timeout
The program generally never blocks, and it is more flexible to leave it up to the caller to set a timeout.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 14:49:20 +09:00
d7d42c69a1 internal/pkg: transparently degrade landlock in tests
This does not test package container, so should transparently cope with Landlock LSM being unavailable.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 14:44:34 +09:00
c758e762bd container: skip landlock on hostnet
This overlaps with net namespace, so can be skipped without degrading security.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 14:36:44 +09:00
10f8b1c221 internal/pkg: optional landlock LSM
The alpine linux riscv64 kernel does not enable Landlock LSM, and kernel compilation is not yet feasible.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-07 12:44:07 +09:00
6907700d67 cmd/dist: set hsu tar header mode bits
This has no effect, but is nice to have.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-06 23:37:38 +09:00
0243f3ffbd internal/rosa/stage0: add riscv64 tarball
This had not yet passed all test suites because emulator is prohibitively slow.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-06 13:57:43 +09:00
cd0beeaf8e internal/uevent: optionally pass UUID during coldboot
This enables rejection of non-coldboot synthetic events.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-06 12:42:47 +09:00
a69273ab2a cmd/dist: replace dist/release.sh
This is much more robust than a shell script.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 23:58:08 +09:00
4cd0f57e48 dist: remove redundant cleanup
This breaks on shells that do not evaluate pathnames.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 16:16:37 +09:00
33a0e6c01b hst: conditionally skip root remount
This enables the writable root overlay use case.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 14:04:21 +09:00
d58f5c7590 dist: destroy workdir on exist
This no longer relies on the hermetic build system to clean up.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 13:32:16 +09:00
1da992e342 dist: prefix from environment
These are baked in, so make them configurable for the build.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 13:11:44 +09:00
9641805ec2 container/init: ignore finished process
This is not considered an error, if the process finishes while the signal is being delivered.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 00:38:33 +09:00
0738f4889a internal/rosa/gnu: fetch mpc source via git
Eliminates the xz dependency.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 00:35:15 +09:00
7de3cfe221 internal/rosa/netfilter: fetch iptables source via git
Eliminates the xz dependency.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 00:19:40 +09:00
8b0648dd5d internal/rosa/netfilter: fetch libnftnl source via git
Eliminates the xz dependency.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 00:14:45 +09:00
4667fac76c internal/rosa/libbsd: fetch source via git
Eliminates the xz dependency.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 00:05:56 +09:00
52e5443b0e internal/rosa/libbsd: fetch libmd source via git
Eliminates the xz dependency.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-05 00:02:56 +09:00
130e470b60 internal/rosa/libxslt: fetch source via git
Eliminates the xz dependency.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-04 23:05:10 +09:00
ba5ee8e3ee internal/rosa/libxml2: fetch source via git
Eliminates the xz dependency. This also switches to meson to avoid pulling in autotools.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-04 22:58:29 +09:00
d1cef30877 internal/rosa/gtk: fetch glib source via git
This eliminates xz dependency.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-04 22:47:10 +09:00
0188a3f0c7 internal/rosa/gnu: gnutls disable arm64 hardware acceleration
Hardware on arm64 is quite messy, this miscompiles.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-04 23:37:18 +09:00
04fe3b24ce internal/rosa/gnu: gnutls configure trust store
The test suite is somehow happy on amd64 but fails on arm64.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-04 22:33:51 +09:00
93ad551054 internal/rosa/git: shallow clone
The .git directory is destroyed anyway, so no point fetching more than the bare minimum.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-04 20:14:40 +09:00
3d54d1f176 internal/rosa: drop caches
This enables accurate benchmarking of the toolchain abstraction.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 20:58:23 +09:00
9feac7738f internal/pkg: optionally suppress via assumed checksum
This is quite error-prone and causes cache inconsistency similar to the store inconsistency seen on nix when a similar condition happens. Keep this behind a flag in case it is ever beneficial.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 20:33:59 +09:00
591a60bac9 internal/pkg: per-cache SCHED_IDLE
This is cleaner than setting it globally, and is impossible to race.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 16:49:34 +09:00
5093a06026 internal/pkg: cache flags
This is cleaner for extending the API.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 16:16:55 +09:00
50c1d7f880 internal/rosa/kernel: 6.12.78 to 6.12.80
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 16:14:16 +09:00
9e63633fbc container: remove test timeouts
These timeouts are no longer useful, and causes spurious test failures under load.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 10:51:37 +09:00
61f981a34a internal/rosa/perl: 5.42.1 to 5.42.2
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 10:45:25 +09:00
d717c41bbe internal/rosa/cmake: 4.3.0 to 4.3.1
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 10:45:07 +09:00
b896eec9b7 internal/rosa/gnu: parallel 20260222 to 20260322
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 10:44:48 +09:00
8ab99e5e40 internal/rosa/util-linux: 2.41.3 to 2.42
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 10:44:25 +09:00
2b6160ef7d internal/rosa/wayland: wayland-protocols 1.47 to 1.48
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 10:44:04 +09:00
4dcac7f133 internal/rosa/xz: 5.8.2 to 5.8.3
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 10:43:37 +09:00
966fd4df9e internal/rosa: connman artifact
Will be gradually replaced with a native implementation.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 01:17:10 +09:00
a2cf59b989 internal/rosa/ncurses: also build dynamic library
GNU readline breaks without this.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 01:13:04 +09:00
e87f59c4e4 internal/rosa/gnu: readline artifact
Nice to have library for command line programs.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 00:42:59 +09:00
3b221c3e77 internal/rosa/gnu: gnutls artifact
Incredibly ugly and expensive package, but unfortunately required by some packages.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 00:36:28 +09:00
ff3b385b12 internal/rosa: libunistring artifact
Required by GnuTLS.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-03 00:00:13 +09:00
c6920e6ab7 cmd/mbf: pick up $TERM
This improves behaviour of some programs.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-02 23:59:15 +09:00
59b25d45fe internal/pkg: pick up $TERM if attaching stdin
This improves behaviour of some programs.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-02 23:50:07 +09:00
9b99650eb1 internal/rosa: libev artifact
Required by gnutls.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-02 23:35:01 +09:00
15bff9e1a6 internal/rosa/git: determine reporting name from url
This is generally correct, and is a lot cleaner to call.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-02 22:42:16 +09:00
b948525c07 internal/rosa: nettle3 artifact
Removed after all packages upgrade for nettle 4.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-02 22:12:39 +09:00
9acbd16e9a internal/rosa/p11: explicitly enable libffi
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-02 22:05:07 +09:00
64e5a1068b internal/rosa: libtasn1 artifact
Optional dependency of p11-kit.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-01 17:20:12 +09:00
b6cbd49d8c internal/rosa: p11-kit artifact
Another package distributed in xz only. This is fetched from the git remote directly to avoid XZ Utils.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-01 16:34:30 +09:00
6913b9224a internal/rosa/git: recursively clone submodules
There is generally no reason to disable this, so it was not made optional.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-01 16:21:46 +09:00
9584958ecc internal/rosa/pkg-config: generate build system
This unfortunately pulls automake, libtool and their dependencies into stage2.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-04-01 15:31:24 +09:00
389844b1ea internal/rosa/gnu: mpc 1.3.1 to 1.4.0
This package now unfortunately switched to xz as well.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-31 23:56:20 +09:00
5b7ab35633 internal/rosa: iptables artifact
This also pulls in netlink libraries from netfilter project.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-31 23:47:16 +09:00
52b1a5a725 internal/rosa: use type P in helper interface
This is easier to type and serialises correctly.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-31 23:45:01 +09:00
6b78df8714 internal/rosa: libmd and libbsd artifacts
These provide headers that are provided by glibc but not musl.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-31 22:57:08 +09:00
dadf170a46 internal/rosa: dbus artifact
Unfortunate ugly indirect dependency we cannot yet get rid of.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-31 21:47:05 +09:00
9594832302 internal/rosa/meson: disallow download
This will fail and waste time on KindExec, and cause nondeterminism in KindExecNet.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-31 21:38:09 +09:00
91a2d4d6e1 internal/uevent: integrate error handling in event loop
There are many subtleties when recovering from errors in the event loop, and coldboot requires internals to drain the receive buffer as synthetic uevents are being arranged.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-31 00:10:14 +09:00
a854719b9f internal/netlink: optional recvmsg without netpoll
For draining the socket receive buffer.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-30 23:18:43 +09:00
f03c0fb249 internal/uevent: synthetic events for coldboot
This causes the kernel to regenerate events that happened before earlyinit started.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-30 23:01:08 +09:00
a6600be34a all: use filepath
This makes package check portable, and removes nonportable behaviour from package pkg, pipewire, and system. All other packages remain nonportable due to their nature. No latency increase was observed due to this change on amd64 and arm64 linux.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-30 18:24:53 +09:00
b5592633f5 internal/uevent: separate recvmsg helper
This enables messages to be received separately.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-30 02:53:26 +09:00
584e302168 internal/netlink: set receive buffer size
This is done by both systemd sd-device and AOSP ueventd to improve robustness. Rosa OS will still handle ENOBUFS via coldboot but a big buffer should mitigate this as well.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-30 02:14:47 +09:00
141958656f internal/uevent: handle state divergence
This requires the caller to arrange for a coldboot to happen, some time after this error is encountered, and to resume event processing.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-30 01:50:09 +09:00
648079f42c internal/netlink: switch to recvmsg/sendmsg
These are more flexible than recvfrom/sendto.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-29 23:36:00 +09:00
19c76e0831 cmd: document Rosa OS programs
The earlyinit and mbf program are not covered by the compatibility promise, so specify that here.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 22:43:25 +09:00
71fcc972ba cmd/hsu: alternative hsurc path for Rosa OS
Rosa OS does not have /etc.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 18:22:55 +09:00
62002efd08 cmd/hsu: document hsurc format and internals
This was previously only documented via an unexported function.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 18:17:31 +09:00
e33294db9c cmd/hakurei: document stable behaviour
These are undocumented anywhere else and is required by tools invoking hakurei.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 17:31:46 +09:00
b1ea3b4acf cmd/hakurei: rename app to run
The run command was a legacy holdover from very early days and is only useful for testing and demonstration these days. This change also renames it to exec.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 16:48:26 +09:00
2c254c70b8 cmd/hakurei: remove linkname directive
This used to be a function that did much more, and was later relocated to another package and exported.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 16:20:02 +09:00
ea014d6af2 internal/uevent: consume kernel-originated events
These are not possible to cover outside integration vm. Extreme care is required when dealing with this method, so keep it simple.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 15:39:16 +09:00
1b48484c16 internal/uevent: exclusive socket access
This is a much simplified mutex, since blocking is not required.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 01:01:06 +09:00
713bff3eb0 internal/uevent: decode uevent messages
The wire format and behaviour is entirely undocumented. This is implemented by reading lib/kobject_uevent.c, with testdata collected from the internal/rosa kernel.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 00:49:34 +09:00
30f459e690 internal/uevent: nontrivial errors
These errors are best represented as JSON.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-28 00:07:56 +09:00
8766fddcb3 internal/uevent: recoverable errors
This runs in the Rosa OS init, so recover as much as possible, as otherwise it is likely to require a full system reboot to resume event processing. The caller is responsible for reporting the error.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-27 22:58:16 +09:00
2745602be3 internal/uevent: wrap netlink socket
Unfortunately these messages do not have the same format as rtnetlink.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-27 22:46:18 +09:00
ee22847dde internal/uevent: kobject_action lookup
This is encoded as part of kobject uevent message headers.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-27 22:39:43 +09:00
c61188649b internal/netlink: export generic connection
This enables abstractions around some families to be implemented in a separate package.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-27 19:08:48 +09:00
6a87a96838 internal/rosa/kernel: 6.12.77 to 6.12.78
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-27 10:40:27 +09:00
2548a681e9 internal/rosa: key-value type
This type is used very frequently. The new type is much easier to type and can receive helper methods eventually if needed.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 16:23:15 +09:00
d514d0679f internal/rosa: set PYTHONUNBUFFERED=1
Some python tools try to be clever and buffers output. This makes the build process appear to hang and is quite frustrating. Instead of trying to address this on a case-by-case basis, this is turned off globally for the interpreter.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 15:29:29 +09:00
4407892632 cmd/mbf: optionally enter cure container
This is very useful for troubleshooting failing tests and such. The ephemeral state is cleaned up by internal/pkg.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 15:10:11 +09:00
e661260607 internal/pkg: enter exec container
This enables much easier troubleshooting of failing cures.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 15:05:04 +09:00
044490e0a5 cmd/mbf: retain session by default
This almost never make sense to be turned off.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 14:59:17 +09:00
af038c89ff internal/pkg: collection helper-artifact
This was moved from internal/rosa because it is considered generally useful.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 14:11:10 +09:00
d2f30173cd internal/pkg: isolate container params
This enables exporting container params for interactive troubleshooting within the cure container.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 14:02:58 +09:00
5319ea994c internal/rosa/libseccomp: fix upstream out-of-bounds read
This was revealed by optimisation changes in the latest toolchain.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 10:43:11 +09:00
bbe178be3e internal/rosa/llvm: 22.1.1 to 22.1.2
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 09:56:34 +09:00
ca28e9936b internal/rosa/musl: 1.2.5 to 1.2.6
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 09:56:06 +09:00
f61c6ade56 internal/rosa/nss: 3.121 to 3.122
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 09:55:27 +09:00
fce3d63823 internal/rosa/gnu: autoconf 2.72 to 2.73
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-26 09:54:44 +09:00
722c3cc54f internal/netlink: optional check header as reply
Not every received message is a reply.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-25 19:33:01 +09:00
372d509e5c internal/netlink: expose multicast groups
This also gets rid of the cached pid value for port since that prevents multiple sockets from being open at once.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-25 17:55:35 +09:00
d62516ed1e internal/netlink: enlarge recvfrom buffer
This also uses an array type for the buffer since its size now uses the hardcoded value found in the kernel.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-25 17:18:56 +09:00
d2b635eb55 cmd/mbf: correctly describe --with-toolchain
The behaviour of this was changed to include the stage2 toolchain instead, but the help text was never updated.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-25 15:41:28 +09:00
50403e9d60 internal/netlink: wrap netpoll via context
This removes netpoll boilerplate for the most common use case.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-25 15:39:29 +09:00
b98c5f2e21 internal/netlink: nonblocking socket I/O
This enables use with blocking calls like when used with NETLINK_KOBJECT_UEVENT.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-25 14:06:59 +09:00
d972cffe5a internal/netlink: make full response available
The previous API makes it impossible to retrieve remaining messages in the current iteration.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-23 16:39:25 +09:00
d8648304bb internal/netlink: isolate receive method
This enables use with epoll for receiving events only.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-23 15:03:15 +09:00
f7bfa9a6c2 internal/rosa/go: disable go1.25.7 smtp test
This uses certs that had just expired.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-20 17:52:54 +09:00
7035b4b598 internal/rosa/cmake: 4.2.3 to 4.3.0
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-20 16:39:57 +09:00
094b8400dd internal/rosa/qemu: 10.2.1 to 10.2.2
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-20 16:13:51 +09:00
4652d921d8 internal/rosa/wayland: 1.24.91 to 1.25.0
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-20 16:13:28 +09:00
066213c245 internal/rosa/libexpat: 2.7.4 to 2.7.5
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-20 16:00:50 +09:00
98832c21ee internal/rosa/fuse: 3.18.1 to 3.18.2
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-20 15:57:49 +09:00
6cdb6a652b internal/rosa/gtk: glib 2.87.5 to 2.88.0
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 20:58:39 +09:00
7c932cbceb internal/rosa: strace artifact
This is not part of the system, but a useful development tool. The test suite is quite broken but that is considered acceptable for now.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 20:40:17 +09:00
20ebddd9bf internal/rosa: export source kind
This is set for an exported field, so export the constants as well.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 20:09:44 +09:00
420c721c7d all: raise timeout defaults
This avoids timing out on systems running very slowly.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 17:04:06 +09:00
bac583f89e internal/stub: move from container
This package solves a very specific stubbing use case, in a less than elegant manner.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 16:09:14 +09:00
722989c682 fhs: move from container
This package is not container-specific.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 15:56:36 +09:00
b852402f67 ext: move syscall wrappers from container
These are generally useful, and none of them are container-specific. Syscalls subtle to use and requiring container-specific setup remains in container.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 15:48:40 +09:00
6d015a949e check: move from container
This package is not container specific, and widely used across the project.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 15:39:03 +09:00
e9a72490db vfs: move from container
This package is not container-specific.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 15:30:30 +09:00
0a12d456ce container: set CLOEXEC via close_range
This is guarded behind the close_range build tag for now.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 14:19:00 +09:00
d1fc1a3db7 ext: wrap close_range syscall
This is useful for container when called with CLOSE_RANGE_CLOEXEC.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 14:15:16 +09:00
1c2d5f6b57 ext: integer limit values
For portably using C integers without cgo.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 14:09:38 +09:00
faea1f4bd6 all: remove deprecated packages
Closes #24.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 13:54:56 +09:00
0cb1007daa ldd: remove deprecated API
Closes #25.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 13:53:14 +09:00
e292031624 ext: move lookup test
This was kept in-place to reduce patch size in the previous patch.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 13:44:59 +09:00
cd5959fe5a ext: isolate from container/std
These are too general to belong in the container package. This targets the v0.4 release to reduce the wrapper maintenance burden.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-17 13:39:26 +09:00
08c35ca24f container: use new netlink implementation
This is adapted from the container netlink implementation and is much more reusable.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-16 23:33:52 +09:00
72bd3fb05e internal/netlink: generalise implementation from container
This is useful for uevent implementation.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-16 23:07:51 +09:00
59c66747df internal/rosa/kernel: 6.12.76 to 6.12.77
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-16 15:21:33 +09:00
9e6fe8db4b internal/rosa/meson: 1.10.1 to 1.10.2
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-16 13:41:06 +09:00
5168ee3e13 internal/rosa/python: remove pre_commit
This is unused and introduces many dependencies.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-16 13:39:56 +09:00
c8313c2dc4 internal/rosa/tamago: disable cgo
This toolchain does not support cgo for the new target, anyway, and disabling it altogether avoids adding a dependency on arm64.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-16 13:22:10 +09:00
3fcdadb669 internal/rosa/curl: remove broken test
Upstream testdata is not broken on the arm64 builder, but breaks reproducibly on amd64.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-16 12:54:03 +09:00
3966bc5152 internal/rosa/hakurei: 0.3.6 to 0.3.7
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-15 21:43:55 +09:00
b208af8b85 release: 0.3.7
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-15 21:04:55 +09:00
8d650c0c8f all: migrate to rosa/hakurei
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-15 20:12:51 +09:00
a720efc32d internal/rosa/llvm: arch-specific versions
This enables temporarily avoiding a broken release on specific targets.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-15 15:06:36 +09:00
400540cd41 internal/rosa/llvm: arch-specific patches
Broken aarch64 tests in LLVM seem unlikely to be fixed soon.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-15 11:37:24 +09:00
1113efa5c2 internal/rosa/kernel: enable arm64 block drivers
These are added separately to the amd64 patch due to the arm64 toolchain not being available at that time.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-15 00:22:05 +09:00
8b875f865c cmd/earlyinit: remount root and set firmware path
The default search paths cannot be configured, configuring them here is most sound for now.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-14 19:50:04 +09:00
8905d653ba cmd/earlyinit: mount pseudo-filesystems
The proposal for merging both init programs was unanimously accepted, so this is set up here alongside devtmpfs.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-14 19:43:42 +09:00
9c2fb6246f internal/rosa/kernel: enable FW_LOADER
This wants to be loaded early, so having it as a dlkm is not helpful as it will always be loaded anyway.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-14 19:32:14 +09:00
9c116acec6 internal/rosa/kernel: enable amd64 block drivers
These have to be built into initramfs, anyway, so build them into the kernel instead. The arm64 toolchain is not yet ready, so will be updated in a later patch.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-14 19:22:56 +09:00
988239a2bc internal/rosa: basic system image
This is a simple image for debugging and is not yet set up for dm-verity.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-14 15:54:13 +09:00
bc03118142 cmd/earlyinit: handle args from cmdline
These are set by the bootloader.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-14 15:13:52 +09:00
393 changed files with 23211 additions and 9463 deletions

39
.gitignore vendored
View File

@@ -1,45 +1,14 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
*.pkg
/hakurei
# Test binary, built with `go test -c`
# produced by tools and text editors
*.qcow2
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
.idea
.vscode
# go generate
/cmd/hakurei/LICENSE
/cmd/pkgserver/.sass-cache
/cmd/pkgserver/ui/static/*.js
/cmd/pkgserver/ui/static/*.css*
/cmd/pkgserver/ui/static/*.css.map
/cmd/pkgserver/ui_test/*.js
/cmd/pkgserver/ui_test/lib/*.js
/cmd/pkgserver/ui_test/lib/*.css*
/cmd/pkgserver/ui_test/lib/*.css.map
/internal/pkg/testdata/testtool
/internal/rosa/hakurei_current.tar.gz
# release
/dist/hakurei-*
# interactive nixos vm
nixos.qcow2
# cmd/dist default destination
/dist

View File

@@ -1,5 +1,5 @@
<p align="center">
<a href="https://git.gensokyo.uk/security/hakurei">
<a href="https://git.gensokyo.uk/rosa/hakurei">
<picture>
<img src="https://basement.gensokyo.uk/images/yukari1.png" width="200px" alt="Yukari">
</picture>
@@ -8,16 +8,16 @@
<p align="center">
<a href="https://pkg.go.dev/hakurei.app"><img src="https://pkg.go.dev/badge/hakurei.app.svg" alt="Go Reference" /></a>
<a href="https://git.gensokyo.uk/security/hakurei/actions"><img src="https://git.gensokyo.uk/security/hakurei/actions/workflows/test.yml/badge.svg?branch=staging&style=flat-square" alt="Gitea Workflow Status" /></a>
<a href="https://git.gensokyo.uk/rosa/hakurei/actions"><img src="https://git.gensokyo.uk/rosa/hakurei/actions/workflows/test.yml/badge.svg?branch=staging&style=flat-square" alt="Gitea Workflow Status" /></a>
<br/>
<a href="https://git.gensokyo.uk/security/hakurei/releases"><img src="https://img.shields.io/gitea/v/release/security/hakurei?gitea_url=https%3A%2F%2Fgit.gensokyo.uk&color=purple" alt="Release" /></a>
<a href="https://git.gensokyo.uk/rosa/hakurei/releases"><img src="https://img.shields.io/gitea/v/release/rosa/hakurei?gitea_url=https%3A%2F%2Fgit.gensokyo.uk&color=purple" alt="Release" /></a>
<a href="https://goreportcard.com/report/hakurei.app"><img src="https://goreportcard.com/badge/hakurei.app" alt="Go Report Card" /></a>
<a href="https://hakurei.app"><img src="https://img.shields.io/website?url=https%3A%2F%2Fhakurei.app" alt="Website" /></a>
</p>
Hakurei is a tool for running sandboxed desktop applications as dedicated
subordinate users on the Linux kernel. It implements the application container
of [planterette (WIP)](https://git.gensokyo.uk/security/planterette), a
of [planterette (WIP)](https://git.gensokyo.uk/rosa/planterette), a
self-contained Android-like package manager with modern security features.
Interaction with hakurei happens entirely through structures described by
@@ -62,4 +62,4 @@ are very likely to be rejected.
## NixOS Module (deprecated)
The NixOS module is in maintenance mode and will be removed once planterette is
feature-complete. Full module documentation can be found [here](options.md).
feature-complete. Full module documentation can be found [here](options.md).

6
all.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/sh -e
TOOLCHAIN_VERSION="$(go version)"
cd "$(dirname -- "$0")/"
echo "# Building cmd/dist using ${TOOLCHAIN_VERSION}."
go run -v --tags=dist ./cmd/dist

View File

@@ -2,10 +2,10 @@
package check
import (
"encoding/json"
"encoding"
"errors"
"fmt"
"path"
"path/filepath"
"slices"
"strings"
"syscall"
@@ -30,6 +30,16 @@ func (e AbsoluteError) Is(target error) bool {
// Absolute holds a pathname checked to be absolute.
type Absolute struct{ pathname unique.Handle[string] }
var (
_ encoding.TextAppender = new(Absolute)
_ encoding.TextMarshaler = new(Absolute)
_ encoding.TextUnmarshaler = new(Absolute)
_ encoding.BinaryAppender = new(Absolute)
_ encoding.BinaryMarshaler = new(Absolute)
_ encoding.BinaryUnmarshaler = new(Absolute)
)
// ok returns whether [Absolute] is not the zero value.
func (a *Absolute) ok() bool { return a != nil && *a != (Absolute{}) }
@@ -61,7 +71,7 @@ func (a *Absolute) Is(v *Absolute) bool {
// NewAbs checks pathname and returns a new [Absolute] if pathname is absolute.
func NewAbs(pathname string) (*Absolute, error) {
if !path.IsAbs(pathname) {
if !filepath.IsAbs(pathname) {
return nil, AbsoluteError(pathname)
}
return unsafeAbs(pathname), nil
@@ -76,46 +86,35 @@ func MustAbs(pathname string) *Absolute {
}
}
// Append calls [path.Join] with [Absolute] as the first element.
// Append calls [filepath.Join] with [Absolute] as the first element.
func (a *Absolute) Append(elem ...string) *Absolute {
return unsafeAbs(path.Join(append([]string{a.String()}, elem...)...))
return unsafeAbs(filepath.Join(append([]string{a.String()}, elem...)...))
}
// Dir calls [path.Dir] with [Absolute] as its argument.
func (a *Absolute) Dir() *Absolute { return unsafeAbs(path.Dir(a.String())) }
// Dir calls [filepath.Dir] with [Absolute] as its argument.
func (a *Absolute) Dir() *Absolute { return unsafeAbs(filepath.Dir(a.String())) }
// GobEncode returns the checked pathname.
func (a *Absolute) GobEncode() ([]byte, error) {
return []byte(a.String()), nil
// AppendText appends the checked pathname.
func (a *Absolute) AppendText(data []byte) ([]byte, error) {
return append(data, a.String()...), nil
}
// GobDecode stores data if it represents an absolute pathname.
func (a *Absolute) GobDecode(data []byte) error {
// MarshalText returns the checked pathname.
func (a *Absolute) MarshalText() ([]byte, error) { return a.AppendText(nil) }
// UnmarshalText stores data if it represents an absolute pathname.
func (a *Absolute) UnmarshalText(data []byte) error {
pathname := string(data)
if !path.IsAbs(pathname) {
if !filepath.IsAbs(pathname) {
return AbsoluteError(pathname)
}
a.pathname = unique.Make(pathname)
return nil
}
// MarshalJSON returns a JSON representation of the checked pathname.
func (a *Absolute) MarshalJSON() ([]byte, error) {
return json.Marshal(a.String())
}
// UnmarshalJSON stores data if it represents an absolute pathname.
func (a *Absolute) UnmarshalJSON(data []byte) error {
var pathname string
if err := json.Unmarshal(data, &pathname); err != nil {
return err
}
if !path.IsAbs(pathname) {
return AbsoluteError(pathname)
}
a.pathname = unique.Make(pathname)
return nil
}
func (a *Absolute) AppendBinary(data []byte) ([]byte, error) { return a.AppendText(data) }
func (a *Absolute) MarshalBinary() ([]byte, error) { return a.MarshalText() }
func (a *Absolute) UnmarshalBinary(data []byte) error { return a.UnmarshalText(data) }
// SortAbs calls [slices.SortFunc] for a slice of [Absolute].
func SortAbs(x []*Absolute) {

View File

@@ -11,12 +11,12 @@ import (
"testing"
_ "unsafe" // for go:linkname
. "hakurei.app/container/check"
. "hakurei.app/check"
)
// unsafeAbs returns check.Absolute on any string value.
//
//go:linkname unsafeAbs hakurei.app/container/check.unsafeAbs
//go:linkname unsafeAbs hakurei.app/check.unsafeAbs
func unsafeAbs(pathname string) *Absolute
func TestAbsoluteError(t *testing.T) {
@@ -170,20 +170,20 @@ func TestCodecAbsolute(t *testing.T) {
{"good", MustAbs("/etc"),
nil,
"\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\b\xff\x80\x00\x04/etc",
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\x0f\xff\x84\x01\x04/etc\x01\xfc\xc0\xed\x00\x00\x00",
"\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\b\xff\x80\x00\x04/etc",
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\x0f\xff\x84\x01\x04/etc\x01\xfc\xc0\xed\x00\x00\x00",
`"/etc"`, `{"val":"/etc","magic":3236757504}`},
{"not absolute", nil,
AbsoluteError("etc"),
"\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\a\xff\x80\x00\x03etc",
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\x0f\xff\x84\x01\x03etc\x01\xfb\x01\x81\xda\x00\x00\x00",
"\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\a\xff\x80\x00\x03etc",
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\x0f\xff\x84\x01\x03etc\x01\xfb\x01\x81\xda\x00\x00\x00",
`"etc"`, `{"val":"etc","magic":3236757504}`},
{"zero", nil,
new(AbsoluteError),
"\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\x04\xff\x80\x00\x00",
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\f\xff\x84\x01\x00\x01\xfb\x01\x81\xda\x00\x00\x00",
"\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\x04\xff\x80\x00\x00",
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\f\xff\x84\x01\x00\x01\xfb\x01\x81\xda\x00\x00\x00",
`""`, `{"val":"","magic":3236757504}`},
}
@@ -347,15 +347,6 @@ func TestCodecAbsolute(t *testing.T) {
})
})
}
t.Run("json passthrough", func(t *testing.T) {
t.Parallel()
wantErr := "invalid character ':' looking for beginning of value"
if err := new(Absolute).UnmarshalJSON([]byte(":3")); err == nil || err.Error() != wantErr {
t.Errorf("UnmarshalJSON: error = %v, want %s", err, wantErr)
}
})
}
func TestAbsoluteWrap(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package check_test
import (
"testing"
"hakurei.app/container/check"
"hakurei.app/check"
)
func TestEscapeOverlayDataSegment(t *testing.T) {

View File

@@ -1,11 +1,11 @@
#compdef hakurei
_hakurei_app() {
_hakurei_run() {
__hakurei_files
return $?
}
_hakurei_run() {
_hakurei_exec() {
_arguments \
'--id[Reverse-DNS style Application identifier, leave empty to inherit instance identifier]:id' \
'-a[Application identity]: :_numbers' \
@@ -57,9 +57,9 @@ __hakurei_instances() {
{
local -a _hakurei_cmds
_hakurei_cmds=(
"app:Load and start container from configuration file"
"run:Configure and start a permissive container"
"show:Show live or local app configuration"
"run:Load and start container from configuration file"
"exec:Configure and start a permissive container"
"show:Show live or local instance configuration"
"ps:List active instances"
"version:Display version information"
"license:Show full license text"

237
cmd/dist/main.go vendored Normal file
View File

@@ -0,0 +1,237 @@
//go:build dist
package main
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha512"
_ "embed"
"encoding/hex"
"fmt"
"io"
"io/fs"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
)
// getenv looks up an environment variable, and returns fallback if it is unset.
func getenv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
// mustRun runs a command with the current process's environment and panics
// on error or non-zero exit code.
func mustRun(ctx context.Context, name string, arg ...string) {
cmd := exec.CommandContext(ctx, name, arg...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
panic(err)
}
}
//go:embed comp/_hakurei
var comp []byte
func main() {
fmt.Println()
log.SetFlags(0)
log.SetPrefix("# ")
version := getenv("HAKUREI_VERSION", "untagged")
prefix := getenv("PREFIX", "/usr")
destdir := getenv("DESTDIR", "dist")
if err := os.MkdirAll(destdir, 0755); err != nil {
log.Fatal(err)
}
s, err := os.MkdirTemp(destdir, ".dist.*")
if err != nil {
log.Fatal(err)
}
defer func() {
var code int
if err = os.RemoveAll(s); err != nil {
code = 1
log.Println(err)
}
if r := recover(); r != nil {
code = 1
log.Println(r)
}
os.Exit(code)
}()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
log.Println("Building hakurei.")
mustRun(ctx, "go", "generate", "./...")
mustRun(
ctx, "go", "build",
"-trimpath",
"-v", "-o", s,
"-ldflags=-s -w "+
"-buildid= -linkmode external -extldflags=-static "+
"-X hakurei.app/internal/info.buildVersion="+version+" "+
"-X hakurei.app/internal/info.hakureiPath="+prefix+"/bin/hakurei "+
"-X hakurei.app/internal/info.hsuPath="+prefix+"/bin/hsu "+
"-X main.hakureiPath="+prefix+"/bin/hakurei",
"./...",
)
fmt.Println()
log.Println("Testing Hakurei.")
mustRun(
ctx, "go", "test",
"-ldflags=-buildid= -linkmode external -extldflags=-static",
"./...",
)
fmt.Println()
log.Println("Creating distribution.")
const suffix = ".tar.gz"
distName := "hakurei-" + version + "-" + runtime.GOARCH
var f *os.File
if f, err = os.OpenFile(
filepath.Join(s, distName+suffix),
os.O_CREATE|os.O_EXCL|os.O_WRONLY,
0644,
); err != nil {
panic(err)
}
defer func() {
if f == nil {
return
}
if err = f.Close(); err != nil {
log.Println(err)
}
}()
h := sha512.New()
gw := gzip.NewWriter(io.MultiWriter(f, h))
tw := tar.NewWriter(gw)
mustWriteHeader := func(name string, size int64, mode os.FileMode) {
header := tar.Header{
Name: filepath.Join(distName, name),
Size: size,
Mode: int64(mode),
Uname: "root",
Gname: "root",
}
if mode&os.ModeDir != 0 {
header.Typeflag = tar.TypeDir
fmt.Printf("%s %s\n", mode, name)
} else {
header.Typeflag = tar.TypeReg
fmt.Printf("%s %s (%d bytes)\n", mode, name, size)
}
if err = tw.WriteHeader(&header); err != nil {
panic(err)
}
}
mustWriteFile := func(name string, data []byte, mode os.FileMode) {
mustWriteHeader(name, int64(len(data)), mode)
if mode&os.ModeDir != 0 {
return
}
if _, err = tw.Write(data); err != nil {
panic(err)
}
}
mustWriteFromPath := func(dst, src string, mode os.FileMode) {
var r *os.File
if r, err = os.Open(src); err != nil {
panic(err)
}
var fi os.FileInfo
if fi, err = r.Stat(); err != nil {
_ = r.Close()
panic(err)
}
if mode == 0 {
mode = fi.Mode()
}
mustWriteHeader(dst, fi.Size(), mode)
if _, err = io.Copy(tw, r); err != nil {
_ = r.Close()
panic(err)
} else if err = r.Close(); err != nil {
panic(err)
}
}
mustWriteFile(".", nil, fs.ModeDir|0755)
mustWriteFile("comp/", nil, os.ModeDir|0755)
mustWriteFile("comp/_hakurei", comp, 0644)
mustWriteFile("install.sh", []byte(`#!/bin/sh -e
cd "$(dirname -- "$0")" || exit 1
install -vDm0755 "bin/hakurei" "${DESTDIR}`+prefix+`/bin/hakurei"
install -vDm0755 "bin/sharefs" "${DESTDIR}`+prefix+`/bin/sharefs"
install -vDm4511 "bin/hsu" "${DESTDIR}`+prefix+`/bin/hsu"
if [ ! -f "${DESTDIR}/etc/hsurc" ]; then
install -vDm0400 "hsurc.default" "${DESTDIR}/etc/hsurc"
fi
install -vDm0644 "comp/_hakurei" "${DESTDIR}`+prefix+`/share/zsh/site-functions/_hakurei"
`), 0755)
mustWriteFromPath("README.md", "README.md", 0)
mustWriteFile("hsurc.default", []byte("1000 0"), 0400)
mustWriteFromPath("bin/hsu", filepath.Join(s, "hsu"), 04511)
for _, name := range []string{
"hakurei",
"sharefs",
} {
mustWriteFromPath(
filepath.Join("bin", name),
filepath.Join(s, name),
0,
)
}
if err = tw.Close(); err != nil {
panic(err)
} else if err = gw.Close(); err != nil {
panic(err)
} else if err = f.Close(); err != nil {
panic(err)
}
f = nil
if err = os.WriteFile(
filepath.Join(destdir, distName+suffix+".sha512"),
append(hex.AppendEncode(nil, h.Sum(nil)), " "+distName+suffix+"\n"...),
0644,
); err != nil {
panic(err)
}
if err = os.Rename(
filepath.Join(s, distName+suffix),
filepath.Join(destdir, distName+suffix),
); err != nil {
panic(err)
}
}

View File

@@ -1,9 +1,14 @@
// The earlyinit is part of the Rosa OS initramfs and serves as the system init.
//
// This program is an internal detail of Rosa OS and is not usable on its own.
// It is not covered by the compatibility promise.
package main
import (
"log"
"os"
"runtime"
"strings"
. "syscall"
)
@@ -12,6 +17,22 @@ func main() {
log.SetFlags(0)
log.SetPrefix("earlyinit: ")
var (
option map[string]string
flags []string
)
if len(os.Args) > 1 {
option = make(map[string]string)
for _, s := range os.Args[1:] {
key, value, ok := strings.Cut(s, "=")
if !ok {
flags = append(flags, s)
continue
}
option[key] = value
}
}
if err := Mount(
"devtmpfs",
"/dev/",
@@ -55,4 +76,56 @@ func main() {
}
}
// staying in rootfs, these are no longer used
must(os.Remove("/root"))
must(os.Remove("/init"))
must(os.Mkdir("/proc", 0))
mustSyscall("mount proc", Mount(
"proc",
"/proc",
"proc",
MS_NOSUID|MS_NOEXEC|MS_NODEV,
"hidepid=1",
))
must(os.Mkdir("/sys", 0))
mustSyscall("mount sysfs", Mount(
"sysfs",
"/sys",
"sysfs",
0,
"",
))
// after top level has been set up
mustSyscall("remount root", Mount(
"",
"/",
"",
MS_REMOUNT|MS_BIND|
MS_RDONLY|MS_NODEV|MS_NOSUID|MS_NOEXEC,
"",
))
must(os.WriteFile(
"/sys/module/firmware_class/parameters/path",
[]byte("/system/lib/firmware"),
0,
))
}
// mustSyscall calls [log.Fatalln] if err is non-nil.
func mustSyscall(action string, err error) {
if err != nil {
log.Fatalln("cannot "+action+":", err)
}
}
// must calls [log.Fatal] with err if it is non-nil.
func must(err error) {
if err != nil {
log.Fatal(err)
}
}

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"io"
"log"
@@ -11,12 +12,11 @@ import (
"strconv"
"sync"
"time"
_ "unsafe" // for go:linkname
"hakurei.app/check"
"hakurei.app/command"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/container/std"
"hakurei.app/ext"
"hakurei.app/fhs"
"hakurei.app/hst"
"hakurei.app/internal/dbus"
"hakurei.app/internal/env"
@@ -27,14 +27,20 @@ import (
// optionalErrorUnwrap calls [errors.Unwrap] and returns the resulting value
// if it is not nil, or the original value if it is.
//
//go:linkname optionalErrorUnwrap hakurei.app/container.optionalErrorUnwrap
func optionalErrorUnwrap(err error) error
func optionalErrorUnwrap(err error) error {
if underlyingErr := errors.Unwrap(err); underlyingErr != nil {
return underlyingErr
}
return err
}
var errSuccess = errors.New("success")
func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErrs, out io.Writer) command.Command {
var (
flagVerbose bool
flagJSON bool
flagVerbose bool
flagInsecure bool
flagJSON bool
)
c := command.New(out, log.Printf, "hakurei", func([]string) error {
msg.SwapVerbose(flagVerbose)
@@ -52,6 +58,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
return nil
}).
Flag(&flagVerbose, "v", command.BoolFlag(false), "Increase log verbosity").
Flag(&flagInsecure, "insecure", command.BoolFlag(false), "Allow use of insecure compatibility options").
Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output in JSON when applicable")
c.Command("shim", command.UsageInternal, func([]string) error { outcome.Shim(msg); return errSuccess })
@@ -60,9 +67,9 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
var (
flagIdentifierFile int
)
c.NewCommand("app", "Load and start container from configuration file", func(args []string) error {
c.NewCommand("run", "Load and start container from configuration file", func(args []string) error {
if len(args) < 1 {
log.Fatal("app requires at least 1 argument")
log.Fatal("run requires at least 1 argument")
}
config := tryPath(msg, args[0])
@@ -70,7 +77,12 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
config.Container.Args = append(config.Container.Args, args[1:]...)
}
outcome.Main(ctx, msg, config, flagIdentifierFile)
var flags int
if flagInsecure {
flags |= hst.VAllowInsecure
}
outcome.Main(ctx, msg, config, flags, flagIdentifierFile)
panic("unreachable")
}).
Flag(&flagIdentifierFile, "identifier-fd", command.IntFlag(-1),
@@ -98,7 +110,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
flagWayland, flagX11, flagDBus, flagPipeWire, flagPulse bool
)
c.NewCommand("run", "Configure and start a permissive container", func(args []string) error {
c.NewCommand("exec", "Configure and start a permissive container", func(args []string) error {
if flagIdentity < hst.IdentityStart || flagIdentity > hst.IdentityEnd {
log.Fatalf("identity %d out of range", flagIdentity)
}
@@ -140,7 +152,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
}
}
var et hst.Enablement
var et hst.Enablements
if flagWayland {
et |= hst.EWayland
}
@@ -158,7 +170,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
ID: flagID,
Identity: flagIdentity,
Groups: flagGroups,
Enablements: hst.NewEnablements(et),
Enablements: &et,
Container: &hst.ContainerConfig{
Filesystem: []hst.FilesystemConfigJSON{
@@ -186,7 +198,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
); err != nil {
log.Fatal(err)
}
config.SchedPriority = std.Int(flagSchedPriority)
config.SchedPriority = ext.Int(flagSchedPriority)
// bind GPU stuff
if et&(hst.EX11|hst.EWayland) != 0 {
@@ -277,7 +289,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
}
}
outcome.Main(ctx, msg, &config, -1)
outcome.Main(ctx, msg, &config, 0, -1)
panic("unreachable")
}).
Flag(&flagDBusConfigSession, "dbus-config", command.StringFlag("builtin"),
@@ -323,7 +335,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
flagShort bool
flagNoStore bool
)
c.NewCommand("show", "Show live or local app configuration", func(args []string) error {
c.NewCommand("show", "Show live or local instance configuration", func(args []string) error {
switch len(args) {
case 0: // system
printShowSystem(os.Stdout, flagShort, flagJSON)

View File

@@ -20,12 +20,12 @@ func TestHelp(t *testing.T) {
}{
{
"main", []string{}, `
Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS]
Usage: hakurei [-h | --help] [-v] [--insecure] [--json] COMMAND [OPTIONS]
Commands:
app Load and start container from configuration file
run Configure and start a permissive container
show Show live or local app configuration
run Load and start container from configuration file
exec Configure and start a permissive container
show Show live or local instance configuration
ps List active instances
version Display version information
license Show full license text
@@ -35,8 +35,8 @@ Commands:
`,
},
{
"run", []string{"run", "-h"}, `
Usage: hakurei run [-h | --help] [--dbus-config <value>] [--dbus-system <value>] [--mpris] [--dbus-log] [--id <value>] [-a <int>] [-g <value>] [-d <value>] [-u <value>] [--policy <value>] [--priority <int>] [--private-runtime] [--private-tmpdir] [--wayland] [-X] [--dbus] [--pipewire] [--pulse] COMMAND [OPTIONS]
"exec", []string{"exec", "-h"}, `
Usage: hakurei exec [-h | --help] [--dbus-config <value>] [--dbus-system <value>] [--mpris] [--dbus-log] [--id <value>] [-a <int>] [-g <value>] [-d <value>] [-u <value>] [--policy <value>] [--priority <int>] [--private-runtime] [--private-tmpdir] [--wayland] [-X] [--dbus] [--pipewire] [--pulse] COMMAND [OPTIONS]
Flags:
-X Enable direct connection to X11

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"hakurei.app/container/stub"
"hakurei.app/internal/stub"
)
func TestDecodeJSON(t *testing.T) {

View File

@@ -1,8 +1,42 @@
// Hakurei runs user-specified containers as subordinate users.
//
// This program is generally invoked by another, higher level program, which
// creates container configuration via package [hst] or an implementation of it.
//
// The parent may leave files open and specify their file descriptor for various
// uses. In these cases, standard streams and netpoll files are treated as
// invalid file descriptors and rejected. All string representations must be in
// decimal.
//
// When specifying a [hst.Config] JSON stream or file to the run subcommand, the
// argument "-" is equivalent to stdin. Otherwise, file descriptor rules
// described above applies. Invalid file descriptors are treated as file names
// in their string representation, with the exception that if a netpoll file
// descriptor is attempted, the program fails.
//
// The flag --identifier-fd can be optionally specified to the run subcommand to
// receive the identifier of the newly started instance. File descriptor rules
// described above applies, and the file must be writable. This is sent after
// its state is made available, so the client must not attempt to poll for it.
// This uses the internal binary format of [hst.ID].
//
// For the show and ps subcommands, the flag --json can be applied to the main
// hakurei command to serialise output in JSON when applicable. Additionally,
// the flag --short targeting each subcommand is used to omit some information
// in both JSON and user-facing output. Only JSON-encoded output is covered
// under the compatibility promise.
//
// A template for [hst.Config] demonstrating all available configuration fields
// is returned by [hst.Template]. The JSON-encoded equivalent of this can be
// obtained via the template subcommand. Fields left unpopulated in the template
// (the direct_* family of fields, which are insecure under any configuration if
// enabled) are unsupported.
//
// For simple (but insecure) testing scenarios, the exec subcommand can be used
// to generate a simple, permissive configuration in-memory. See its help
// message for all available options.
package main
// this works around go:embed '..' limitation
//go:generate cp ../../LICENSE .
import (
"context"
_ "embed"
@@ -13,15 +47,13 @@ import (
"syscall"
"hakurei.app/container"
"hakurei.app/ext"
"hakurei.app/message"
)
var (
errSuccess = errors.New("success")
//go:embed LICENSE
license string
)
//go:generate cp ../../LICENSE .
//go:embed LICENSE
var license string
// earlyHardeningErrs are errors collected while setting up early hardening feature.
type earlyHardeningErrs struct{ yamaLSM, dumpable error }
@@ -30,13 +62,13 @@ func main() {
// early init path, skips root check and duplicate PR_SET_DUMPABLE
container.TryArgv0(nil)
log.SetPrefix("hakurei: ")
log.SetFlags(0)
log.SetPrefix("hakurei: ")
msg := message.New(log.Default())
early := earlyHardeningErrs{
yamaLSM: container.SetPtracer(0),
dumpable: container.SetDumpable(container.SUID_DUMP_DISABLE),
yamaLSM: ext.SetPtracer(0),
dumpable: ext.SetDumpable(ext.SUID_DUMP_DISABLE),
}
if os.Geteuid() == 0 {

View File

@@ -17,8 +17,9 @@ import (
)
// tryPath attempts to read [hst.Config] from multiple sources.
// tryPath reads from [os.Stdin] if name has value "-".
// Otherwise, name is passed to tryFd, and if that returns nil, name is passed to [os.Open].
//
// tryPath reads from [os.Stdin] if name has value "-". Otherwise, name is
// passed to tryFd, and if that returns nil, name is passed to [os.Open].
func tryPath(msg message.Msg, name string) (config *hst.Config) {
var r io.ReadCloser
config = new(hst.Config)
@@ -46,7 +47,8 @@ func tryPath(msg message.Msg, name string) (config *hst.Config) {
return
}
// tryFd returns a [io.ReadCloser] if name represents an integer corresponding to a valid file descriptor.
// tryFd returns a [io.ReadCloser] if name represents an integer corresponding
// to a valid file descriptor.
func tryFd(msg message.Msg, name string) io.ReadCloser {
if v, err := strconv.Atoi(name); err != nil {
if !errors.Is(err, strconv.ErrSyntax) {
@@ -60,7 +62,12 @@ func tryFd(msg message.Msg, name string) io.ReadCloser {
msg.Verbosef("trying config stream from %d", v)
fd := uintptr(v)
if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETFD, 0); errno != 0 {
if _, _, errno := syscall.Syscall(
syscall.SYS_FCNTL,
fd,
syscall.F_GETFD,
0,
); errno != 0 {
if errors.Is(errno, syscall.EBADF) { // reject bad fd
return nil
}
@@ -75,10 +82,12 @@ func tryFd(msg message.Msg, name string) io.ReadCloser {
}
}
// shortLengthMin is the minimum length a short form identifier can have and still be interpreted as an identifier.
// shortLengthMin is the minimum length a short form identifier can have and
// still be interpreted as an identifier.
const shortLengthMin = 1 << 3
// shortIdentifier returns an eight character short representation of [hst.ID] from its random bytes.
// shortIdentifier returns an eight character short representation of [hst.ID]
// from its random bytes.
func shortIdentifier(id *hst.ID) string {
return shortIdentifierString(id.String())
}
@@ -88,7 +97,8 @@ func shortIdentifierString(s string) string {
return s[len(hst.ID{}) : len(hst.ID{})+shortLengthMin]
}
// tryIdentifier attempts to match [hst.State] from a [hex] representation of [hst.ID] or a prefix of its lower half.
// tryIdentifier attempts to match [hst.State] from a [hex] representation of
// [hst.ID] or a prefix of its lower half.
func tryIdentifier(msg message.Msg, name string, s *store.Store) *hst.State {
const (
likeShort = 1 << iota
@@ -96,7 +106,8 @@ func tryIdentifier(msg message.Msg, name string, s *store.Store) *hst.State {
)
var likely uintptr
if len(name) >= shortLengthMin && len(name) <= len(hst.ID{}) { // half the hex representation
// half the hex representation
if len(name) >= shortLengthMin && len(name) <= len(hst.ID{}) {
// cannot safely decode here due to unknown alignment
for _, c := range name {
if c >= '0' && c <= '9' {

View File

@@ -6,7 +6,7 @@ import (
"testing"
"time"
"hakurei.app/container/check"
"hakurei.app/check"
"hakurei.app/hst"
"hakurei.app/internal/store"
"hakurei.app/message"

View File

@@ -56,7 +56,7 @@ func printShowInstance(
t := newPrinter(output)
defer t.MustFlush()
if err := config.Validate(); err != nil {
if err := config.Validate(hst.VAllowInsecure); err != nil {
valid = false
if m, ok := message.GetMessage(err); ok {
mustPrint(output, "Error: "+m+"!\n\n")

View File

@@ -7,7 +7,7 @@ import (
"testing"
"time"
"hakurei.app/container/check"
"hakurei.app/check"
"hakurei.app/hst"
"hakurei.app/internal/store"
"hakurei.app/message"
@@ -32,7 +32,7 @@ var (
PID: 0xbeef,
ShimPID: 0xcafe,
Config: &hst.Config{
Enablements: hst.NewEnablements(hst.EWayland | hst.EPipeWire),
Enablements: new(hst.EWayland | hst.EPipeWire),
Identity: 1,
Container: &hst.ContainerConfig{
Shell: check.MustAbs("/bin/sh"),

7
cmd/hsu/conf.go Normal file
View File

@@ -0,0 +1,7 @@
//go:build !rosa
package main
// hsuConfPath is an absolute pathname to the hsu configuration file. Its
// contents are interpreted by parseConfig.
const hsuConfPath = "/etc/hsurc"

7
cmd/hsu/config_rosa.go Normal file
View File

@@ -0,0 +1,7 @@
//go:build rosa
package main
// hsuConfPath is the pathname to the hsu configuration file, specific to
// Rosa OS. Its contents are interpreted by parseConfig.
const hsuConfPath = "/system/etc/hsurc"

View File

@@ -1,6 +1,6 @@
package main
/* copied from hst and must never be changed */
/* keep in sync with hst */
const (
userOffset = 100000

View File

@@ -1,13 +1,64 @@
// hsu starts the hakurei shim as the target subordinate user.
//
// The hsu program must be installed with the setuid and setgid bit set, and
// owned by root. A configuration file must be installed at /etc/hsurc with
// permission bits 0400, and owned by root. Each line of the file specifies a
// hakurei userid to kernel uid mapping. A line consists of the decimal string
// representation of the uid of the user wishing to start hakurei containers,
// followed by a space, followed by the decimal string representation of its
// userid. Duplicate uid entries are ignored, with the first occurrence taking
// effect.
//
// For example, to map the kernel uid 1000 to the hakurei user id 0:
//
// 1000 0
//
// # Internals
//
// Hakurei and hsu holds pathnames pointing to each other set at link time. For
// this reason, a distribution of hakurei has fixed installation prefix. Since
// this program is never invoked by the user, behaviour described in the
// following paragraphs are considered an internal detail and not covered by the
// compatibility promise.
//
// After checking credentials, hsu checks via /proc/ the absolute pathname of
// its parent process, and fails if it does not match the hakurei pathname set
// at link time. This is not a security feature: the priv-side is considered
// trusted, and this feature makes no attempt to address the racy nature of
// querying /proc/, or debuggers attached to the parent process. Instead, this
// aims to discourage misuse and reduce confusion if the user accidentally
// stumbles upon this program. It also prevents accidental use of the incorrect
// installation of hsu in some environments.
//
// Since target container environment variables are set up in shim via the
// [container] infrastructure, the environment is used for parameters from the
// parent process.
//
// HAKUREI_SHIM specifies a single byte between '3' and '9' representing the
// setup pipe file descriptor. It is passed as is to the shim process and is the
// only value in the environment of the shim process. Since hsurc is not
// accessible to the parent process, leaving this unset causes hsu to print the
// corresponding hakurei user id of the parent and terminate.
//
// HAKUREI_IDENTITY specifies the identity of the instance being started and is
// used to produce the kernel uid alongside hakurei user id looked up from hsurc.
//
// HAKUREI_GROUPS specifies supplementary groups to inherit from the credentials
// of the parent process in a ' ' separated list of decimal string
// representations of gid. This has the unfortunate consequence of allowing
// users mapped via hsurc to effectively drop group membership, so special care
// must be taken to ensure this does not lead to an increase in access. This is
// not applicable to Rosa OS since unsigned code execution is not permitted
// outside hakurei containers, and is generally nonapplicable to the security
// model of hakurei, where all untrusted code runs within containers.
package main
// minimise imports to avoid inadvertently calling init or global variable functions
import (
"bytes"
"fmt"
"log"
"os"
"path"
"path/filepath"
"runtime"
"slices"
"strconv"
@@ -16,10 +67,13 @@ import (
)
const (
// envIdentity is the name of the environment variable holding a
// single byte representing the shim setup pipe file descriptor.
// envShim is the name of the environment variable holding a single byte
// representing the shim setup pipe file descriptor.
envShim = "HAKUREI_SHIM"
// envGroups holds a ' ' separated list of string representations of
// envIdentity is the name of the environment variable holding a decimal
// string representation of the current application identity.
envIdentity = "HAKUREI_IDENTITY"
// envGroups holds a ' ' separated list of decimal string representations of
// supplementary group gid. Membership requirements are enforced.
envGroups = "HAKUREI_GROUPS"
)
@@ -35,7 +89,6 @@ func main() {
log.SetFlags(0)
log.SetPrefix("hsu: ")
log.SetOutput(os.Stderr)
if os.Geteuid() != 0 {
log.Fatal("this program must be owned by uid 0 and have the setuid bit set")
@@ -49,13 +102,13 @@ func main() {
log.Fatal("this program must not be started by root")
}
if !path.IsAbs(hakureiPath) {
if !filepath.IsAbs(hakureiPath) {
log.Fatal("this program is compiled incorrectly")
return
}
var toolPath string
pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe")
pexe := filepath.Join("/proc", strconv.Itoa(os.Getppid()), "exe")
if p, err := os.Readlink(pexe); err != nil {
log.Fatalf("cannot read parent executable path: %v", err)
} else if strings.HasSuffix(p, " (deleted)") {
@@ -99,8 +152,6 @@ func main() {
// last possible uid outcome
uidEnd = 999919999
)
// cast to int for use with library functions
uid := int(toUser(userid, identity))
// final bounds check to catch any bugs
@@ -136,7 +187,6 @@ func main() {
}
// careful! users in the allowlist is effectively allowed to drop groups via hsu
if err := syscall.Setresgid(uid, uid, uid); err != nil {
log.Fatalf("cannot set gid: %v", err)
}
@@ -146,10 +196,21 @@ func main() {
if err := syscall.Setresuid(uid, uid, uid); err != nil {
log.Fatalf("cannot set uid: %v", err)
}
if _, _, errno := syscall.AllThreadsSyscall(syscall.SYS_PRCTL, PR_SET_NO_NEW_PRIVS, 1, 0); errno != 0 {
if _, _, errno := syscall.AllThreadsSyscall(
syscall.SYS_PRCTL,
PR_SET_NO_NEW_PRIVS, 1,
0,
); errno != 0 {
log.Fatalf("cannot set no_new_privs flag: %s", errno.Error())
}
if err := syscall.Exec(toolPath, []string{"hakurei", "shim"}, []string{envShim + "=" + shimSetupFd}); err != nil {
if err := syscall.Exec(toolPath, []string{
"hakurei",
"shim",
}, []string{
envShim + "=" + shimSetupFd,
}); err != nil {
log.Fatalf("cannot start shim: %v", err)
}

View File

@@ -18,8 +18,9 @@ const (
useridEnd = useridStart + rangeSize - 1
)
// parseUint32Fast parses a string representation of an unsigned 32-bit integer value
// using the fast path only. This limits the range of values it is defined in.
// parseUint32Fast parses a string representation of an unsigned 32-bit integer
// value using the fast path only. This limits the range of values it is defined
// in but is perfectly adequate for this use case.
func parseUint32Fast(s string) (uint32, error) {
sLen := len(s)
if sLen < 1 {
@@ -40,12 +41,14 @@ func parseUint32Fast(s string) (uint32, error) {
return n, nil
}
// parseConfig reads a list of allowed users from r until it encounters puid or [io.EOF].
// parseConfig reads a list of allowed users from r until it encounters puid or
// [io.EOF].
//
// Each line of the file specifies a hakurei userid to kernel uid mapping. A line consists
// of the string representation of the uid of the user wishing to start hakurei containers,
// followed by a space, followed by the string representation of its userid. Duplicate uid
// entries are ignored, with the first occurrence taking effect.
// Each line of the file specifies a hakurei userid to kernel uid mapping. A
// line consists of the string representation of the uid of the user wishing to
// start hakurei containers, followed by a space, followed by the string
// representation of its userid. Duplicate uid entries are ignored, with the
// first occurrence taking effect.
//
// All string representations are parsed by calling parseUint32Fast.
func parseConfig(r io.Reader, puid uint32) (userid uint32, ok bool, err error) {
@@ -81,10 +84,6 @@ func parseConfig(r io.Reader, puid uint32) (userid uint32, ok bool, err error) {
return useridEnd + 1, false, s.Err()
}
// hsuConfPath is an absolute pathname to the hsu configuration file.
// Its contents are interpreted by parseConfig.
const hsuConfPath = "/etc/hsurc"
// mustParseConfig calls parseConfig to interpret the contents of hsuConfPath,
// terminating the program if an error is encountered, the syntax is incorrect,
// or the current user is not authorised to use hsu because its uid is missing.
@@ -112,10 +111,6 @@ func mustParseConfig(puid int) (userid uint32) {
return
}
// envIdentity is the name of the environment variable holding a
// string representation of the current application identity.
var envIdentity = "HAKUREI_IDENTITY"
// mustReadIdentity calls parseUint32Fast to interpret the value stored in envIdentity,
// terminating the program if the value is not set, malformed, or out of bounds.
func mustReadIdentity() uint32 {

94
cmd/mbf/cache.go Normal file
View File

@@ -0,0 +1,94 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"hakurei.app/check"
"hakurei.app/internal/pkg"
"hakurei.app/message"
)
// cache refers to an instance of [pkg.Cache] that might be open.
type cache struct {
ctx context.Context
msg message.Msg
// Should generally not be used directly.
c *pkg.Cache
cures, jobs int
hostAbstract, idle bool
base string
}
// open opens the underlying [pkg.Cache].
func (cache *cache) open() (err error) {
if cache.c != nil {
return os.ErrInvalid
}
if cache.base == "" {
cache.base = "cache"
}
var base *check.Absolute
if cache.base, err = filepath.Abs(cache.base); err != nil {
return
} else if base, err = check.NewAbs(cache.base); err != nil {
return
}
var flags int
if cache.idle {
flags |= pkg.CSchedIdle
}
if cache.hostAbstract {
flags |= pkg.CHostAbstract
}
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-cache.ctx.Done():
if testing.Testing() {
return
}
os.Exit(2)
case <-done:
return
}
}()
cache.msg.Verbosef("opening cache at %s", base)
cache.c, err = pkg.Open(
cache.ctx,
cache.msg,
flags,
cache.cures,
cache.jobs,
base,
)
return
}
// Close closes the underlying [pkg.Cache] if it is open.
func (cache *cache) Close() {
if cache.c != nil {
cache.c.Close()
}
}
// Do calls f on the underlying cache and returns its error value.
func (cache *cache) Do(f func(cache *pkg.Cache) error) error {
if cache.c == nil {
if err := cache.open(); err != nil {
return err
}
}
return f(cache.c)
}

37
cmd/mbf/cache_test.go Normal file
View File

@@ -0,0 +1,37 @@
package main
import (
"log"
"os"
"testing"
"hakurei.app/internal/pkg"
"hakurei.app/message"
)
func TestCache(t *testing.T) {
t.Parallel()
cm := cache{
ctx: t.Context(),
msg: message.New(log.New(os.Stderr, "check: ", 0)),
base: t.TempDir(),
hostAbstract: true, idle: true,
}
defer cm.Close()
cm.Close()
if err := cm.open(); err != nil {
t.Fatalf("open: error = %v", err)
}
if err := cm.open(); err != os.ErrInvalid {
t.Errorf("(duplicate) open: error = %v", err)
}
if err := cm.Do(func(cache *pkg.Cache) error {
return cache.Scrub(0)
}); err != nil {
t.Errorf("Scrub: error = %v", err)
}
}

151
cmd/mbf/daemon.go Normal file
View File

@@ -0,0 +1,151 @@
package main
import (
"context"
"errors"
"io"
"log"
"net"
"os"
"sync"
"testing"
"time"
"hakurei.app/check"
"hakurei.app/internal/pkg"
)
// daemonTimeout is the maximum amount of time cureFromIR will wait on I/O.
const daemonTimeout = 30 * time.Second
// daemonDeadline returns the deadline corresponding to daemonTimeout, or the
// zero value when running in a test.
func daemonDeadline() time.Time {
if testing.Testing() {
return time.Time{}
}
return time.Now().Add(daemonTimeout)
}
// cureFromIR services an IR curing request.
func cureFromIR(
ctx context.Context,
cache *pkg.Cache,
conn net.Conn,
) (pkg.Artifact, error) {
go func() {
<-ctx.Done()
_ = conn.SetDeadline(time.Now())
}()
if err := conn.SetReadDeadline(daemonDeadline()); err != nil {
return nil, errors.Join(err, conn.Close())
}
a, decodeErr := cache.NewDecoder(conn).Decode()
if decodeErr != nil {
_, err := conn.Write([]byte("\x00" + decodeErr.Error()))
return nil, errors.Join(decodeErr, err, conn.Close())
}
pathname, _, cureErr := cache.Cure(a)
if err := conn.SetWriteDeadline(daemonDeadline()); err != nil {
if !testing.Testing() || !errors.Is(err, io.ErrClosedPipe) {
return a, errors.Join(err, conn.Close())
}
}
if cureErr != nil {
_, err := conn.Write([]byte("\x00" + cureErr.Error()))
return a, errors.Join(cureErr, err, conn.Close())
}
_, err := conn.Write([]byte(pathname.String()))
if testing.Testing() && errors.Is(err, io.ErrClosedPipe) {
return a, nil
}
return a, errors.Join(err, conn.Close())
}
// serve services connections from a [net.UnixListener].
func serve(ctx context.Context, log *log.Logger, cm *cache, ul *net.UnixListener) error {
ul.SetUnlinkOnClose(true)
if cm.c == nil {
if err := cm.open(); err != nil {
return errors.Join(err, ul.Close())
}
}
var wg sync.WaitGroup
defer wg.Wait()
wg.Go(func() {
for {
if ctx.Err() != nil {
break
}
conn, err := ul.AcceptUnix()
if err != nil {
if !errors.Is(err, os.ErrDeadlineExceeded) {
log.Println(err)
}
continue
}
wg.Go(func() {
if a, _err := cureFromIR(ctx, cm.c, conn); _err != nil {
log.Println(_err)
} else {
log.Printf(
"fulfilled artifact %s",
pkg.Encode(cm.c.Ident(a).Value()),
)
}
})
}
})
<-ctx.Done()
if err := ul.SetDeadline(time.Now()); err != nil {
return errors.Join(err, ul.Close())
}
wg.Wait()
return ul.Close()
}
// cureRemote cures a [pkg.Artifact] on a daemon.
func cureRemote(
ctx context.Context,
addr *net.UnixAddr,
a pkg.Artifact,
) (*check.Absolute, error) {
conn, err := net.DialUnix("unix", nil, addr)
if err != nil {
return nil, err
}
go func() {
<-ctx.Done()
_ = conn.SetDeadline(time.Now())
}()
if err = pkg.NewIR().EncodeAll(conn, a); err != nil {
return nil, errors.Join(err, conn.Close())
} else if err = conn.CloseWrite(); err != nil {
return nil, errors.Join(err, conn.Close())
}
payload, recvErr := io.ReadAll(conn)
if err = errors.Join(recvErr, conn.Close()); err != nil {
if errors.Is(err, os.ErrDeadlineExceeded) {
err = ctx.Err()
}
return nil, err
}
if len(payload) > 0 && payload[0] == 0 {
return nil, errors.New(string(payload[1:]))
}
var p *check.Absolute
p, err = check.NewAbs(string(payload))
return p, err
}

125
cmd/mbf/daemon_test.go Normal file
View File

@@ -0,0 +1,125 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"log"
"net"
"os"
"path/filepath"
"testing"
"time"
"hakurei.app/check"
"hakurei.app/internal/pkg"
"hakurei.app/message"
)
func TestCureFromIR(t *testing.T) {
t.Parallel()
if !daemonDeadline().IsZero() {
t.Fatal("daemonDeadline did not return the zero value")
}
c, err := pkg.Open(
t.Context(),
message.New(log.New(os.Stderr, "cir: ", 0)),
0, 0, 0,
check.MustAbs(t.TempDir()),
)
if err != nil {
t.Fatalf("Open: error = %v", err)
}
defer c.Close()
client, server := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
go func() {
<-t.Context().Done()
if _err := client.SetDeadline(time.Now()); _err != nil && !errors.Is(_err, io.ErrClosedPipe) {
panic(_err)
}
}()
if _err := c.EncodeAll(
client,
pkg.NewFile("check", []byte{0}),
); _err != nil {
panic(_err)
} else if _err = client.Close(); _err != nil {
panic(_err)
}
}()
a, cureErr := cureFromIR(t.Context(), c, server)
if cureErr != nil {
t.Fatalf("cureFromIR: error = %v", cureErr)
}
<-done
wantIdent := pkg.MustDecode("fiZf-ZY_Yq6qxJNrHbMiIPYCsGkUiKCRsZrcSELXTqZWtCnESlHmzV5ThhWWGGYG")
if gotIdent := c.Ident(a).Value(); gotIdent != wantIdent {
t.Errorf(
"cureFromIR: %s, want %s",
pkg.Encode(gotIdent), pkg.Encode(wantIdent),
)
}
}
func TestDaemon(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "daemon: ", 0)
addr := net.UnixAddr{
Name: filepath.Join(t.TempDir(), "daemon"),
Net: "unix",
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
cm := cache{
ctx: ctx,
msg: message.New(logger),
base: t.TempDir(),
}
defer cm.Close()
ul, err := net.ListenUnix("unix", &addr)
if err != nil {
t.Fatalf("ListenUnix: error = %v", err)
}
done := make(chan struct{})
go func() {
defer close(done)
if _err := serve(ctx, logger, &cm, ul); _err != nil {
panic(_err)
}
}()
var p *check.Absolute
p, err = cureRemote(ctx, &addr, pkg.NewFile("check", []byte{0}))
if err != nil {
t.Fatalf("cureRemote: error = %v", err)
}
cancel()
<-done
const want = "fiZf-ZY_Yq6qxJNrHbMiIPYCsGkUiKCRsZrcSELXTqZWtCnESlHmzV5ThhWWGGYG"
if got := filepath.Base(p.String()); got != want {
t.Errorf("cureRemote: %s, want %s", got, want)
}
const wantLog = `daemon: fulfilled artifact fiZf-ZY_Yq6qxJNrHbMiIPYCsGkUiKCRsZrcSELXTqZWtCnESlHmzV5ThhWWGGYG
`
if gotLog := buf.String(); gotLog != wantLog {
t.Errorf("serve: logged\n%s\nwant\n%s", gotLog, wantLog)
}
}

127
cmd/mbf/info.go Normal file
View File

@@ -0,0 +1,127 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"strings"
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
)
// commandInfo implements the info subcommand.
func commandInfo(
cm *cache,
args []string,
w io.Writer,
writeStatus bool,
reportPath string,
) (err error) {
if len(args) == 0 {
return errors.New("info requires at least 1 argument")
}
var r *rosa.Report
if reportPath != "" {
if r, err = rosa.OpenReport(reportPath); err != nil {
return err
}
defer func() {
if closeErr := r.Close(); err == nil {
err = closeErr
}
}()
defer r.HandleAccess(&err)()
}
// recovered by HandleAccess
mustPrintln := func(a ...any) {
if _, _err := fmt.Fprintln(w, a...); _err != nil {
panic(_err)
}
}
mustPrint := func(a ...any) {
if _, _err := fmt.Fprint(w, a...); _err != nil {
panic(_err)
}
}
for i, name := range args {
if p, ok := rosa.ResolveName(name); !ok {
return fmt.Errorf("unknown artifact %q", name)
} else {
var suffix string
if version := rosa.Std.Version(p); version != rosa.Unversioned {
suffix += "-" + version
}
mustPrintln("name : " + name + suffix)
meta := rosa.GetMetadata(p)
mustPrintln("description : " + meta.Description)
if meta.Website != "" {
mustPrintln("website : " +
strings.TrimSuffix(meta.Website, "/"))
}
if len(meta.Dependencies) > 0 {
mustPrint("depends on :")
for _, d := range meta.Dependencies {
s := rosa.GetMetadata(d).Name
if version := rosa.Std.Version(d); version != rosa.Unversioned {
s += "-" + version
}
mustPrint(" " + s)
}
mustPrintln()
}
const statusPrefix = "status : "
if writeStatus {
if r == nil {
var f io.ReadSeekCloser
err = cm.Do(func(cache *pkg.Cache) (err error) {
f, err = cache.OpenStatus(rosa.Std.Load(p))
return
})
if err != nil {
if errors.Is(err, os.ErrNotExist) {
mustPrintln(
statusPrefix + "not yet cured",
)
} else {
return
}
} else {
mustPrint(statusPrefix)
_, err = io.Copy(w, f)
if err = errors.Join(err, f.Close()); err != nil {
return
}
}
} else if err = cm.Do(func(cache *pkg.Cache) (err error) {
status, n := r.ArtifactOf(cache.Ident(rosa.Std.Load(p)))
if status == nil {
mustPrintln(
statusPrefix + "not in report",
)
} else {
mustPrintln("size :", n)
mustPrint(statusPrefix)
if _, err = w.Write(status); err != nil {
return
}
}
return
}); err != nil {
return
}
}
if i != len(args)-1 {
mustPrintln()
}
}
}
return nil
}

170
cmd/mbf/info_test.go Normal file
View File

@@ -0,0 +1,170 @@
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"reflect"
"strings"
"syscall"
"testing"
"unsafe"
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
"hakurei.app/message"
)
func TestInfo(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
args []string
status map[string]string
report string
want string
wantErr any
}{
{"qemu", []string{"qemu"}, nil, "", `
name : qemu-` + rosa.Std.Version(rosa.QEMU) + `
description : a generic and open source machine emulator and virtualizer
website : https://www.qemu.org
depends on : glib-` + rosa.Std.Version(rosa.GLib) + ` zstd-` + rosa.Std.Version(rosa.Zstd) + `
`, nil},
{"multi", []string{"hakurei", "hakurei-dist"}, nil, "", `
name : hakurei-` + rosa.Std.Version(rosa.Hakurei) + `
description : low-level userspace tooling for Rosa OS
website : https://hakurei.app
name : hakurei-dist-` + rosa.Std.Version(rosa.HakureiDist) + `
description : low-level userspace tooling for Rosa OS (distribution tarball)
website : https://hakurei.app
`, nil},
{"nonexistent", []string{"zlib", "\x00"}, nil, "", `
name : zlib-` + rosa.Std.Version(rosa.Zlib) + `
description : lossless data-compression library
website : https://zlib.net
`, fmt.Errorf("unknown artifact %q", "\x00")},
{"status cache", []string{"zlib", "zstd"}, map[string]string{
"zstd": "internal/pkg (amd64) on satori\n",
"hakurei": "internal/pkg (amd64) on satori\n\n",
}, "", `
name : zlib-` + rosa.Std.Version(rosa.Zlib) + `
description : lossless data-compression library
website : https://zlib.net
status : not yet cured
name : zstd-` + rosa.Std.Version(rosa.Zstd) + `
description : a fast compression algorithm
website : https://facebook.github.io/zstd
status : internal/pkg (amd64) on satori
`, nil},
{"status cache perm", []string{"zlib"}, map[string]string{
"zlib": "\x00",
}, "", `
name : zlib-` + rosa.Std.Version(rosa.Zlib) + `
description : lossless data-compression library
website : https://zlib.net
`, func(cm *cache) error {
return &os.PathError{
Op: "open",
Path: filepath.Join(cm.base, "status", pkg.Encode(cm.c.Ident(rosa.Std.Load(rosa.Zlib)).Value())),
Err: syscall.EACCES,
}
}},
{"status report", []string{"zlib"}, nil, strings.Repeat("\x00", len(pkg.Checksum{})+8), `
name : zlib-` + rosa.Std.Version(rosa.Zlib) + `
description : lossless data-compression library
website : https://zlib.net
status : not in report
`, nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
cm *cache
buf strings.Builder
rp string
)
if tc.status != nil || tc.report != "" {
cm = &cache{
ctx: context.Background(),
msg: message.New(log.New(os.Stderr, "info: ", 0)),
base: t.TempDir(),
}
defer cm.Close()
}
if tc.report != "" {
rp = filepath.Join(t.TempDir(), "report")
if err := os.WriteFile(
rp,
unsafe.Slice(unsafe.StringData(tc.report), len(tc.report)),
0400,
); err != nil {
t.Fatal(err)
}
}
if tc.status != nil {
for name, status := range tc.status {
p, ok := rosa.ResolveName(name)
if !ok {
t.Fatalf("invalid name %q", name)
}
perm := os.FileMode(0400)
if status == "\x00" {
perm = 0
}
if err := cm.Do(func(cache *pkg.Cache) error {
return os.WriteFile(filepath.Join(
cm.base,
"status",
pkg.Encode(cache.Ident(rosa.Std.Load(p)).Value()),
), unsafe.Slice(unsafe.StringData(status), len(status)), perm)
}); err != nil {
t.Fatalf("Do: error = %v", err)
}
}
}
var wantErr error
switch c := tc.wantErr.(type) {
case error:
wantErr = c
case func(cm *cache) error:
wantErr = c(cm)
default:
if tc.wantErr != nil {
t.Fatalf("invalid wantErr %#v", tc.wantErr)
}
}
if err := commandInfo(
cm,
tc.args,
&buf,
cm != nil,
rp,
); !reflect.DeepEqual(err, wantErr) {
t.Fatalf("commandInfo: error = %v, want %v", err, wantErr)
}
if got := buf.String(); got != strings.TrimPrefix(tc.want, "\n") {
t.Errorf("commandInfo:\n%s\nwant\n%s", got, tc.want)
}
})
}
}

View File

@@ -1,29 +1,43 @@
// The mbf program is a frontend for [hakurei.app/internal/rosa].
//
// This program is not covered by the compatibility promise. The command line
// interface, available packages and their behaviour, and even the on-disk
// format, may change at any time.
//
// # Name
//
// The name mbf stands for maiden's best friend, as a tribute to the DOOM source
// port of [the same name]. This name is a placeholder and is subject to change.
//
// [the same name]: https://www.doomwiki.org/wiki/MBF
package main
import (
"context"
"crypto/sha512"
"errors"
"fmt"
"io"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"unique"
"hakurei.app/check"
"hakurei.app/command"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/container/seccomp"
"hakurei.app/container/std"
"hakurei.app/ext"
"hakurei.app/fhs"
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
"hakurei.app/message"
@@ -40,14 +54,13 @@ func main() {
log.Fatal("this program must not run as root")
}
var cache *pkg.Cache
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer stop()
var cm cache
defer func() {
if cache != nil {
cache.Close()
}
cm.Close()
if r := recover(); r != nil {
fmt.Println(r)
@@ -56,61 +69,66 @@ func main() {
}()
var (
flagQuiet bool
flagCures int
flagBase string
flagTShift int
flagIdle bool
flagQuiet bool
addr net.UnixAddr
)
c := command.New(os.Stderr, log.Printf, "mbf", func([]string) (err error) {
c := command.New(os.Stderr, log.Printf, "mbf", func([]string) error {
msg.SwapVerbose(!flagQuiet)
cm.ctx, cm.msg = ctx, msg
cm.base = os.ExpandEnv(cm.base)
flagBase = os.ExpandEnv(flagBase)
if flagBase == "" {
flagBase = "cache"
addr.Net = "unix"
addr.Name = os.ExpandEnv(addr.Name)
if addr.Name == "" {
addr.Name = "daemon"
}
var base *check.Absolute
if flagBase, err = filepath.Abs(flagBase); err != nil {
return
} else if base, err = check.NewAbs(flagBase); err != nil {
return
}
if cache, err = pkg.Open(ctx, msg, flagCures, base); err == nil {
if flagTShift < 0 {
cache.SetThreshold(0)
} else if flagTShift > 31 {
cache.SetThreshold(1 << 31)
} else {
cache.SetThreshold(1 << flagTShift)
}
}
if flagIdle {
pkg.SetSchedIdle = true
}
return
return nil
}).Flag(
&flagQuiet,
"q", command.BoolFlag(false),
"Do not print cure messages",
).Flag(
&flagCures,
&cm.cures,
"cures", command.IntFlag(0),
"Maximum number of dependencies to cure at any given time",
).Flag(
&flagBase,
&cm.jobs,
"jobs", command.IntFlag(0),
"Preferred number of jobs to run, when applicable",
).Flag(
&cm.base,
"d", command.StringFlag("$MBF_CACHE_DIR"),
"Directory to store cured artifacts",
).Flag(
&flagTShift,
"tshift", command.IntFlag(-1),
"Dependency graph size exponent, to the power of 2",
).Flag(
&flagIdle,
&cm.idle,
"sched-idle", command.BoolFlag(false),
"Set SCHED_IDLE scheduling policy",
).Flag(
&cm.hostAbstract,
"host-abstract", command.BoolFlag(
os.Getenv("MBF_HOST_ABSTRACT") != "",
),
"Do not restrict networked cure containers from connecting to host "+
"abstract UNIX sockets",
).Flag(
&addr.Name,
"socket", command.StringFlag("$MBF_DAEMON_SOCKET"),
"Pathname of socket to bind to",
)
c.NewCommand(
"checksum", "Compute checksum of data read from standard input",
func([]string) error {
go func() { <-ctx.Done(); os.Exit(1) }()
h := sha512.New384()
if _, err := io.Copy(h, os.Stdin); err != nil {
return err
}
log.Println(pkg.Encode(pkg.Checksum(h.Sum(nil))))
return nil
},
)
{
@@ -124,7 +142,9 @@ func main() {
if flagShifts < 0 || flagShifts > 31 {
flagShifts = 12
}
return cache.Scrub(runtime.NumCPU() << flagShifts)
return cm.Do(func(cache *pkg.Cache) error {
return cache.Scrub(runtime.NumCPU() << flagShifts)
})
},
).Flag(
&flagShifts,
@@ -142,105 +162,17 @@ func main() {
"info",
"Display out-of-band metadata of an artifact",
func(args []string) (err error) {
if len(args) == 0 {
return errors.New("info requires at least 1 argument")
}
var r *rosa.Report
if flagReport != "" {
if r, err = rosa.OpenReport(flagReport); err != nil {
return err
}
defer func() {
if closeErr := r.Close(); err == nil {
err = closeErr
}
}()
defer r.HandleAccess(&err)()
}
for i, name := range args {
if p, ok := rosa.ResolveName(name); !ok {
return fmt.Errorf("unknown artifact %q", name)
} else {
var suffix string
if version := rosa.Std.Version(p); version != rosa.Unversioned {
suffix += "-" + version
}
fmt.Println("name : " + name + suffix)
meta := rosa.GetMetadata(p)
fmt.Println("description : " + meta.Description)
if meta.Website != "" {
fmt.Println("website : " +
strings.TrimSuffix(meta.Website, "/"))
}
if len(meta.Dependencies) > 0 {
fmt.Print("depends on :")
for _, d := range meta.Dependencies {
s := rosa.GetMetadata(d).Name
if version := rosa.Std.Version(d); version != rosa.Unversioned {
s += "-" + version
}
fmt.Print(" " + s)
}
fmt.Println()
}
const statusPrefix = "status : "
if flagStatus {
if r == nil {
var f io.ReadSeekCloser
f, err = cache.OpenStatus(rosa.Std.Load(p))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Println(
statusPrefix + "not yet cured",
)
} else {
return
}
} else {
fmt.Print(statusPrefix)
_, err = io.Copy(os.Stdout, f)
if err = errors.Join(err, f.Close()); err != nil {
return
}
}
} else {
status, n := r.ArtifactOf(cache.Ident(rosa.Std.Load(p)))
if status == nil {
fmt.Println(
statusPrefix + "not in report",
)
} else {
fmt.Println("size :", n)
fmt.Print(statusPrefix)
if _, err = os.Stdout.Write(status); err != nil {
return
}
}
}
}
if i != len(args)-1 {
fmt.Println()
}
}
}
return nil
return commandInfo(&cm, args, os.Stdout, flagStatus, flagReport)
},
).
Flag(
&flagStatus,
"status", command.BoolFlag(false),
"Display cure status if available",
).
Flag(
&flagReport,
"report", command.StringFlag(""),
"Load cure status from this report file instead of cache",
)
).Flag(
&flagStatus,
"status", command.BoolFlag(false),
"Display cure status if available",
).Flag(
&flagReport,
"report", command.StringFlag(""),
"Load cure status from this report file instead of cache",
)
}
c.NewCommand(
@@ -271,10 +203,12 @@ func main() {
return errors.New("report requires 1 argument")
}
if container.Isatty(int(w.Fd())) {
if ext.Isatty(int(w.Fd())) {
return errors.New("output appears to be a terminal")
}
return rosa.WriteReport(msg, w, cache)
return cm.Do(func(cache *pkg.Cache) error {
return rosa.WriteReport(msg, w, cache)
})
},
)
@@ -337,14 +271,26 @@ func main() {
" package(s) are out of date"))
}
return errors.Join(errs...)
}).
Flag(
&flagJobs,
"j", command.IntFlag(32),
"Maximum number of simultaneous connections",
)
}).Flag(
&flagJobs,
"j", command.IntFlag(32),
"Maximum number of simultaneous connections",
)
}
c.NewCommand(
"daemon",
"Service artifact IR with Rosa OS extensions",
func(args []string) error {
ul, err := net.ListenUnix("unix", &addr)
if err != nil {
return err
}
log.Printf("listening on pathname socket at %s", addr.Name)
return serve(ctx, log.Default(), &cm, ul)
},
)
{
var (
flagGentoo string
@@ -369,25 +315,37 @@ func main() {
rosa.SetGentooStage3(flagGentoo, checksum)
}
_, _, _, stage1 := (t - 2).NewLLVM()
_, _, _, stage2 := (t - 1).NewLLVM()
_, _, _, stage3 := t.NewLLVM()
var (
pathname *check.Absolute
checksum [2]unique.Handle[pkg.Checksum]
)
if pathname, _, err = cache.Cure(stage1); err != nil {
return err
if err = cm.Do(func(cache *pkg.Cache) (err error) {
pathname, _, err = cache.Cure(
(t - 2).Load(rosa.Clang),
)
return
}); err != nil {
return
}
log.Println("stage1:", pathname)
if pathname, checksum[0], err = cache.Cure(stage2); err != nil {
return err
if err = cm.Do(func(cache *pkg.Cache) (err error) {
pathname, checksum[0], err = cache.Cure(
(t - 1).Load(rosa.Clang),
)
return
}); err != nil {
return
}
log.Println("stage2:", pathname)
if pathname, checksum[1], err = cache.Cure(stage3); err != nil {
return err
if err = cm.Do(func(cache *pkg.Cache) (err error) {
pathname, checksum[1], err = cache.Cure(
t.Load(rosa.Clang),
)
return
}); err != nil {
return
}
log.Println("stage3:", pathname)
@@ -404,38 +362,40 @@ func main() {
}
if flagStage0 {
if pathname, _, err = cache.Cure(
t.Load(rosa.Stage0),
); err != nil {
return err
if err = cm.Do(func(cache *pkg.Cache) (err error) {
pathname, _, err = cache.Cure(
t.Load(rosa.Stage0),
)
return
}); err != nil {
return
}
log.Println(pathname)
}
return
},
).
Flag(
&flagGentoo,
"gentoo", command.StringFlag(""),
"Bootstrap from a Gentoo stage3 tarball",
).
Flag(
&flagChecksum,
"checksum", command.StringFlag(""),
"Checksum of Gentoo stage3 tarball",
).
Flag(
&flagStage0,
"stage0", command.BoolFlag(false),
"Create bootstrap stage0 tarball",
)
).Flag(
&flagGentoo,
"gentoo", command.StringFlag(""),
"Bootstrap from a Gentoo stage3 tarball",
).Flag(
&flagChecksum,
"checksum", command.StringFlag(""),
"Checksum of Gentoo stage3 tarball",
).Flag(
&flagStage0,
"stage0", command.BoolFlag(false),
"Create bootstrap stage0 tarball",
)
}
{
var (
flagDump string
flagEnter bool
flagExport string
flagRemote bool
)
c.NewCommand(
"cure",
@@ -444,10 +404,18 @@ func main() {
if len(args) != 1 {
return errors.New("cure requires 1 argument")
}
if p, ok := rosa.ResolveName(args[0]); !ok {
p, ok := rosa.ResolveName(args[0])
if !ok {
return fmt.Errorf("unknown artifact %q", args[0])
} else if flagDump == "" {
pathname, _, err := cache.Cure(rosa.Std.Load(p))
}
switch {
default:
var pathname *check.Absolute
err := cm.Do(func(cache *pkg.Cache) (err error) {
pathname, _, err = cache.Cure(rosa.Std.Load(p))
return
})
if err != nil {
return err
}
@@ -476,7 +444,8 @@ func main() {
}
return nil
} else {
case flagDump != "":
f, err := os.OpenFile(
flagDump,
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
@@ -486,25 +455,49 @@ func main() {
return err
}
if err = cache.EncodeAll(f, rosa.Std.Load(p)); err != nil {
if err = pkg.NewIR().EncodeAll(f, rosa.Std.Load(p)); err != nil {
_ = f.Close()
return err
}
return f.Close()
case flagEnter:
return cm.Do(func(cache *pkg.Cache) error {
return cache.EnterExec(
ctx,
rosa.Std.Load(p),
true, os.Stdin, os.Stdout, os.Stderr,
rosa.AbsSystem.Append("bin", "mksh"),
"sh",
)
})
case flagRemote:
pathname, err := cureRemote(ctx, &addr, rosa.Std.Load(p))
if err == nil {
log.Println(pathname)
}
return err
}
},
).
Flag(
&flagDump,
"dump", command.StringFlag(""),
"Write IR to specified pathname and terminate",
).
Flag(
&flagExport,
"export", command.StringFlag(""),
"Export cured artifact to specified pathname",
)
).Flag(
&flagDump,
"dump", command.StringFlag(""),
"Write IR to specified pathname and terminate",
).Flag(
&flagExport,
"export", command.StringFlag(""),
"Export cured artifact to specified pathname",
).Flag(
&flagEnter,
"enter", command.BoolFlag(false),
"Enter cure container with an interactive shell",
).Flag(
&flagRemote,
"daemon", command.BoolFlag(false),
"Cure artifact on the daemon",
)
}
{
@@ -518,7 +511,7 @@ func main() {
"shell",
"Interactive shell in the specified Rosa OS environment",
func(args []string) error {
presets := make([]rosa.PArtifact, len(args))
presets := make([]rosa.PArtifact, len(args)+3)
for i, arg := range args {
p, ok := rosa.ResolveName(arg)
if !ok {
@@ -526,23 +519,26 @@ func main() {
}
presets[i] = p
}
root := make(rosa.Collect, 0, 6+len(args))
root = rosa.Std.AppendPresets(root, presets...)
if flagWithToolchain {
musl, compilerRT, runtimes, clang := (rosa.Std - 1).NewLLVM()
root = append(root, musl, compilerRT, runtimes, clang)
} else {
root = append(root, rosa.Std.Load(rosa.Musl))
base := rosa.Clang
if !flagWithToolchain {
base = rosa.Musl
}
root = append(root,
rosa.Std.Load(rosa.Mksh),
rosa.Std.Load(rosa.Toybox),
presets = append(presets,
base,
rosa.Mksh,
rosa.Toybox,
)
if _, _, err := cache.Cure(&root); err == nil {
root := make(pkg.Collect, 0, 6+len(args))
root = rosa.Std.AppendPresets(root, presets...)
if err := cm.Do(func(cache *pkg.Cache) error {
_, _, err := cache.Cure(&root)
return err
}); err == nil {
return errors.New("unreachable")
} else if !errors.Is(err, rosa.Collected{}) {
} else if !pkg.IsCollected(err) {
return err
}
@@ -552,11 +548,22 @@ func main() {
}
cured := make(map[pkg.Artifact]cureRes)
for _, a := range root {
pathname, checksum, err := cache.Cure(a)
if err != nil {
if err := cm.Do(func(cache *pkg.Cache) error {
pathname, checksum, err := cache.Cure(a)
if err == nil {
cured[a] = cureRes{pathname, checksum}
}
return err
}); err != nil {
return err
}
}
// explicitly open for direct error-free use from this point
if cm.c == nil {
if err := cm.open(); err != nil {
return err
}
cured[a] = cureRes{pathname, checksum}
}
layers := pkg.PromoteLayers(root, func(a pkg.Artifact) (
@@ -566,7 +573,7 @@ func main() {
res := cured[a]
return res.pathname, res.checksum
}, func(i int, d pkg.Artifact) {
r := pkg.Encode(cache.Ident(d).Value())
r := pkg.Encode(cm.c.Ident(d).Value())
if s, ok := d.(fmt.Stringer); ok {
if name := s.String(); name != "" {
r += "-" + name
@@ -585,6 +592,9 @@ func main() {
z.Hostname = "localhost"
z.Uid, z.Gid = (1<<10)-1, (1<<10)-1
z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr
if s, ok := os.LookupEnv("TERM"); ok {
z.Env = append(z.Env, "TERM="+s)
}
var tempdir *check.Absolute
if s, err := filepath.Abs(os.TempDir()); err != nil {
@@ -627,22 +637,19 @@ func main() {
}
return z.Wait()
},
).
Flag(
&flagNet,
"net", command.BoolFlag(false),
"Share host net namespace",
).
Flag(
&flagSession,
"session", command.BoolFlag(false),
"Retain session",
).
Flag(
&flagWithToolchain,
"with-toolchain", command.BoolFlag(false),
"Include the stage3 LLVM toolchain",
)
).Flag(
&flagNet,
"net", command.BoolFlag(false),
"Share host net namespace",
).Flag(
&flagSession,
"session", command.BoolFlag(true),
"Retain session",
).Flag(
&flagWithToolchain,
"with-toolchain", command.BoolFlag(false),
"Include the stage2 LLVM toolchain",
)
}
@@ -653,9 +660,7 @@ func main() {
)
c.MustParse(os.Args[1:], func(err error) {
if cache != nil {
cache.Close()
}
cm.Close()
if w, ok := err.(interface{ Unwrap() []error }); !ok {
log.Fatal(err)
} else {

View File

@@ -1,176 +0,0 @@
package main
import (
"encoding/json"
"log"
"net/http"
"net/url"
"path"
"strconv"
"sync"
"hakurei.app/internal/info"
"hakurei.app/internal/rosa"
)
// for lazy initialisation of serveInfo
var (
infoPayload struct {
// Current package count.
Count int `json:"count"`
// Hakurei version, set at link time.
HakureiVersion string `json:"hakurei_version"`
}
infoPayloadOnce sync.Once
)
// handleInfo writes constant system information.
func handleInfo(w http.ResponseWriter, _ *http.Request) {
infoPayloadOnce.Do(func() {
infoPayload.Count = int(rosa.PresetUnexportedStart)
infoPayload.HakureiVersion = info.Version()
})
// TODO(mae): cache entire response if no additional fields are planned
writeAPIPayload(w, infoPayload)
}
// newStatusHandler returns a [http.HandlerFunc] that offers status files for
// viewing or download, if available.
func (index *packageIndex) newStatusHandler(disposition bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m, ok := index.names[path.Base(r.URL.Path)]
if !ok || !m.HasReport {
http.NotFound(w, r)
return
}
contentType := "text/plain; charset=utf-8"
if disposition {
contentType = "application/octet-stream"
// quoting like this is unsound, but okay, because metadata is hardcoded
contentDisposition := `attachment; filename="`
contentDisposition += m.Name + "-"
if m.Version != "" {
contentDisposition += m.Version + "-"
}
contentDisposition += m.ids + `.log"`
w.Header().Set("Content-Disposition", contentDisposition)
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := func() (err error) {
defer index.handleAccess(&err)()
_, err = w.Write(m.status)
return
}(); err != nil {
log.Println(err)
http.Error(
w, "cannot deliver status, contact maintainers",
http.StatusInternalServerError,
)
}
}
}
// handleGet writes a slice of metadata with specified order.
func (index *packageIndex) handleGet(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit, err := strconv.Atoi(q.Get("limit"))
if err != nil || limit > 100 || limit < 1 {
http.Error(
w, "limit must be an integer between 1 and 100",
http.StatusBadRequest,
)
return
}
i, err := strconv.Atoi(q.Get("index"))
if err != nil || i >= len(index.sorts[0]) || i < 0 {
http.Error(
w, "index must be an integer between 0 and "+
strconv.Itoa(int(rosa.PresetUnexportedStart-1)),
http.StatusBadRequest,
)
return
}
sort, err := strconv.Atoi(q.Get("sort"))
if err != nil || sort >= len(index.sorts) || sort < 0 {
http.Error(
w, "sort must be an integer between 0 and "+
strconv.Itoa(sortOrderEnd),
http.StatusBadRequest,
)
return
}
values := index.sorts[sort][i:min(i+limit, len(index.sorts[sort]))]
writeAPIPayload(w, &struct {
Values []*metadata `json:"values"`
}{values})
}
func (index *packageIndex) handleSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit, err := strconv.Atoi(q.Get("limit"))
if err != nil || limit > 100 || limit < 1 {
http.Error(
w, "limit must be an integer between 1 and 100",
http.StatusBadRequest,
)
return
}
i, err := strconv.Atoi(q.Get("index"))
if err != nil || i >= len(index.sorts[0]) || i < 0 {
http.Error(
w, "index must be an integer between 0 and "+
strconv.Itoa(int(rosa.PresetUnexportedStart-1)),
http.StatusBadRequest,
)
return
}
search, err := url.PathUnescape(q.Get("search"))
if len(search) > 100 || err != nil {
http.Error(
w, "search must be a string between 0 and 100 characters long",
http.StatusBadRequest,
)
return
}
desc := q.Get("desc") == "true"
n, res, err := index.performSearchQuery(limit, i, search, desc)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
writeAPIPayload(w, &struct {
Count int `json:"count"`
Results []searchResult `json:"results"`
}{n, res})
}
// apiVersion is the name of the current API revision, as part of the pattern.
const apiVersion = "v1"
// registerAPI registers API handler functions.
func (index *packageIndex) registerAPI(mux *http.ServeMux) {
mux.HandleFunc("GET /api/"+apiVersion+"/info", handleInfo)
mux.HandleFunc("GET /api/"+apiVersion+"/get", index.handleGet)
mux.HandleFunc("GET /api/"+apiVersion+"/search", index.handleSearch)
mux.HandleFunc("GET /api/"+apiVersion+"/status/", index.newStatusHandler(false))
mux.HandleFunc("GET /status/", index.newStatusHandler(true))
}
// writeAPIPayload sets headers common to API responses and encodes payload as
// JSON for the response body.
func writeAPIPayload(w http.ResponseWriter, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Println(err)
http.Error(
w, "cannot encode payload, contact maintainers",
http.StatusInternalServerError,
)
}
}

View File

@@ -1,183 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"slices"
"strconv"
"testing"
"hakurei.app/internal/info"
"hakurei.app/internal/rosa"
)
// prefix is prepended to every API path.
const prefix = "/api/" + apiVersion + "/"
func TestAPIInfo(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
handleInfo(w, httptest.NewRequestWithContext(
t.Context(),
http.MethodGet,
prefix+"info",
nil,
))
resp := w.Result()
checkStatus(t, resp, http.StatusOK)
checkAPIHeader(t, w.Header())
checkPayload(t, resp, struct {
Count int `json:"count"`
HakureiVersion string `json:"hakurei_version"`
}{int(rosa.PresetUnexportedStart), info.Version()})
}
func TestAPIGet(t *testing.T) {
t.Parallel()
const target = prefix + "get"
index := newIndex(t)
newRequest := func(suffix string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
index.handleGet(w, httptest.NewRequestWithContext(
t.Context(),
http.MethodGet,
target+suffix,
nil,
))
return w
}
checkValidate := func(t *testing.T, suffix string, vmin, vmax int, wantErr string) {
t.Run("invalid", func(t *testing.T) {
t.Parallel()
w := newRequest("?" + suffix + "=invalid")
resp := w.Result()
checkError(t, resp, wantErr, http.StatusBadRequest)
})
t.Run("min", func(t *testing.T) {
t.Parallel()
w := newRequest("?" + suffix + "=" + strconv.Itoa(vmin-1))
resp := w.Result()
checkError(t, resp, wantErr, http.StatusBadRequest)
w = newRequest("?" + suffix + "=" + strconv.Itoa(vmin))
resp = w.Result()
checkStatus(t, resp, http.StatusOK)
})
t.Run("max", func(t *testing.T) {
t.Parallel()
w := newRequest("?" + suffix + "=" + strconv.Itoa(vmax+1))
resp := w.Result()
checkError(t, resp, wantErr, http.StatusBadRequest)
w = newRequest("?" + suffix + "=" + strconv.Itoa(vmax))
resp = w.Result()
checkStatus(t, resp, http.StatusOK)
})
}
t.Run("limit", func(t *testing.T) {
t.Parallel()
checkValidate(
t, "index=0&sort=0&limit", 1, 100,
"limit must be an integer between 1 and 100",
)
})
t.Run("index", func(t *testing.T) {
t.Parallel()
checkValidate(
t, "limit=1&sort=0&index", 0, int(rosa.PresetUnexportedStart-1),
"index must be an integer between 0 and "+strconv.Itoa(int(rosa.PresetUnexportedStart-1)),
)
})
t.Run("sort", func(t *testing.T) {
t.Parallel()
checkValidate(
t, "index=0&limit=1&sort", 0, int(sortOrderEnd),
"sort must be an integer between 0 and "+strconv.Itoa(int(sortOrderEnd)),
)
})
checkWithSuffix := func(name, suffix string, want []*metadata) {
t.Run(name, func(t *testing.T) {
t.Parallel()
w := newRequest(suffix)
resp := w.Result()
checkStatus(t, resp, http.StatusOK)
checkAPIHeader(t, w.Header())
checkPayloadFunc(t, resp, func(got *struct {
Count int `json:"count"`
Values []*metadata `json:"values"`
}) bool {
return got.Count == len(want) &&
slices.EqualFunc(got.Values, want, func(a, b *metadata) bool {
return (a.Version == b.Version ||
a.Version == rosa.Unversioned ||
b.Version == rosa.Unversioned) &&
a.HasReport == b.HasReport &&
a.Name == b.Name &&
a.Description == b.Description &&
a.Website == b.Website
})
})
})
}
checkWithSuffix("declarationAscending", "?limit=2&index=0&sort=0", []*metadata{
{
Metadata: rosa.GetMetadata(0),
Version: rosa.Std.Version(0),
},
{
Metadata: rosa.GetMetadata(1),
Version: rosa.Std.Version(1),
},
})
checkWithSuffix("declarationAscending offset", "?limit=3&index=5&sort=0", []*metadata{
{
Metadata: rosa.GetMetadata(5),
Version: rosa.Std.Version(5),
},
{
Metadata: rosa.GetMetadata(6),
Version: rosa.Std.Version(6),
},
{
Metadata: rosa.GetMetadata(7),
Version: rosa.Std.Version(7),
},
})
checkWithSuffix("declarationDescending", "?limit=3&index=0&sort=1", []*metadata{
{
Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 1),
Version: rosa.Std.Version(rosa.PresetUnexportedStart - 1),
},
{
Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 2),
Version: rosa.Std.Version(rosa.PresetUnexportedStart - 2),
},
{
Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 3),
Version: rosa.Std.Version(rosa.PresetUnexportedStart - 3),
},
})
checkWithSuffix("declarationDescending offset", "?limit=1&index=37&sort=1", []*metadata{
{
Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 38),
Version: rosa.Std.Version(rosa.PresetUnexportedStart - 38),
},
})
}

View File

@@ -1,105 +0,0 @@
package main
import (
"cmp"
"errors"
"slices"
"strings"
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
)
const (
declarationAscending = iota
declarationDescending
nameAscending
nameDescending
sizeAscending
sizeDescending
sortOrderEnd = iota - 1
)
// packageIndex refers to metadata by name and various sort orders.
type packageIndex struct {
sorts [sortOrderEnd + 1][rosa.PresetUnexportedStart]*metadata
names map[string]*metadata
search searchCache
// Taken from [rosa.Report] if available.
handleAccess func(*error) func()
}
// metadata holds [rosa.Metadata] extended with additional information.
type metadata struct {
p rosa.PArtifact
*rosa.Metadata
// Populated via [rosa.Toolchain.Version], [rosa.Unversioned] is equivalent
// to the zero value. Otherwise, the zero value is invalid.
Version string `json:"version,omitempty"`
// Output data size, available if present in report.
Size int64 `json:"size,omitempty"`
// Whether the underlying [pkg.Artifact] is present in the report.
HasReport bool `json:"report"`
// Ident string encoded ahead of time.
ids string
// Backed by [rosa.Report], access must be prepared by HandleAccess.
status []byte
}
// populate deterministically populates packageIndex, optionally with a report.
func (index *packageIndex) populate(cache *pkg.Cache, report *rosa.Report) (err error) {
if report != nil {
defer report.HandleAccess(&err)()
index.handleAccess = report.HandleAccess
}
var work [rosa.PresetUnexportedStart]*metadata
index.names = make(map[string]*metadata)
for p := range rosa.PresetUnexportedStart {
m := metadata{
p: p,
Metadata: rosa.GetMetadata(p),
Version: rosa.Std.Version(p),
}
if m.Version == "" {
return errors.New("invalid version from " + m.Name)
}
if m.Version == rosa.Unversioned {
m.Version = ""
}
if cache != nil && report != nil {
id := cache.Ident(rosa.Std.Load(p))
m.ids = pkg.Encode(id.Value())
m.status, m.Size = report.ArtifactOf(id)
m.HasReport = m.Size >= 0
}
work[p] = &m
index.names[m.Name] = &m
}
index.sorts[declarationAscending] = work
index.sorts[declarationDescending] = work
slices.Reverse(index.sorts[declarationDescending][:])
index.sorts[nameAscending] = work
slices.SortFunc(index.sorts[nameAscending][:], func(a, b *metadata) int {
return strings.Compare(a.Name, b.Name)
})
index.sorts[nameDescending] = index.sorts[nameAscending]
slices.Reverse(index.sorts[nameDescending][:])
index.sorts[sizeAscending] = work
slices.SortFunc(index.sorts[sizeAscending][:], func(a, b *metadata) int {
return cmp.Compare(a.Size, b.Size)
})
index.sorts[sizeDescending] = index.sorts[sizeAscending]
slices.Reverse(index.sorts[sizeDescending][:])
return
}

View File

@@ -1,115 +0,0 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"hakurei.app/command"
"hakurei.app/container/check"
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
"hakurei.app/message"
)
const shutdownTimeout = 15 * time.Second
func main() {
log.SetFlags(0)
log.SetPrefix("pkgserver: ")
var (
flagBaseDir string
flagAddr string
)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer stop()
msg := message.New(log.Default())
c := command.New(os.Stderr, log.Printf, "pkgserver", func(args []string) error {
var (
cache *pkg.Cache
report *rosa.Report
)
switch len(args) {
case 0:
break
case 1:
baseDir, err := check.NewAbs(flagBaseDir)
if err != nil {
return err
}
cache, err = pkg.Open(ctx, msg, 0, baseDir)
if err != nil {
return err
}
defer cache.Close()
report, err = rosa.OpenReport(args[0])
if err != nil {
return err
}
default:
return errors.New("pkgserver requires 1 argument")
}
var index packageIndex
index.search = make(searchCache)
if err := index.populate(cache, report); err != nil {
return err
}
ticker := time.NewTicker(1 * time.Minute)
go func() {
for {
select {
case <-ctx.Done():
ticker.Stop()
return
case <-ticker.C:
index.search.clean()
}
}
}()
var mux http.ServeMux
uiRoutes(&mux)
testUiRoutes(&mux)
index.registerAPI(&mux)
server := http.Server{
Addr: flagAddr,
Handler: &mux,
}
go func() {
<-ctx.Done()
c, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := server.Shutdown(c); err != nil {
log.Fatal(err)
}
}()
return server.ListenAndServe()
}).Flag(
&flagBaseDir,
"b", command.StringFlag(""),
"base directory for cache",
).Flag(
&flagAddr,
"addr", command.StringFlag(":8067"),
"TCP network address to listen on",
)
c.MustParse(os.Args[1:], func(err error) {
if errors.Is(err, http.ErrServerClosed) {
os.Exit(0)
}
log.Fatal(err)
})
}

View File

@@ -1,96 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"testing"
)
// newIndex returns the address of a newly populated packageIndex.
func newIndex(t *testing.T) *packageIndex {
t.Helper()
var index packageIndex
if err := index.populate(nil, nil); err != nil {
t.Fatalf("populate: error = %v", err)
}
return &index
}
// checkStatus checks response status code.
func checkStatus(t *testing.T, resp *http.Response, want int) {
t.Helper()
if resp.StatusCode != want {
t.Errorf(
"StatusCode: %s, want %s",
http.StatusText(resp.StatusCode),
http.StatusText(want),
)
}
}
// checkHeader checks the value of a header entry.
func checkHeader(t *testing.T, h http.Header, key, want string) {
t.Helper()
if got := h.Get(key); got != want {
t.Errorf("%s: %q, want %q", key, got, want)
}
}
// checkAPIHeader checks common entries set for API endpoints.
func checkAPIHeader(t *testing.T, h http.Header) {
t.Helper()
checkHeader(t, h, "Content-Type", "application/json; charset=utf-8")
checkHeader(t, h, "Cache-Control", "no-cache, no-store, must-revalidate")
checkHeader(t, h, "Pragma", "no-cache")
checkHeader(t, h, "Expires", "0")
}
// checkPayloadFunc checks the JSON response of an API endpoint by passing it to f.
func checkPayloadFunc[T any](
t *testing.T,
resp *http.Response,
f func(got *T) bool,
) {
t.Helper()
var got T
r := io.Reader(resp.Body)
if testing.Verbose() {
var buf bytes.Buffer
r = io.TeeReader(r, &buf)
defer func() { t.Helper(); t.Log(buf.String()) }()
}
if err := json.NewDecoder(r).Decode(&got); err != nil {
t.Fatalf("Decode: error = %v", err)
}
if !f(&got) {
t.Errorf("Body: %#v", got)
}
}
// checkPayload checks the JSON response of an API endpoint.
func checkPayload[T any](t *testing.T, resp *http.Response, want T) {
t.Helper()
checkPayloadFunc(t, resp, func(got *T) bool {
return reflect.DeepEqual(got, &want)
})
}
func checkError(t *testing.T, resp *http.Response, error string, code int) {
t.Helper()
checkStatus(t, resp, code)
if got, _ := io.ReadAll(resp.Body); string(got) != fmt.Sprintln(error) {
t.Errorf("Body: %q, want %q", string(got), error)
}
}

View File

@@ -1,77 +0,0 @@
package main
import (
"cmp"
"maps"
"regexp"
"slices"
"time"
)
type searchCache map[string]searchCacheEntry
type searchResult struct {
NameIndices [][]int `json:"name_matches"`
DescIndices [][]int `json:"desc_matches,omitempty"`
Score float64 `json:"score"`
*metadata
}
type searchCacheEntry struct {
query string
results []searchResult
expiry time.Time
}
func (index *packageIndex) performSearchQuery(limit int, i int, search string, desc bool) (int, []searchResult, error) {
entry, ok := index.search[search]
if ok {
return len(entry.results), entry.results[i:min(i+limit, len(entry.results))], nil
}
regex, err := regexp.Compile(search)
if err != nil {
return 0, make([]searchResult, 0), err
}
res := make([]searchResult, 0)
for p := range maps.Values(index.names) {
nameIndices := regex.FindAllIndex([]byte(p.Name), -1)
var descIndices [][]int = nil
if desc {
descIndices = regex.FindAllIndex([]byte(p.Description), -1)
}
if nameIndices == nil && descIndices == nil {
continue
}
score := float64(indexsum(nameIndices)) / (float64(len(nameIndices)) + 1)
if desc {
score += float64(indexsum(descIndices)) / (float64(len(descIndices)) + 1) / 10.0
}
res = append(res, searchResult{
NameIndices: nameIndices,
DescIndices: descIndices,
Score: score,
metadata: p,
})
}
slices.SortFunc(res[:], func(a, b searchResult) int { return -cmp.Compare(a.Score, b.Score) })
expiry := time.Now().Add(1 * time.Minute)
entry = searchCacheEntry{
query: search,
results: res,
expiry: expiry,
}
index.search[search] = entry
return len(res), res[i:min(i+limit, len(entry.results))], nil
}
func (s *searchCache) clean() {
maps.DeleteFunc(*s, func(_ string, v searchCacheEntry) bool {
return v.expiry.Before(time.Now())
})
}
func indexsum(in [][]int) int {
sum := 0
for i := 0; i < len(in); i++ {
sum += in[i][1] - in[i][0]
}
return sum
}

View File

@@ -1,97 +0,0 @@
//go:build frontend && frontend_test
package main
import (
"embed"
"net/http"
"path"
"strings"
)
//go:generate tsc -p ui_test
//go:generate sass ui_test/lib/ui.scss ui_test/lib/ui.css
//go:embed ui_test/*
var test_content embed.FS
func serveTestWebUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1")
w.Header().Set("X-Frame-Options", "DENY")
http.ServeFileFS(w, r, test_content, "ui_test/lib/ui.html")
}
func serveTestWebUIStaticContent(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/testui/style.css":
http.ServeFileFS(w, r, test_content, "ui_test/lib/ui.css")
case "/testui/skip-closed.svg":
http.ServeFileFS(w, r, test_content, "ui_test/lib/skip-closed.svg")
case "/testui/skip-open.svg":
http.ServeFileFS(w, r, test_content, "ui_test/lib/skip-open.svg")
case "/testui/success-closed.svg":
http.ServeFileFS(w, r, test_content, "ui_test/lib/success-closed.svg")
case "/testui/success-open.svg":
http.ServeFileFS(w, r, test_content, "ui_test/lib/success-open.svg")
case "/testui/failure-closed.svg":
http.ServeFileFS(w, r, test_content, "ui_test/lib/failure-closed.svg")
case "/testui/failure-open.svg":
http.ServeFileFS(w, r, test_content, "ui_test/lib/failure-open.svg")
default:
http.NotFound(w, r)
}
}
func serveTestLibrary(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/test/lib/test.js":
http.ServeFileFS(w, r, test_content, "ui_test/lib/test.js")
default:
http.NotFound(w, r)
}
}
func serveTests(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/test/" {
http.Redirect(w, r, "/test.html", http.StatusMovedPermanently)
return
}
testPath := strings.TrimPrefix(r.URL.Path, "/test/")
if path.Ext(testPath) != ".js" {
http.Error(w, "403 forbidden", http.StatusForbidden)
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
http.ServeFileFS(w, r, test_content, "ui_test/"+testPath)
}
func redirectUI(w http.ResponseWriter, r *http.Request) {
// The base path should not redirect to the root.
if r.URL.Path == "/ui/" {
http.NotFound(w, r)
return
}
if path.Ext(r.URL.Path) != ".js" {
http.Error(w, "403 forbidden", http.StatusForbidden)
return
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
http.Redirect(w, r, strings.TrimPrefix(r.URL.Path, "/ui"), http.StatusFound)
}
func testUiRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /test.html", serveTestWebUI)
mux.HandleFunc("GET /testui/", serveTestWebUIStaticContent)
mux.HandleFunc("GET /test/lib", serveTestLibrary)
mux.HandleFunc("GET /test/", serveTests)
mux.HandleFunc("GET /ui/", redirectUI)
}

View File

@@ -1,7 +0,0 @@
//go:build !(frontend && frontend_test)
package main
import "net/http"
func testUiRoutes(mux *http.ServeMux) {}

View File

@@ -1,38 +0,0 @@
package main
import "net/http"
func serveWebUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1")
w.Header().Set("X-Frame-Options", "DENY")
http.ServeFileFS(w, r, content, "ui/index.html")
}
func serveStaticContent(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/static/style.css":
darkTheme := r.CookiesNamed("dark_theme")
if len(darkTheme) > 0 && darkTheme[0].Value == "true" {
http.ServeFileFS(w, r, content, "ui/static/dark.css")
} else {
http.ServeFileFS(w, r, content, "ui/static/light.css")
}
case "/favicon.ico":
http.ServeFileFS(w, r, content, "ui/static/favicon.ico")
case "/static/index.js":
http.ServeFileFS(w, r, content, "ui/static/index.js")
default:
http.NotFound(w, r)
}
}
func uiRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", serveWebUI)
mux.HandleFunc("GET /favicon.ico", serveStaticContent)
mux.HandleFunc("GET /static/", serveStaticContent)
}

View File

@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="static/style.css">
<title>Hakurei PkgServer</title>
<script src="static/index.js"></script>
</head>
<body>
<h1>Hakurei PkgServer</h1>
<table id="pkg-list">
<tr><td>Loading...</td></tr>
</table>
<p>Showing entries <span id="entry-counter"></span>.</p>
<span class="bottom-nav"><a href="javascript:prevPage()">&laquo; Previous</a> <span id="page-number">1</span> <a href="javascript:nextPage()">Next &raquo;</a></span>
<span><label for="count">Entries per page: </label><select name="count" id="count">
<option value="10">10</option>
<option value="20">20</option>
<option value="30">30</option>
<option value="50">50</option>
</select></span>
<span><label for="sort">Sort by: </label><select name="sort" id="sort">
<option value="0">Definition (ascending)</option>
<option value="1">Definition (descending)</option>
<option value="2">Name (ascending)</option>
<option value="3">Name (descending)</option>
<option value="4">Size (ascending)</option>
<option value="5">Size (descending)</option>
</select></span>
</body>
<footer>
<p>&copy;<a href="https://hakurei.app/">Hakurei</a> (<span id="hakurei-version">unknown</span>). Licensed under the MIT license.</p>
</footer>
</html>

View File

@@ -1,6 +0,0 @@
@use 'common';
html {
background-color: #2c2c2c;
color: ghostwhite;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,161 +0,0 @@
function assertGetElementById(id: string): HTMLElement {
let elem = document.getElementById(id)
if(elem == null) throw new ReferenceError(`element with ID '${id}' missing from DOM`)
return elem
}
interface PackageIndexEntry {
name: string
size: number | null
description: string | null
website: string | null
version: string | null
report: boolean
}
function toHTML(entry: PackageIndexEntry): HTMLTableRowElement {
let v = entry.version != null ? `<span>${escapeHtml(entry.version)}</span>` : ""
let s = entry.size != null ? `<p>Size: ${toByteSizeString(entry.size)} (${entry.size})</p>` : ""
let d = entry.description != null ? `<p>${escapeHtml(entry.description)}</p>` : ""
let w = entry.website != null ? `<a href="${encodeURI(entry.website)}">Website</a>` : ""
let r = entry.report ? `Log (<a href=\"${encodeURI('/api/v1/status/' + entry.name)}\">View</a> | <a href=\"${encodeURI('/status/' + entry.name)}\">Download</a>)` : ""
let row = <HTMLTableRowElement>(document.createElement('tr'))
row.innerHTML = `<td>
<h2>${escapeHtml(entry.name)} ${v}</h2>
${d}
${s}
${w}
${r}
</td>`
return row
}
function toByteSizeString(bytes: number): string {
if(bytes == null || bytes < 1024) return `${bytes}B`
if(bytes < Math.pow(1024, 2)) return `${(bytes/1024).toFixed(2)}kiB`
if(bytes < Math.pow(1024, 3)) return `${(bytes/Math.pow(1024, 2)).toFixed(2)}MiB`
if(bytes < Math.pow(1024, 4)) return `${(bytes/Math.pow(1024, 3)).toFixed(2)}GiB`
if(bytes < Math.pow(1024, 5)) return `${(bytes/Math.pow(1024, 4)).toFixed(2)}TiB`
return "not only is it big, it's large"
}
const API_VERSION = 1
const ENDPOINT = `/api/v${API_VERSION}`
interface InfoPayload {
count: number
hakurei_version: string
}
async function infoRequest(): Promise<InfoPayload> {
const res = await fetch(`${ENDPOINT}/info`)
const payload = await res.json()
return payload
}
interface GetPayload {
values: PackageIndexEntry[]
}
enum SortOrders {
DeclarationAscending,
DeclarationDescending,
NameAscending,
NameDescending
}
async function getRequest(limit: number, index: number, sort: SortOrders): Promise<GetPayload> {
const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`)
const payload = await res.json()
return payload
}
class State {
entriesPerPage: number = 10
entryIndex: number = 0
maxEntries: number = 0
sort: SortOrders = SortOrders.DeclarationAscending
getEntriesPerPage(): number {
return this.entriesPerPage
}
setEntriesPerPage(entriesPerPage: number) {
this.entriesPerPage = entriesPerPage
this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage)
}
getEntryIndex(): number {
return this.entryIndex
}
setEntryIndex(entryIndex: number) {
this.entryIndex = entryIndex
this.updatePage()
this.updateRange()
this.updateListings()
}
getMaxEntries(): number {
return this.maxEntries
}
setMaxEntries(max: number) {
this.maxEntries = max
}
getSortOrder(): SortOrders {
return this.sort
}
setSortOrder(sortOrder: SortOrders) {
this.sort = sortOrder
this.setEntryIndex(0)
}
updatePage() {
let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage())
assertGetElementById("page-number").innerText = String(page)
}
updateRange() {
let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxEntries())
assertGetElementById("entry-counter").innerText = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxEntries()}`
}
updateListings() {
getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
.then(res => {
let table = assertGetElementById("pkg-list")
table.innerHTML = ''
res.values.forEach((row) => {
table.appendChild(toHTML(row))
})
})
}
}
let STATE: State
function prevPage() {
let index = STATE.getEntryIndex()
STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()))
}
function nextPage() {
let index = STATE.getEntryIndex()
STATE.setEntryIndex(Math.min((Math.ceil(STATE.getMaxEntries() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()) - STATE.getEntriesPerPage(), index + STATE.getEntriesPerPage()))
}
function escapeHtml(str: string): string {
if(str === undefined) return ""
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
document.addEventListener("DOMContentLoaded", () => {
STATE = new State()
infoRequest()
.then(res => {
STATE.setMaxEntries(res.count)
assertGetElementById("hakurei-version").innerText = res.hakurei_version
STATE.updateRange()
STATE.updateListings()
})
assertGetElementById("count").addEventListener("change", (event) => {
STATE.setEntriesPerPage(parseInt((event.target as HTMLSelectElement).value))
})
assertGetElementById("sort").addEventListener("change", (event) => {
STATE.setSortOrder(parseInt((event.target as HTMLSelectElement).value))
})
})

View File

@@ -1,6 +0,0 @@
@use 'common';
html {
background-color: #d3d3d3;
color: black;
}

View File

@@ -1,6 +0,0 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2024"
}
}

View File

@@ -1,9 +0,0 @@
//go:build frontend
package main
import "embed"
//go:generate sh -c "sass ui/static/dark.scss ui/static/dark.css && sass ui/static/light.scss ui/static/light.css && tsc -p ui/static"
//go:embed ui/*
var content embed.FS

View File

@@ -1,7 +0,0 @@
//go:build !frontend
package main
import "testing/fstest"
var content fstest.MapFS

View File

@@ -1,2 +0,0 @@
// Import all test files to execute their suite registrations.
import "./test_tests.js";

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env node
// Many editors have terminal emulators built in, so running tests with NodeJS
// provides faster iteration, especially for those acclimated to test-driven
// development.
import "../all_tests.js";
import { StreamReporter, TESTS } from "./test.js";
// TypeScript doesn't like process and Deno as their type definitions aren't
// installed, but doesn't seem to complain if they're accessed through
// globalThis.
const process: any = (globalThis as any).process;
const Deno: any = (globalThis as any).Deno;
function getArgs(): string[] {
if (process) {
const [runtime, program, ...args] = process.argv;
return args;
}
if (Deno) return Deno.args;
return [];
}
function exit(code?: number): never {
if (Deno) Deno.exit(code);
if (process) process.exit(code);
throw `exited with code ${code ?? 0}`;
}
const args = getArgs();
let verbose = false;
if (args.length > 1) {
console.error("Too many arguments");
exit(1);
}
if (args.length === 1) {
if (args[0] === "-v" || args[0] === "--verbose" || args[0] === "-verbose") {
verbose = true;
} else if (args[0] !== "--") {
console.error(`Unknown argument '${args[0]}'`);
exit(1);
}
}
let reporter = new StreamReporter({ writeln: console.log }, verbose);
TESTS.run(reporter);
exit(reporter.succeeded() ? 0 : 1);

View File

@@ -1,13 +0,0 @@
<?xml version="1.0"?>
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
<!-- This triangle should match success-closed.svg, fill and stroke color notwithstanding. -->
<polygon points="0,0 100,50 0,100" fill="red" stroke="red" stroke-width="15" stroke-linejoin="round"/>
<!--
! y-coordinates go before x-coordinates here to highlight the difference
! (or, lack thereof) between these numbers and the ones in failure-open.svg;
! try a textual diff. Make sure to keep the numbers in sync!
-->
<line y1="30" x1="10" y2="70" x2="50" stroke="white" stroke-width="16"/>
<line y1="30" x1="50" y2="70" x2="10" stroke="white" stroke-width="16"/>
</svg>

Before

Width:  |  Height:  |  Size: 788 B

View File

@@ -1,35 +0,0 @@
<?xml version="1.0"?>
<!--
! This view box is a bit weird: the strokes assume they're working in a view
! box that spans from the (0,0) to (100,100), and indeed that is convenient
! conceptualizing the strokes, but the stroke itself has a considerable width
! that gets clipped by restrictive view box dimensions. Hence, the view is
! shifted from (0,0)(100,100) to (-20,-20)(120,120), to make room for the
! clipped stroke, while leaving behind an illusion of working in a view box
! spanning from (0,0) to (100,100).
!
! However, the resulting SVG is too close to the summary text, and CSS
! properties to add padding do not seem to work with `content:` (likely because
! they're anonymous replaced elements); thus, the width of the view is
! increased considerably to provide padding in the SVG itself, while leaving
! the strokes oblivious.
!
! It gets worse: the summary text isn't vertically aligned with the icon! As
! a flexbox cannot be used in a summary to align the marker with the text, the
! simplest and most effective solution is to reduce the height of the view box
! from 140 to 130, thereby removing some of the bottom padding present.
!
! All six SVGs use the same view box (and indeed, they refer to this comment)
! so that they all appear to be the same size and position relative to each
! other on the DOM—indeed, the view box dimensions, alongside the width,
! directly control their placement on the DOM.
!
! TL;DR: CSS is janky, overflow is weird, and SVG is awesome!
-->
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
<!-- This triangle should match success-open.svg, fill and stroke color notwithstanding. -->
<polygon points="0,0 100,0 50,100" fill="red" stroke="red" stroke-width="15" stroke-linejoin="round"/>
<!-- See the comment in failure-closed.svg before modifying this. -->
<line x1="30" y1="10" x2="70" y2="50" stroke="white" stroke-width="16"/>
<line x1="30" y1="50" x2="70" y2="10" stroke="white" stroke-width="16"/>
</svg>

View File

@@ -1,3 +0,0 @@
import "../all_tests.js";
import { GoTestReporter, TESTS } from "./test.js";
TESTS.run(new GoTestReporter());

View File

@@ -1,21 +0,0 @@
<?xml version="1.0"?>
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
<!-- This triangle should match success-closed.svg, fill and stroke color notwithstanding. -->
<polygon points="0,0 100,50 0,100" fill="blue" stroke="blue" stroke-width="15" stroke-linejoin="round"/>
<!--
! This path is extremely similar to the one in skip-open.svg; before
! making minor modifications, diff the two to understand how they should
! remain in sync.
-->
<path
d="M 50,50
A 23,23 270,1,1 30,30
l -10,20
m 10,-20
l -20,-10"
fill="none"
stroke="white"
stroke-width="12"
stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 812 B

View File

@@ -1,21 +0,0 @@
<?xml version="1.0"?>
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
<!-- This triangle should match success-open.svg, fill and stroke color notwithstanding. -->
<polygon points="0,0 100,0 50,100" fill="blue" stroke="blue" stroke-width="15" stroke-linejoin="round"/>
<!--
! This path is extremely similar to the one in skip-closed.svg; before
! making minor modifications, diff the two to understand how they should
! remain in sync.
-->
<path
d="M 50,50
A 23,23 270,1,1 70,30
l 10,-20
m -10,20
l -20,-10"
fill="none"
stroke="white"
stroke-width="12"
stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 812 B

View File

@@ -1,16 +0,0 @@
<?xml version="1.0"?>
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
<style>
.adaptive-stroke {
stroke: black;
}
@media (prefers-color-scheme: dark) {
.adaptive-stroke {
stroke: ghostwhite;
}
}
</style>
<!-- When updating this triangle, also update the other five SVGs. -->
<polygon points="0,0 100,50 0,100" fill="none" class="adaptive-stroke" stroke-width="15" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 572 B

View File

@@ -1,16 +0,0 @@
<?xml version="1.0"?>
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
<style>
.adaptive-stroke {
stroke: black;
}
@media (prefers-color-scheme: dark) {
.adaptive-stroke {
stroke: ghostwhite;
}
}
</style>
<!-- When updating this triangle, also update the other five SVGs. -->
<polygon points="0,0 100,0 50,100" fill="none" class="adaptive-stroke" stroke-width="15" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 572 B

View File

@@ -1,400 +0,0 @@
// =============================================================================
// DSL
type TestTree = TestGroup | Test;
type TestGroup = { name: string, children: TestTree[] };
type Test = { name: string, test: (t: TestController) => void };
export class TestRegistrar {
#suites: TestGroup[];
constructor() {
this.#suites = [];
}
suite(name: string, children: TestTree[]) {
checkDuplicates(name, children)
this.#suites.push({ name, children });
}
run(reporter: Reporter) {
reporter.register(this.#suites);
for (const suite of this.#suites) {
for (const c of suite.children) runTests(reporter, [suite.name], c);
}
reporter.finalize();
}
}
export let TESTS = new TestRegistrar();
// Register a suite in the global registrar.
export function suite(name: string, children: TestTree[]) {
TESTS.suite(name, children);
}
export function context(name: string, children: TestTree[]): TestTree {
checkDuplicates(name, children);
return { name, children };
}
export const group = context;
export function test(name: string, test: (t: TestController) => void): TestTree {
return { name, test };
}
function checkDuplicates(parent: string, names: { name: string }[]) {
let seen = new Set<string>();
for (const { name } of names) {
if (seen.has(name)) {
throw new RangeError(`duplicate name '${name}' in '${parent}'`);
}
seen.add(name);
}
}
export type TestState = "success" | "failure" | "skip";
class AbortSentinel {}
export class TestController {
#state: TestState;
logs: string[];
constructor() {
this.#state = "success";
this.logs = [];
}
getState(): TestState {
return this.#state;
}
fail() {
this.#state = "failure";
}
failed(): boolean {
return this.#state === "failure";
}
failNow(): never {
this.fail();
throw new AbortSentinel();
}
log(message: string) {
this.logs.push(message);
}
error(message: string) {
this.log(message);
this.fail();
}
fatal(message: string): never {
this.log(message);
this.failNow();
}
skip(message?: string): never {
if (message != null) this.log(message);
if (this.#state !== "failure") this.#state = "skip";
throw new AbortSentinel();
}
skipped(): boolean {
return this.#state === "skip";
}
}
// =============================================================================
// Execution
export interface TestResult {
state: TestState;
logs: string[];
}
function runTests(reporter: Reporter, parents: string[], node: TestTree) {
const path = [...parents, node.name];
if ("children" in node) {
for (const c of node.children) runTests(reporter, path, c);
return;
}
let controller = new TestController();
try {
node.test(controller);
} catch (e) {
if (!(e instanceof AbortSentinel)) {
controller.error(extractExceptionString(e));
}
}
reporter.update(path, { state: controller.getState(), logs: controller.logs });
}
function extractExceptionString(e: any): string {
// String() instead of .toString() as null and undefined don't have
// properties.
const s = String(e);
if (!(e instanceof Error && e.stack)) return s;
// v8 (Chromium, NodeJS) includes the error message, while Firefox and
// WebKit do not.
if (e.stack.startsWith(s)) return e.stack;
return `${s}\n${e.stack}`;
}
// =============================================================================
// Reporting
export interface Reporter {
register(suites: TestGroup[]): void
update(path: string[], result: TestResult): void;
finalize(): void;
}
export class NoOpReporter implements Reporter {
suites: TestGroup[];
results: ({ path: string[] } & TestResult)[];
finalized: boolean;
constructor() {
this.suites = [];
this.results = [];
this.finalized = false;
}
register(suites: TestGroup[]) {
this.suites = suites;
}
update(path: string[], result: TestResult) {
this.results.push({ path, ...result });
}
finalize() {
this.finalized = true;
}
}
export interface Stream {
writeln(s: string): void;
}
const SEP = " ";
export class StreamReporter implements Reporter {
stream: Stream;
verbose: boolean;
#successes: ({ path: string[] } & TestResult)[];
#failures: ({ path: string[] } & TestResult)[];
#skips: ({ path: string[] } & TestResult)[];
constructor(stream: Stream, verbose: boolean = false) {
this.stream = stream;
this.verbose = verbose;
this.#successes = [];
this.#failures = [];
this.#skips = [];
}
succeeded(): boolean {
return this.#successes.length > 0 && this.#failures.length === 0;
}
register(suites: TestGroup[]) {}
update(path: string[], result: TestResult) {
if (path.length === 0) throw new RangeError("path is empty");
const pathStr = path.join(SEP);
switch (result.state) {
case "success":
this.#successes.push({ path, ...result });
if (this.verbose) this.stream.writeln(`✅️ ${pathStr}`);
break;
case "failure":
this.#failures.push({ path, ...result });
this.stream.writeln(`⚠️ ${pathStr}`);
break;
case "skip":
this.#skips.push({ path, ...result });
this.stream.writeln(`⏭️ ${pathStr}`);
break;
}
}
finalize() {
if (this.verbose) this.#displaySection("successes", this.#successes, true);
this.#displaySection("failures", this.#failures);
this.#displaySection("skips", this.#skips);
this.stream.writeln("");
this.stream.writeln(
`${this.#successes.length} succeeded, ${this.#failures.length} failed`
+ (this.#skips.length ? `, ${this.#skips.length} skipped` : "")
);
}
#displaySection(name: string, data: ({ path: string[] } & TestResult)[], ignoreEmpty: boolean = false) {
if (!data.length) return;
// Transform [{ path: ["a", "b", "c"] }, { path: ["a", "b", "d"] }]
// into { "a b": ["c", "d"] }.
let pathMap = new Map<string, ({ name: string } & TestResult)[]>();
for (const t of data) {
if (t.path.length === 0) throw new RangeError("path is empty");
const key = t.path.slice(0, -1).join(SEP);
if (!pathMap.has(key)) pathMap.set(key, []);
pathMap.get(key)!.push({ name: t.path.at(-1)!, ...t });
}
this.stream.writeln("");
this.stream.writeln(name.toUpperCase());
this.stream.writeln("=".repeat(name.length));
for (let [path, tests] of pathMap) {
if (ignoreEmpty) tests = tests.filter((t) => t.logs.length);
if (tests.length === 0) continue;
if (tests.length === 1) {
this.#writeOutput(tests[0], path ? `${path}${SEP}` : "", false);
} else {
this.stream.writeln(path);
for (const t of tests) this.#writeOutput(t, " - ", true);
}
}
}
#writeOutput(test: { name: string } & TestResult, prefix: string, nested: boolean) {
let output = "";
if (test.logs.length) {
// Individual logs might span multiple lines, so join them together
// then split it again.
const logStr = test.logs.join("\n");
const lines = logStr.split("\n");
if (lines.length <= 1) {
output = `: ${logStr}`;
} else {
const padding = nested ? " " : " ";
output = ":\n" + lines.map((line) => padding + line).join("\n");
}
}
this.stream.writeln(`${prefix}${test.name}${output}`);
}
}
function assertGetElementById(id: string): HTMLElement {
let elem = document.getElementById(id);
if (elem == null) throw new ReferenceError(`element with ID '${id}' missing from DOM`);
return elem;
}
export class DOMReporter implements Reporter {
register(suites: TestGroup[]) {}
update(path: string[], result: TestResult) {
if (path.length === 0) throw new RangeError("path is empty");
if (result.state === "skip") {
assertGetElementById("skip-counter-text").classList.remove("hidden");
}
const counter = assertGetElementById(`${result.state}-counter`);
counter.innerText = (Number(counter.innerText) + 1).toString();
let parent = assertGetElementById("root");
for (const node of path) {
let child: HTMLDetailsElement | null = null;
let summary: HTMLElement | null = null;
let d: Element;
outer: for (d of parent.children) {
if (!(d instanceof HTMLDetailsElement)) continue;
for (const s of d.children) {
if (!(s instanceof HTMLElement)) continue;
if (!(s.tagName === "SUMMARY" && s.innerText === node)) continue;
child = d;
summary = s;
break outer;
}
}
if (!child) {
child = document.createElement("details");
child.className = "test-node";
child.ariaRoleDescription = "test";
summary = document.createElement("summary");
summary.appendChild(document.createTextNode(node));
summary.ariaRoleDescription = "test name";
child.appendChild(summary);
parent.appendChild(child);
}
if (!summary) throw new Error("unreachable as assigned above");
switch (result.state) {
case "failure":
child.open = true;
child.classList.add("failure");
child.classList.remove("skip");
child.classList.remove("success");
summary.setAttribute("aria-labelledby", "failure-description");
break;
case "skip":
if (child.classList.contains("failure")) break;
child.classList.add("skip");
child.classList.remove("success");
summary.setAttribute("aria-labelledby", "skip-description");
break;
case "success":
if (child.classList.contains("failure") || child.classList.contains("skip")) break;
child.classList.add("success");
summary.setAttribute("aria-labelledby", "success-description");
break;
}
parent = child;
}
const p = document.createElement("p");
p.classList.add("test-desc");
if (result.logs.length) {
const pre = document.createElement("pre");
pre.appendChild(document.createTextNode(result.logs.join("\n")));
p.appendChild(pre);
} else {
p.classList.add("italic");
p.appendChild(document.createTextNode("No output."));
}
parent.appendChild(p);
}
finalize() {}
}
interface GoNode {
name: string;
subtests: GoNode[] | null;
}
// Used to display results via `go test`, via some glue code from the Go side.
export class GoTestReporter implements Reporter {
// Convert a test tree into the one expected by the Go code.
static serialize(node: TestTree): GoNode {
return {
name: node.name,
subtests: "children" in node ? node.children.map(GoTestReporter.serialize) : null,
};
}
register(suites: TestGroup[]) {
console.log(JSON.stringify(suites.map(GoTestReporter.serialize)));
}
update(path: string[], result: TestResult) {
let state: number;
switch (result.state) {
case "success": state = 0; break;
case "failure": state = 1; break;
case "skip": state = 2; break;
}
console.log(JSON.stringify({ path: path, state, logs: result.logs }));
}
finalize() {
console.log("null");
}
}

View File

@@ -1,39 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/testui/style.css">
<title>PkgServer Tests</title>
</head>
<body>
<noscript>
I hate JavaScript as much as you, but this page runs tests written in
JavaScript to test the functionality of code written in JavaScript, so it
wouldn't make sense for it to work without JavaScript. <strong>Please turn
JavaScript on!</strong>
</noscript>
<h1>PkgServer Tests</h1>
<main>
<p id="counters">
<span id="success-counter">0</span> succeeded, <span id="failure-counter">0</span>
failed<span id="skip-counter-text" class="hidden">, <span id="skip-counter">0</span> skipped</span>.
</p>
<p hidden id="success-description">Successful test</p>
<p hidden id="failure-description">Failed test</p>
<p hidden id="skip-description">Partially or fully skipped test</p>
<div id="root">
</div>
<script type="module">
import "/test/all_tests.js";
import { DOMReporter, TESTS } from "/test/lib/test.js";
TESTS.run(new DOMReporter());
</script>
</main>
</body>
</html>

View File

@@ -1,87 +0,0 @@
:root {
--bg: #d3d3d3;
--fg: black;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #2c2c2c;
--fg: ghostwhite;
}
}
html {
background-color: var(--bg);
color: var(--fg);
}
h1, p, summary, noscript {
font-family: sans-serif;
}
noscript {
font-size: 16pt;
}
.root {
margin: 1rem 0;
}
details.test-node {
margin-left: 1rem;
padding: 0.2rem 0.5rem;
border-left: 2px dashed var(--fg);
> summary {
cursor: pointer;
}
&.success > summary::marker {
/*
* WebKit only supports color and font-size properties in ::marker [1],
* and its ::-webkit-details-marker only supports hiding the marker
* entirely [2], contrary to mdn's example [3]; thus, set a color as
* a fallback: while it may not be accessible for colorblind
* individuals, it's better than no indication of a test's state for
* anyone, as that there's no other way to include an indication in the
* marker on WebKit.
*
* [1]: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::marker#browser_compatibility
* [2]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#default_style
* [3]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#changing_the_summarys_icon
*/
color: var(--fg);
content: url("/testui/success-closed.svg") / "success";
}
&.success[open] > summary::marker {
content: url("/testui/success-open.svg") / "success";
}
&.failure > summary::marker {
color: red;
content: url("/testui/failure-closed.svg") / "failure";
}
&.failure[open] > summary::marker {
content: url("/testui/failure-open.svg") / "failure";
}
&.skip > summary::marker {
color: blue;
content: url("/testui/skip-closed.svg") / "skip";
}
&.skip[open] > summary::marker {
content: url("/testui/skip-open.svg") / "skip";
}
}
p.test-desc {
margin: 0 0 0 1rem;
padding: 2px 0;
> pre {
margin: 0;
}
}
.hidden {
display: none;
}
.italic {
font-style: italic;
}

View File

@@ -1,85 +0,0 @@
import { NoOpReporter, TestRegistrar, context, group, suite, test } from "./lib/test.js";
suite("dog", [
group("tail", [
test("wags when happy", (t) => {
if (0 / 0 !== Infinity / Infinity) {
t.fatal("undefined must not be defined");
}
}),
test("idle when down", (t) => {
t.log("test test");
t.error("dog whining noises go here");
}),
]),
test("likes headpats", (t) => {
if (2 !== 2) {
t.error("IEEE 754 violated: 2 is NaN");
}
}),
context("near cat", [
test("is ecstatic", (t) => {
if (("b" + "a" + + "a" + "a").toLowerCase() === "banana") {
t.error("🍌🍌🍌");
t.error("🍌🍌🍌");
t.error("🍌🍌🍌");
t.failNow();
}
}),
test("playfully bites cats' tails", (t) => {
t.log("arf!");
throw new Error("nom");
}),
]),
]);
suite("cat", [
test("likes headpats", (t) => {
t.log("meow");
}),
test("owns skipping rope", (t) => {
t.skip("this cat is stuck in your machine!");
t.log("never logged");
}),
test("tester tester", (t) => {
const r = new TestRegistrar();
r.suite("explod", [
test("with yarn", (t) => {
t.log("YAY");
}),
]);
const reporter = new NoOpReporter();
r.run(reporter);
if (reporter.suites.length !== 1) {
t.fatal(`incorrect number of suites registered got=${reporter.suites.length} want=1`);
}
const suite = reporter.suites[0];
if (suite.name !== "explod") {
t.error(`suite name incorrect got='${suite.name}' want='explod'`);
}
if (suite.children.length !== 1) {
t.fatal(`incorrect number of suite children got=${suite.children.length} want=1`);
}
const test_ = suite.children[0];
if (test_.name !== "with yarn") {
t.error(`incorrect test name got='${test_.name}' want='with yarn'`);
}
if ("children" in test_) {
t.error(`expected leaf node, got group of ${test_.children.length} children`);
}
if (!reporter.finalized) t.error(`expected reporter to have been finalized`);
if (reporter.results.length !== 1) {
t.fatal(`incorrect result count got=${reporter.results.length} want=1`);
}
const result = reporter.results[0];
if (!(result.path.length === 2 &&
result.path[0] === "explod" &&
result.path[1] === "with yarn")) {
t.error(`incorrect result path got=${result.path} want=["explod", "with yarn"]`);
}
if (result.state !== "success") t.error(`expected test to succeed`);
if (!(result.logs.length === 1 && result.logs[0] === "YAY")) {
t.error(`incorrect result logs got=${result.logs} want=["YAY"]`);
}
}),
]);

View File

@@ -1,6 +0,0 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2024"
}
}

View File

@@ -7,8 +7,8 @@
#endif
#define SHAREFS_MEDIA_RW_ID (1 << 10) - 1 /* owning gid presented to userspace */
#define SHAREFS_PERM_DIR 0700 /* permission bits for directories presented to userspace */
#define SHAREFS_PERM_REG 0600 /* permission bits for regular files presented to userspace */
#define SHAREFS_PERM_DIR 0770 /* permission bits for directories presented to userspace */
#define SHAREFS_PERM_REG 0660 /* permission bits for regular files presented to userspace */
#define SHAREFS_FORBIDDEN_FLAGS O_DIRECT /* these open flags are cleared unconditionally */
/* sharefs_private is populated by sharefs_init and contains process-wide context */

View File

@@ -19,22 +19,21 @@ import (
"encoding/gob"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"path"
"path/filepath"
"runtime"
"runtime/cgo"
"strconv"
"syscall"
"unsafe"
"hakurei.app/check"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/container/std"
"hakurei.app/fhs"
"hakurei.app/hst"
"hakurei.app/internal/helper/proc"
"hakurei.app/internal/info"
@@ -85,7 +84,10 @@ func destroySetup(private_data unsafe.Pointer) (ok bool) {
}
//export sharefs_init
func sharefs_init(_ *C.struct_fuse_conn_info, cfg *C.struct_fuse_config) unsafe.Pointer {
func sharefs_init(
_ *C.struct_fuse_conn_info,
cfg *C.struct_fuse_config,
) unsafe.Pointer {
ctx := C.fuse_get_context()
priv := (*C.struct_sharefs_private)(ctx.private_data)
setup := cgo.Handle(priv.setup).Value().(*setupState)
@@ -103,7 +105,11 @@ func sharefs_init(_ *C.struct_fuse_conn_info, cfg *C.struct_fuse_config) unsafe.
cfg.negative_timeout = 0
// all future filesystem operations happen through this dirfd
if fd, err := syscall.Open(setup.Source.String(), syscall.O_DIRECTORY|syscall.O_RDONLY|syscall.O_CLOEXEC, 0); err != nil {
if fd, err := syscall.Open(
setup.Source.String(),
syscall.O_DIRECTORY|syscall.O_RDONLY|syscall.O_CLOEXEC,
0,
); err != nil {
log.Printf("cannot open %q: %v", setup.Source, err)
goto fail
} else if err = syscall.Fchdir(fd); err != nil {
@@ -138,9 +144,9 @@ func sharefs_destroy(private_data unsafe.Pointer) {
func showHelp(args *fuseArgs) {
executableName := sharefsName
if args.argc > 0 {
executableName = path.Base(C.GoString(*args.argv))
executableName = filepath.Base(C.GoString(*args.argv))
} else if name, err := os.Executable(); err == nil {
executableName = path.Base(name)
executableName = filepath.Base(name)
}
fmt.Printf("usage: %s [options] <mountpoint>\n\n", executableName)
@@ -169,8 +175,11 @@ func parseOpts(args *fuseArgs, setup *setupState, log *log.Logger) (ok bool) {
// Decimal string representation of gid to set when running as root.
setgid *C.char
// Decimal string representation of open file descriptor to read setupState from.
// This is an internal detail for containerisation and must not be specified directly.
// Decimal string representation of open file descriptor to read
// setupState from.
//
// This is an internal detail for containerisation and must not be
// specified directly.
setup *C.char
}
@@ -253,7 +262,8 @@ func parseOpts(args *fuseArgs, setup *setupState, log *log.Logger) (ok bool) {
return true
}
// copyArgs returns a heap allocated copy of an argument slice in fuse_args representation.
// copyArgs returns a heap allocated copy of an argument slice in fuse_args
// representation.
func copyArgs(s ...string) fuseArgs {
if len(s) == 0 {
return fuseArgs{argc: 0, argv: nil, allocated: 0}
@@ -269,6 +279,7 @@ func copyArgs(s ...string) fuseArgs {
func freeArgs(args *fuseArgs) { C.fuse_opt_free_args(args) }
// unsafeAddArgument adds an argument to fuseArgs via fuse_opt_add_arg.
//
// The last byte of arg must be 0.
func unsafeAddArgument(args *fuseArgs, arg string) {
C.fuse_opt_add_arg(args, (*C.char)(unsafe.Pointer(unsafe.StringData(arg))))
@@ -288,8 +299,8 @@ func _main(s ...string) (exitCode int) {
args := copyArgs(s...)
defer freeArgs(&args)
// this causes the kernel to enforce access control based on
// struct stat populated by sharefs_getattr
// this causes the kernel to enforce access control based on struct stat
// populated by sharefs_getattr
unsafeAddArgument(&args, "-odefault_permissions\x00")
var priv C.struct_sharefs_private
@@ -453,15 +464,19 @@ func _main(s ...string) (exitCode int) {
z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr
}
z.Bind(z.Path, z.Path, 0)
setup.Fuse = int(proc.ExtraFileSlice(&z.ExtraFiles, os.NewFile(uintptr(C.fuse_session_fd(se)), "fuse")))
setup.Fuse = int(proc.ExtraFileSlice(
&z.ExtraFiles,
os.NewFile(uintptr(C.fuse_session_fd(se)), "fuse"),
))
var setupWriter io.WriteCloser
if fd, w, err := container.Setup(&z.ExtraFiles); err != nil {
var setupPipe [2]*os.File
if r, w, err := os.Pipe(); err != nil {
log.Println(err)
return 5
} else {
z.Args = append(z.Args, "-osetup="+strconv.Itoa(fd))
setupWriter = w
z.Args = append(z.Args, "-osetup="+strconv.Itoa(3+len(z.ExtraFiles)))
z.ExtraFiles = append(z.ExtraFiles, r)
setupPipe[0], setupPipe[1] = r, w
}
if err := z.Start(); err != nil {
@@ -472,6 +487,9 @@ func _main(s ...string) (exitCode int) {
}
return 5
}
if err := setupPipe[0].Close(); err != nil {
log.Println(err)
}
if err := z.Serve(); err != nil {
if m, ok := message.GetMessage(err); ok {
log.Println(m)
@@ -481,10 +499,10 @@ func _main(s ...string) (exitCode int) {
return 5
}
if err := gob.NewEncoder(setupWriter).Encode(&setup); err != nil {
if err := gob.NewEncoder(setupPipe[1]).Encode(&setup); err != nil {
log.Println(err)
return 5
} else if err = setupWriter.Close(); err != nil {
} else if err = setupPipe[1].Close(); err != nil {
log.Println(err)
}

View File

@@ -6,7 +6,7 @@ import (
"reflect"
"testing"
"hakurei.app/container/check"
"hakurei.app/check"
)
func TestParseOpts(t *testing.T) {

View File

@@ -1,3 +1,10 @@
// The sharefs FUSE filesystem is a permissionless shared filesystem.
//
// This filesystem is the primary means of file sharing between hakurei
// application containers. It serves the same purpose in Rosa OS as /sdcard
// does in AOSP.
//
// See help message for all available options.
package main
import (

View File

@@ -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())
}
}

View File

@@ -4,8 +4,8 @@ import (
"encoding/gob"
"fmt"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/check"
"hakurei.app/fhs"
)
func init() { gob.Register(new(AutoEtcOp)) }

View File

@@ -5,8 +5,8 @@ import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/check"
"hakurei.app/internal/stub"
)
func TestAutoEtcOp(t *testing.T) {

View File

@@ -4,8 +4,8 @@ import (
"encoding/gob"
"fmt"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/check"
"hakurei.app/fhs"
"hakurei.app/message"
)

View File

@@ -5,9 +5,9 @@ import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/check"
"hakurei.app/container/std"
"hakurei.app/container/stub"
"hakurei.app/internal/stub"
"hakurei.app/message"
)

View File

@@ -3,6 +3,8 @@ package container
import (
"syscall"
"unsafe"
"hakurei.app/ext"
)
const (
@@ -51,15 +53,15 @@ func capset(hdrp *capHeader, datap *[2]capData) error {
// capBoundingSetDrop drops a capability from the calling thread's capability bounding set.
func capBoundingSetDrop(cap uintptr) error {
return Prctl(syscall.PR_CAPBSET_DROP, cap, 0)
return ext.Prctl(syscall.PR_CAPBSET_DROP, cap, 0)
}
// capAmbientClearAll clears the ambient capability set of the calling thread.
func capAmbientClearAll() error {
return Prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0)
return ext.Prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0)
}
// capAmbientRaise adds to the ambient capability set of the calling thread.
func capAmbientRaise(cap uintptr) error {
return Prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, cap)
return ext.Prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, cap)
}

View File

@@ -16,10 +16,12 @@ import (
. "syscall"
"time"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/check"
"hakurei.app/container/seccomp"
"hakurei.app/container/std"
"hakurei.app/ext"
"hakurei.app/fhs"
"hakurei.app/internal/landlock"
"hakurei.app/message"
)
@@ -27,9 +29,6 @@ const (
// CancelSignal is the signal expected by container init on context cancel.
// A custom [Container.Cancel] function must eventually deliver this signal.
CancelSignal = SIGUSR2
// Timeout for writing initParams to Container.setup.
initSetupTimeout = 5 * time.Second
)
type (
@@ -41,10 +40,10 @@ type (
// Whether to set SchedPolicy and SchedPriority via sched_setscheduler(2).
SetScheduler bool
// Scheduling policy to set via sched_setscheduler(2).
SchedPolicy std.SchedPolicy
SchedPolicy ext.SchedPolicy
// Scheduling priority to set via sched_setscheduler(2). The zero value
// implies the minimum value supported by the current SchedPolicy.
SchedPriority std.Int
SchedPriority ext.Int
// Cgroup fd, nil to disable.
Cgroup *int
// ExtraFiles passed through to initial process in the container, with
@@ -52,7 +51,7 @@ type (
ExtraFiles []*os.File
// Write end of a pipe connected to the init to deliver [Params].
setup *os.File
setup [2]*os.File
// Cancels the context passed to the underlying cmd.
cancel context.CancelFunc
// Closed after Wait returns. Keeps the spawning thread alive.
@@ -185,31 +184,24 @@ var (
closeOnExecErr error
)
// ensureCloseOnExec ensures all currently open file descriptors have the syscall.FD_CLOEXEC flag set.
// This is only ran once as it is intended to handle files left open by the parent, and any file opened
// on this side should already have syscall.FD_CLOEXEC set.
// ensureCloseOnExec ensures all currently open file descriptors have the
// syscall.FD_CLOEXEC flag set.
//
// This is only ran once as it is intended to handle files left open by the
// parent, and any file opened on this side should already have
// syscall.FD_CLOEXEC set.
func ensureCloseOnExec() error {
closeOnExecOnce.Do(func() {
const fdPrefixPath = "/proc/self/fd/"
var entries []os.DirEntry
if entries, closeOnExecErr = os.ReadDir(fdPrefixPath); closeOnExecErr != nil {
return
}
var fd int
for _, ent := range entries {
if fd, closeOnExecErr = strconv.Atoi(ent.Name()); closeOnExecErr != nil {
break // not reached
}
CloseOnExec(fd)
}
})
closeOnExecOnce.Do(func() { closeOnExecErr = doCloseOnExec() })
if closeOnExecErr == nil {
return nil
}
return &StartError{Fatal: true, Step: "set FD_CLOEXEC on all open files", Err: closeOnExecErr, Passthrough: true}
return &StartError{
Fatal: true,
Step: "set FD_CLOEXEC on all open files",
Err: closeOnExecErr,
Passthrough: true,
}
}
// Start starts the container init. The init process blocks until Serve is called.
@@ -293,14 +285,16 @@ func (p *Container) Start() error {
}
// place setup pipe before user supplied extra files, this is later restored by init
if fd, f, err := Setup(&p.cmd.ExtraFiles); err != nil {
if r, w, err := os.Pipe(); err != nil {
return &StartError{
Fatal: true,
Step: "set up params stream",
Err: err,
}
} else {
p.setup = f
fd := 3 + len(p.cmd.ExtraFiles)
p.cmd.ExtraFiles = append(p.cmd.ExtraFiles, r)
p.setup[0], p.setup[1] = r, w
p.cmd.Env = []string{setupEnv + "=" + strconv.Itoa(fd)}
}
p.cmd.ExtraFiles = append(p.cmd.ExtraFiles, p.ExtraFiles...)
@@ -314,7 +308,7 @@ func (p *Container) Start() error {
done <- func() error {
// PR_SET_NO_NEW_PRIVS: thread-directed but acts on all processes
// created from the calling thread
if err := SetNoNewPrivs(); err != nil {
if err := setNoNewPrivs(); err != nil {
return &StartError{
Fatal: true,
Step: "prctl(PR_SET_NO_NEW_PRIVS)",
@@ -324,15 +318,17 @@ func (p *Container) Start() error {
// landlock: depends on per-thread state but acts on a process group
{
rulesetAttr := &RulesetAttr{Scoped: LANDLOCK_SCOPE_SIGNAL}
rulesetAttr := &landlock.RulesetAttr{
Scoped: landlock.LANDLOCK_SCOPE_SIGNAL,
}
if !p.HostAbstract {
rulesetAttr.Scoped |= LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET
rulesetAttr.Scoped |= landlock.LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET
}
if abi, err := LandlockGetABI(); err != nil {
if p.HostAbstract {
if abi, err := landlock.GetABI(); err != nil {
if p.HostAbstract || !p.HostNet {
// landlock can be skipped here as it restricts access
// to resources already covered by namespaces (pid)
// to resources already covered by namespaces (pid, net)
goto landlockOut
}
return &StartError{Step: "get landlock ABI", Err: err}
@@ -358,7 +354,7 @@ func (p *Container) Start() error {
}
} else {
p.msg.Verbosef("enforcing landlock ruleset %s", rulesetAttr)
if err = LandlockRestrictSelf(rulesetFd, 0); err != nil {
if err = landlock.RestrictSelf(rulesetFd, 0); err != nil {
_ = Close(rulesetFd)
return &StartError{
Fatal: true,
@@ -378,7 +374,7 @@ func (p *Container) Start() error {
// sched_setscheduler: thread-directed but acts on all processes
// created from the calling thread
if p.SetScheduler {
if p.SchedPolicy < 0 || p.SchedPolicy > std.SCHED_LAST {
if p.SchedPolicy < 0 || p.SchedPolicy > ext.SCHED_LAST {
return &StartError{
Fatal: false,
Step: "set scheduling policy",
@@ -434,24 +430,33 @@ func (p *Container) Start() error {
// Serve serves [Container.Params] to the container init.
//
// Serve must only be called once.
func (p *Container) Serve() error {
if p.setup == nil {
func (p *Container) Serve() (err error) {
if p.setup[0] == nil || p.setup[1] == nil {
panic("invalid serve")
}
setup := p.setup
p.setup = nil
if err := setup.SetDeadline(time.Now().Add(initSetupTimeout)); err != nil {
done := make(chan struct{})
defer func() {
if closeErr := p.setup[1].Close(); err == nil {
err = closeErr
}
if err != nil {
p.cancel()
}
close(done)
p.setup[0], p.setup[1] = nil, nil
}()
if err = p.setup[0].Close(); err != nil {
return &StartError{
Fatal: true,
Step: "set init pipe deadline",
Step: "close read end of init pipe",
Err: err,
Passthrough: true,
}
}
if p.Path == nil {
p.cancel()
return &StartError{
Step: "invalid executable pathname",
Err: EINVAL,
@@ -467,18 +472,27 @@ func (p *Container) Serve() error {
p.SeccompRules = make([]std.NativeRule, 0)
}
err := gob.NewEncoder(setup).Encode(&initParams{
t := time.Now().UTC()
go func(f *os.File) {
select {
case <-p.ctx.Done():
if cancelErr := f.SetWriteDeadline(t); cancelErr != nil {
p.msg.Verbose(err)
}
case <-done:
p.msg.Verbose("setup payload took", time.Since(t))
return
}
}(p.setup[1])
return gob.NewEncoder(p.setup[1]).Encode(&initParams{
p.Params,
Getuid(),
Getgid(),
len(p.ExtraFiles),
p.msg.IsVerbose(),
})
_ = setup.Close()
if err != nil {
p.cancel()
}
return err
}
// Wait blocks until the container init process to exit and releases any

View File

@@ -16,18 +16,21 @@ import (
"strings"
"syscall"
"testing"
"time"
"hakurei.app/check"
"hakurei.app/command"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/container/seccomp"
"hakurei.app/container/std"
"hakurei.app/container/vfs"
"hakurei.app/ext"
"hakurei.app/fhs"
"hakurei.app/hst"
"hakurei.app/internal/info"
"hakurei.app/internal/landlock"
"hakurei.app/internal/params"
"hakurei.app/ldd"
"hakurei.app/message"
"hakurei.app/vfs"
)
// Note: this package requires cgo, which is unavailable in the Go playground.
@@ -83,9 +86,9 @@ func TestStartError(t *testing.T) {
{"params env", &container.StartError{
Fatal: true,
Step: "set up params stream",
Err: container.ErrReceiveEnv,
Err: params.ErrReceiveEnv,
}, "set up params stream: environment variable not set",
container.ErrReceiveEnv, syscall.EBADF,
params.ErrReceiveEnv, syscall.EBADF,
"cannot set up params stream: environment variable not set"},
{"params", &container.StartError{
@@ -258,7 +261,7 @@ var containerTestCases = []struct {
1000, 100, nil, 0, std.PresetExt},
{"custom rules", true, true, true, false,
emptyOps, emptyMnt,
1, 31, []std.NativeRule{{Syscall: std.ScmpSyscall(syscall.SYS_SETUID), Errno: std.ScmpErrno(syscall.EPERM)}}, 0, std.PresetExt},
1, 31, []std.NativeRule{{Syscall: ext.SyscallNum(syscall.SYS_SETUID), Errno: std.ScmpErrno(syscall.EPERM)}}, 0, std.PresetExt},
{"tmpfs", true, false, false, true,
earlyOps(new(container.Ops).
@@ -435,11 +438,8 @@ func TestContainer(t *testing.T) {
wantOps, wantOpsCtx := tc.ops(t)
wantMnt := tc.mnt(t, wantOpsCtx)
ctx, cancel := context.WithTimeout(t.Context(), helperDefaultTimeout)
defer cancel()
var libPaths []*check.Absolute
c := helperNewContainerLibPaths(ctx, &libPaths, "container", strconv.Itoa(i))
c := helperNewContainerLibPaths(t.Context(), &libPaths, "container", strconv.Itoa(i))
c.Uid = tc.uid
c.Gid = tc.gid
c.Hostname = hostnameFromTestCase(tc.name)
@@ -449,7 +449,6 @@ func TestContainer(t *testing.T) {
} else {
c.Stdout, c.Stderr = os.Stdout, os.Stderr
}
c.WaitDelay = helperDefaultTimeout
*c.Ops = append(*c.Ops, *wantOps...)
c.SeccompRules = tc.rules
c.SeccompFlags = tc.flags | seccomp.AllowMultiarch
@@ -457,6 +456,15 @@ func TestContainer(t *testing.T) {
c.SeccompDisable = !tc.filter
c.RetainSession = tc.session
c.HostNet = tc.net
if info.CanDegrade {
if _, err := landlock.GetABI(); err != nil {
if !errors.Is(err, syscall.ENOSYS) {
t.Fatalf("LandlockGetABI: error = %v", err)
}
c.HostAbstract = true
t.Log("Landlock LSM is unavailable, enabling HostAbstract")
}
}
c.
Readonly(check.MustAbs(pathReadonly), 0755).
@@ -552,11 +560,10 @@ func testContainerCancel(
) func(t *testing.T) {
return func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), helperDefaultTimeout)
ctx, cancel := context.WithCancel(t.Context())
c := helperNewContainer(ctx, "block")
c.Stdout, c.Stderr = os.Stdout, os.Stderr
c.WaitDelay = helperDefaultTimeout
if containerExtra != nil {
containerExtra(c)
}
@@ -737,8 +744,7 @@ func init() {
const (
envDoCheck = "HAKUREI_TEST_DO_CHECK"
helperDefaultTimeout = 5 * time.Second
helperInnerPath = "/usr/bin/helper"
helperInnerPath = "/usr/bin/helper"
)
var (

View File

@@ -1,8 +1,10 @@
package container
import (
"context"
"io"
"io/fs"
"net"
"os"
"os/exec"
"os/signal"
@@ -12,6 +14,9 @@ import (
"hakurei.app/container/seccomp"
"hakurei.app/container/std"
"hakurei.app/ext"
"hakurei.app/internal/netlink"
"hakurei.app/internal/params"
"hakurei.app/message"
)
@@ -52,7 +57,7 @@ type syscallDispatcher interface {
// isatty provides [Isatty].
isatty(fd int) bool
// receive provides [Receive].
receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error)
receive(key string, e any, fdp *int) (closeFunc func() error, err error)
// bindMount provides procPaths.bindMount.
bindMount(msg message.Msg, source, target string, flags uintptr) error
@@ -63,7 +68,7 @@ type syscallDispatcher interface {
// ensureFile provides ensureFile.
ensureFile(name string, perm, pperm os.FileMode) error
// mustLoopback provides mustLoopback.
mustLoopback(msg message.Msg)
mustLoopback(ctx context.Context, msg message.Msg)
// seccompLoad provides [seccomp.Load].
seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error
@@ -141,18 +146,18 @@ func (k direct) new(f func(k syscallDispatcher)) { go f(k) }
func (direct) lockOSThread() { runtime.LockOSThread() }
func (direct) setPtracer(pid uintptr) error { return SetPtracer(pid) }
func (direct) setDumpable(dumpable uintptr) error { return SetDumpable(dumpable) }
func (direct) setNoNewPrivs() error { return SetNoNewPrivs() }
func (direct) setPtracer(pid uintptr) error { return ext.SetPtracer(pid) }
func (direct) setDumpable(dumpable uintptr) error { return ext.SetDumpable(dumpable) }
func (direct) setNoNewPrivs() error { return setNoNewPrivs() }
func (direct) lastcap(msg message.Msg) uintptr { return LastCap(msg) }
func (direct) capset(hdrp *capHeader, datap *[2]capData) error { return capset(hdrp, datap) }
func (direct) capBoundingSetDrop(cap uintptr) error { return capBoundingSetDrop(cap) }
func (direct) capAmbientClearAll() error { return capAmbientClearAll() }
func (direct) capAmbientRaise(cap uintptr) error { return capAmbientRaise(cap) }
func (direct) isatty(fd int) bool { return Isatty(fd) }
func (direct) receive(key string, e any, fdp *uintptr) (func() error, error) {
return Receive(key, e, fdp)
func (direct) isatty(fd int) bool { return ext.Isatty(fd) }
func (direct) receive(key string, e any, fdp *int) (func() error, error) {
return params.Receive(key, e, fdp)
}
func (direct) bindMount(msg message.Msg, source, target string, flags uintptr) error {
@@ -167,7 +172,50 @@ func (k direct) mountTmpfs(fsname, target string, flags uintptr, size int, perm
func (direct) ensureFile(name string, perm, pperm os.FileMode) error {
return ensureFile(name, perm, pperm)
}
func (direct) mustLoopback(msg message.Msg) { mustLoopback(msg) }
func (direct) mustLoopback(ctx context.Context, msg message.Msg) {
var lo int
if ifi, err := net.InterfaceByName("lo"); err != nil {
msg.GetLogger().Fatalln(err)
} else {
lo = ifi.Index
}
c, err := netlink.DialRoute(0)
if err != nil {
msg.GetLogger().Fatalln(err)
}
must := func(err error) {
if err == nil {
return
}
if closeErr := c.Close(); closeErr != nil {
msg.Verbosef("cannot close RTNETLINK: %v", closeErr)
}
switch err.(type) {
case *os.SyscallError:
msg.GetLogger().Fatalf("cannot %v", err)
case syscall.Errno:
msg.GetLogger().Fatalf("RTNETLINK answers: %v", err)
default:
if err == context.DeadlineExceeded || err == context.Canceled {
msg.GetLogger().Fatalf("interrupted RTNETLINK operation")
}
msg.GetLogger().Fatal("RTNETLINK answers with malformed message")
}
}
must(c.SendNewaddrLo(ctx, uint32(lo)))
must(c.SendIfInfomsg(ctx, syscall.RTM_NEWLINK, 0, &syscall.IfInfomsg{
Family: syscall.AF_UNSPEC,
Index: int32(lo),
Flags: syscall.IFF_UP,
Change: syscall.IFF_UP,
}))
must(c.Close())
}
func (direct) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error {
return seccomp.Load(rules, flags)

View File

@@ -2,6 +2,7 @@ package container
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
@@ -18,7 +19,7 @@ import (
"hakurei.app/container/seccomp"
"hakurei.app/container/std"
"hakurei.app/container/stub"
"hakurei.app/internal/stub"
"hakurei.app/message"
)
@@ -389,7 +390,7 @@ func (k *kstub) isatty(fd int) bool {
return expect.Ret.(bool)
}
func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error) {
func (k *kstub) receive(key string, e any, fdp *int) (closeFunc func() error, err error) {
k.Helper()
expect := k.Expects("receive")
@@ -407,10 +408,17 @@ func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error
}
return nil
}
// avoid changing test cases
var fdpComp *uintptr
if fdp != nil {
fdpComp = new(uintptr(*fdp))
}
err = expect.Error(
stub.CheckArg(k.Stub, "key", key, 0),
stub.CheckArgReflect(k.Stub, "e", e, 1),
stub.CheckArgReflect(k.Stub, "fdp", fdp, 2))
stub.CheckArgReflect(k.Stub, "fdp", fdpComp, 2))
// 3 is unused so stores params
if expect.Args[3] != nil {
@@ -425,7 +433,7 @@ func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error
if expect.Args[4] != nil {
if v, ok := expect.Args[4].(uintptr); ok && v >= 3 {
if fdp != nil {
*fdp = v
*fdp = int(v)
}
}
}
@@ -468,7 +476,7 @@ func (k *kstub) ensureFile(name string, perm, pperm os.FileMode) error {
stub.CheckArg(k.Stub, "pperm", pperm, 2))
}
func (*kstub) mustLoopback(message.Msg) { /* noop */ }
func (*kstub) mustLoopback(context.Context, message.Msg) { /* noop */ }
func (k *kstub) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error {
k.Helper()

View File

@@ -5,9 +5,9 @@ import (
"os"
"syscall"
"hakurei.app/container/check"
"hakurei.app/container/vfs"
"hakurei.app/check"
"hakurei.app/message"
"hakurei.app/vfs"
)
// messageFromError returns a printable error message for a supported concrete type.

View File

@@ -8,9 +8,9 @@ import (
"syscall"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/container/vfs"
"hakurei.app/check"
"hakurei.app/internal/stub"
"hakurei.app/vfs"
)
func TestMessageFromError(t *testing.T) {

View File

@@ -1,37 +0,0 @@
package container
import (
"fmt"
"log"
"os"
"sync"
"hakurei.app/message"
)
var (
executable string
executableOnce sync.Once
)
func copyExecutable(msg message.Msg) {
if name, err := os.Executable(); err != nil {
m := fmt.Sprintf("cannot read executable path: %v", err)
if msg != nil {
msg.BeforeExit()
msg.GetLogger().Fatal(m)
} else {
log.Fatal(m)
}
} else {
executable = name
}
}
// MustExecutable calls [os.Executable] and terminates the process on error.
//
// Deprecated: This is no longer used and will be removed in 0.4.
func MustExecutable(msg message.Msg) string {
executableOnce.Do(func() { copyExecutable(msg) })
return executable
}

View File

@@ -1,18 +0,0 @@
package container_test
import (
"os"
"testing"
"hakurei.app/container"
"hakurei.app/message"
)
func TestExecutable(t *testing.T) {
t.Parallel()
for i := 0; i < 16; i++ {
if got := container.MustExecutable(message.New(nil)); got != os.Args[0] {
t.Errorf("MustExecutable: %q, want %q", got, os.Args[0])
}
}
}

View File

@@ -7,7 +7,8 @@ import (
"log"
"os"
"os/exec"
"path"
"os/signal"
"path/filepath"
"slices"
"strconv"
"sync"
@@ -15,8 +16,10 @@ import (
. "syscall"
"time"
"hakurei.app/container/fhs"
"hakurei.app/container/seccomp"
"hakurei.app/ext"
"hakurei.app/fhs"
"hakurei.app/internal/params"
"hakurei.app/message"
)
@@ -145,44 +148,46 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
}
var (
params initParams
closeSetup func() error
setupFd uintptr
offsetSetup int
param initParams
closeSetup func() error
setupFd int
)
if f, err := k.receive(setupEnv, &params, &setupFd); err != nil {
if f, err := k.receive(setupEnv, &param, &setupFd); err != nil {
if errors.Is(err, EBADF) {
k.fatal(msg, "invalid setup descriptor")
}
if errors.Is(err, ErrReceiveEnv) {
if errors.Is(err, params.ErrReceiveEnv) {
k.fatal(msg, setupEnv+" not set")
}
k.fatalf(msg, "cannot decode init setup payload: %v", err)
} else {
if params.Ops == nil {
if param.Ops == nil {
k.fatal(msg, "invalid setup parameters")
}
if params.ParentPerm == 0 {
params.ParentPerm = 0755
if param.ParentPerm == 0 {
param.ParentPerm = 0755
}
msg.SwapVerbose(params.Verbose)
msg.SwapVerbose(param.Verbose)
msg.Verbose("received setup parameters")
closeSetup = f
offsetSetup = int(setupFd + 1)
}
if !params.HostNet {
k.mustLoopback(msg)
if !param.HostNet {
ctx, cancel := signal.NotifyContext(context.Background(), CancelSignal,
os.Interrupt, SIGTERM, SIGQUIT)
defer cancel() // for panics
k.mustLoopback(ctx, msg)
cancel()
}
// write uid/gid map here so parent does not need to set dumpable
if err := k.setDumpable(SUID_DUMP_USER); err != nil {
if err := k.setDumpable(ext.SUID_DUMP_USER); err != nil {
k.fatalf(msg, "cannot set SUID_DUMP_USER: %v", err)
}
if err := k.writeFile(fhs.Proc+"self/uid_map",
append([]byte{}, strconv.Itoa(params.Uid)+" "+strconv.Itoa(params.HostUid)+" 1\n"...),
append([]byte{}, strconv.Itoa(param.Uid)+" "+strconv.Itoa(param.HostUid)+" 1\n"...),
0); err != nil {
k.fatalf(msg, "%v", err)
}
@@ -192,17 +197,17 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
k.fatalf(msg, "%v", err)
}
if err := k.writeFile(fhs.Proc+"self/gid_map",
append([]byte{}, strconv.Itoa(params.Gid)+" "+strconv.Itoa(params.HostGid)+" 1\n"...),
append([]byte{}, strconv.Itoa(param.Gid)+" "+strconv.Itoa(param.HostGid)+" 1\n"...),
0); err != nil {
k.fatalf(msg, "%v", err)
}
if err := k.setDumpable(SUID_DUMP_DISABLE); err != nil {
if err := k.setDumpable(ext.SUID_DUMP_DISABLE); err != nil {
k.fatalf(msg, "cannot set SUID_DUMP_DISABLE: %v", err)
}
oldmask := k.umask(0)
if params.Hostname != "" {
if err := k.sethostname([]byte(params.Hostname)); err != nil {
if param.Hostname != "" {
if err := k.sethostname([]byte(param.Hostname)); err != nil {
k.fatalf(msg, "cannot set hostname: %v", err)
}
}
@@ -215,7 +220,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
}
ctx, cancel := context.WithCancel(context.Background())
state := &setupState{process: make(map[int]WaitStatus), Params: &params.Params, Msg: msg, Context: ctx}
state := &setupState{process: make(map[int]WaitStatus), Params: &param.Params, Msg: msg, Context: ctx}
defer cancel()
/* early is called right before pivot_root into intermediate root;
@@ -223,7 +228,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
difficult to obtain via library functions after pivot_root, and
implementations are expected to avoid changing the state of the mount
namespace */
for i, op := range *params.Ops {
for i, op := range *param.Ops {
if op == nil || !op.Valid() {
k.fatalf(msg, "invalid op at index %d", i)
}
@@ -266,7 +271,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
step sets up the container filesystem, and implementations are expected to
keep the host root and sysroot mount points intact but otherwise can do
whatever they need to. Calling chdir is allowed but discouraged. */
for i, op := range *params.Ops {
for i, op := range *param.Ops {
// ops already checked during early setup
if prefix, ok := op.prefix(); ok {
msg.Verbosef("%s %s", prefix, op)
@@ -290,7 +295,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
{
var fd int
if err := IgnoringEINTR(func() (err error) {
if err := ext.IgnoringEINTR(func() (err error) {
fd, err = k.open(fhs.Root, O_DIRECTORY|O_RDONLY, 0)
return
}); err != nil {
@@ -322,7 +327,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
k.fatalf(msg, "cannot clear the ambient capability set: %v", err)
}
for i := uintptr(0); i <= lastcap; i++ {
if params.Privileged && i == CAP_SYS_ADMIN {
if param.Privileged && i == CAP_SYS_ADMIN {
continue
}
if err := k.capBoundingSetDrop(i); err != nil {
@@ -331,7 +336,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
}
var keep [2]uint32
if params.Privileged {
if param.Privileged {
keep[capToIndex(CAP_SYS_ADMIN)] |= capToMask(CAP_SYS_ADMIN)
if err := k.capAmbientRaise(CAP_SYS_ADMIN); err != nil {
@@ -345,13 +350,13 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
k.fatalf(msg, "cannot capset: %v", err)
}
if !params.SeccompDisable {
rules := params.SeccompRules
if !param.SeccompDisable {
rules := param.SeccompRules
if len(rules) == 0 { // non-empty rules slice always overrides presets
msg.Verbosef("resolving presets %#x", params.SeccompPresets)
rules = seccomp.Preset(params.SeccompPresets, params.SeccompFlags)
msg.Verbosef("resolving presets %#x", param.SeccompPresets)
rules = seccomp.Preset(param.SeccompPresets, param.SeccompFlags)
}
if err := k.seccompLoad(rules, params.SeccompFlags); err != nil {
if err := k.seccompLoad(rules, param.SeccompFlags); err != nil {
// this also indirectly asserts PR_SET_NO_NEW_PRIVS
k.fatalf(msg, "cannot load syscall filter: %v", err)
}
@@ -360,10 +365,10 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
msg.Verbose("syscall filter not configured")
}
extraFiles := make([]*os.File, params.Count)
extraFiles := make([]*os.File, param.Count)
for i := range extraFiles {
// setup fd is placed before all extra files
extraFiles[i] = k.newFile(uintptr(offsetSetup+i), "extra file "+strconv.Itoa(i))
extraFiles[i] = k.newFile(uintptr(setupFd+1+i), "extra file "+strconv.Itoa(i))
}
k.umask(oldmask)
@@ -441,7 +446,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
// called right before startup of initial process, all state changes to the
// current process is prohibited during late
for i, op := range *params.Ops {
for i, op := range *param.Ops {
// ops already checked during early setup
if err := op.late(state, k); err != nil {
if m, ok := messageFromError(err); ok {
@@ -462,14 +467,14 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
k.fatalf(msg, "cannot close setup pipe: %v", err)
}
cmd := exec.Command(params.Path.String())
cmd := exec.Command(param.Path.String())
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Args = params.Args
cmd.Env = params.Env
cmd.Args = param.Args
cmd.Env = param.Env
cmd.ExtraFiles = extraFiles
cmd.Dir = params.Dir.String()
cmd.Dir = param.Dir.String()
msg.Verbosef("starting initial process %s", params.Path)
msg.Verbosef("starting initial process %s", param.Path)
if err := k.start(cmd); err != nil {
k.fatalf(msg, "%v", err)
}
@@ -487,9 +492,9 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
for {
select {
case s := <-sig:
if s == CancelSignal && params.ForwardCancel && cmd.Process != nil {
if s == CancelSignal && param.ForwardCancel && cmd.Process != nil {
msg.Verbose("forwarding context cancellation")
if err := k.signal(cmd, os.Interrupt); err != nil {
if err := k.signal(cmd, os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
k.printf(msg, "cannot forward cancellation: %v", err)
}
continue
@@ -519,7 +524,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
cancel()
// start timeout early
go func() { time.Sleep(params.AdoptWaitDelay); close(timeout) }()
go func() { time.Sleep(param.AdoptWaitDelay); close(timeout) }()
// close initial process files; this also keeps them alive
for _, f := range extraFiles {
@@ -563,7 +568,7 @@ func TryArgv0(msg message.Msg) {
msg = message.New(log.Default())
}
if len(os.Args) > 0 && path.Base(os.Args[0]) == initName {
if len(os.Args) > 0 && filepath.Base(os.Args[0]) == initName {
Init(msg)
msg.BeforeExit()
os.Exit(0)

View File

@@ -7,10 +7,11 @@ import (
"testing"
"time"
"hakurei.app/container/check"
"hakurei.app/check"
"hakurei.app/container/seccomp"
"hakurei.app/container/std"
"hakurei.app/container/stub"
"hakurei.app/internal/params"
"hakurei.app/internal/stub"
)
func TestInitEntrypoint(t *testing.T) {
@@ -40,7 +41,7 @@ func TestInitEntrypoint(t *testing.T) {
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
call("getpid", stub.ExpectArgs{}, 1, nil),
call("setPtracer", stub.ExpectArgs{uintptr(0)}, nil, nil),
call("receive", stub.ExpectArgs{"HAKUREI_SETUP", new(initParams), new(uintptr)}, nil, ErrReceiveEnv),
call("receive", stub.ExpectArgs{"HAKUREI_SETUP", new(initParams), new(uintptr)}, nil, params.ErrReceiveEnv),
call("fatal", stub.ExpectArgs{[]any{"HAKUREI_SETUP not set"}}, nil, nil),
},
}, nil},

View File

@@ -6,7 +6,7 @@ import (
"os"
"syscall"
"hakurei.app/container/check"
"hakurei.app/check"
"hakurei.app/container/std"
)

View File

@@ -6,9 +6,9 @@ import (
"syscall"
"testing"
"hakurei.app/container/check"
"hakurei.app/check"
"hakurei.app/container/std"
"hakurei.app/container/stub"
"hakurei.app/internal/stub"
)
func TestBindMountOp(t *testing.T) {

View File

@@ -12,8 +12,8 @@ import (
"syscall"
"time"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/check"
"hakurei.app/fhs"
)
func init() { gob.Register(new(DaemonOp)) }

View File

@@ -4,8 +4,8 @@ import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/check"
"hakurei.app/internal/stub"
"hakurei.app/message"
)

View File

@@ -3,11 +3,11 @@ package container
import (
"encoding/gob"
"fmt"
"path"
"path/filepath"
. "syscall"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/check"
"hakurei.app/fhs"
)
func init() { gob.Register(new(MountDevOp)) }
@@ -46,7 +46,7 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
}
for _, name := range []string{"null", "zero", "full", "random", "urandom", "tty"} {
targetPath := path.Join(target, name)
targetPath := filepath.Join(target, name)
if err := k.ensureFile(targetPath, 0444, state.ParentPerm); err != nil {
return err
}
@@ -62,7 +62,7 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
for i, name := range []string{"stdin", "stdout", "stderr"} {
if err := k.symlink(
fhs.Proc+"self/fd/"+string(rune(i+'0')),
path.Join(target, name),
filepath.Join(target, name),
); err != nil {
return err
}
@@ -72,13 +72,13 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
{fhs.Proc + "kcore", "core"},
{"pts/ptmx", "ptmx"},
} {
if err := k.symlink(pair[0], path.Join(target, pair[1])); err != nil {
if err := k.symlink(pair[0], filepath.Join(target, pair[1])); err != nil {
return err
}
}
devShmPath := path.Join(target, "shm")
devPtsPath := path.Join(target, "pts")
devShmPath := filepath.Join(target, "shm")
devPtsPath := filepath.Join(target, "pts")
for _, name := range []string{devShmPath, devPtsPath} {
if err := k.mkdir(name, state.ParentPerm); err != nil {
return err
@@ -92,7 +92,7 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
if state.RetainSession {
if k.isatty(Stdout) {
consolePath := path.Join(target, "console")
consolePath := filepath.Join(target, "console")
if err := k.ensureFile(consolePath, 0444, state.ParentPerm); err != nil {
return err
}
@@ -110,7 +110,7 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
}
if d.Mqueue {
mqueueTarget := path.Join(target, "mqueue")
mqueueTarget := filepath.Join(target, "mqueue")
if err := k.mkdir(mqueueTarget, state.ParentPerm); err != nil {
return err
}

View File

@@ -4,8 +4,8 @@ import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/check"
"hakurei.app/internal/stub"
)
func TestMountDevOp(t *testing.T) {

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"os"
"hakurei.app/container/check"
"hakurei.app/check"
)
func init() { gob.Register(new(MkdirOp)) }

View File

@@ -4,8 +4,8 @@ import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/check"
"hakurei.app/internal/stub"
)
func TestMkdirOp(t *testing.T) {

View File

@@ -6,8 +6,8 @@ import (
"slices"
"strings"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/check"
"hakurei.app/fhs"
)
const (

View File

@@ -5,8 +5,8 @@ import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/check"
"hakurei.app/internal/stub"
)
func TestMountOverlayOp(t *testing.T) {

View File

@@ -5,8 +5,8 @@ import (
"fmt"
"syscall"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/check"
"hakurei.app/fhs"
)
const (

View File

@@ -4,8 +4,8 @@ import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/check"
"hakurei.app/internal/stub"
)
func TestTmpfileOp(t *testing.T) {

View File

@@ -5,7 +5,7 @@ import (
"fmt"
. "syscall"
"hakurei.app/container/check"
"hakurei.app/check"
)
func init() { gob.Register(new(MountProcOp)) }

View File

@@ -4,8 +4,8 @@ import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/check"
"hakurei.app/internal/stub"
)
func TestMountProcOp(t *testing.T) {

View File

@@ -4,7 +4,7 @@ import (
"encoding/gob"
"fmt"
"hakurei.app/container/check"
"hakurei.app/check"
)
func init() { gob.Register(new(RemountOp)) }

View File

@@ -4,8 +4,8 @@ import (
"syscall"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/check"
"hakurei.app/internal/stub"
)
func TestRemountOp(t *testing.T) {

View File

@@ -3,9 +3,9 @@ package container
import (
"encoding/gob"
"fmt"
"path"
"path/filepath"
"hakurei.app/container/check"
"hakurei.app/check"
)
func init() { gob.Register(new(SymlinkOp)) }
@@ -30,7 +30,7 @@ func (l *SymlinkOp) Valid() bool { return l != nil && l.Target != nil && l.LinkN
func (l *SymlinkOp) early(_ *setupState, k syscallDispatcher) error {
if l.Dereference {
if !path.IsAbs(l.LinkName) {
if !filepath.IsAbs(l.LinkName) {
return check.AbsoluteError(l.LinkName)
}
if name, err := k.readlink(l.LinkName); err != nil {
@@ -44,7 +44,7 @@ func (l *SymlinkOp) early(_ *setupState, k syscallDispatcher) error {
func (l *SymlinkOp) apply(state *setupState, k syscallDispatcher) error {
target := toSysroot(l.Target.String())
if err := k.mkdirAll(path.Dir(target), state.ParentPerm); err != nil {
if err := k.mkdirAll(filepath.Dir(target), state.ParentPerm); err != nil {
return err
}
return k.symlink(l.LinkName, target)

View File

@@ -4,8 +4,8 @@ import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/check"
"hakurei.app/internal/stub"
)
func TestSymlinkOp(t *testing.T) {

Some files were not shown because too many files have changed in this diff Show More