Compare commits
41 Commits
wip-sysfs-
...
pkgserver-
| Author | SHA1 | Date | |
|---|---|---|---|
|
d91b9f4730
|
|||
|
90277bf6fe
|
|||
|
514f268c8c
|
|||
|
0ddae23eda
|
|||
|
f2fded0620
|
|||
|
8d759f654d
|
|||
|
82060ac154
|
|||
| c7e195fe64 | |||
| d5db9add98 | |||
| ab8abdc82b | |||
| 770fd46510 | |||
| 99f1c6aab4 | |||
| 9ee629d402 | |||
| f475dde8b9 | |||
| c43a0c41b6 | |||
| 55827f1a85 | |||
| 721bdddfa1 | |||
| fb18e599dd | |||
| ec9005c794 | |||
| c6d35b4003 | |||
| 6401533cc2 | |||
| 5d6c401beb | |||
| 0a2d6aec14 | |||
| 67b11335d6 | |||
| ef3bd1b60a | |||
| beae7c89db | |||
| ed26d1a1c2 | |||
| faa0006d47 | |||
| 796ddbc977 | |||
| 98ab020160 | |||
| 26a346036d | |||
| 4ac9c72132 | |||
| c39c07d440 | |||
| b3fa0fe271 | |||
| 92a90582bb | |||
| 2e5ac56bdf | |||
| 75133e0234 | |||
| c120d4de4f | |||
| d6af8edb4a | |||
| da25d609d5 | |||
| 95ceed0de0 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -27,6 +27,14 @@ go.work.sum
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<a href="https://git.gensokyo.uk/rosa/hakurei">
|
||||
<a href="https://git.gensokyo.uk/security/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/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>
|
||||
<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>
|
||||
<br/>
|
||||
<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://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://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/rosa/planterette), a
|
||||
of [planterette (WIP)](https://git.gensokyo.uk/security/planterette), a
|
||||
self-contained Android-like package manager with modern security features.
|
||||
|
||||
Interaction with hakurei happens entirely through structures described by
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
@@ -17,22 +12,6 @@ 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/",
|
||||
@@ -76,56 +55,4 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -12,11 +11,12 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
_ "unsafe" // for go:linkname
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/command"
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/fhs"
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/hst"
|
||||
"hakurei.app/internal/dbus"
|
||||
"hakurei.app/internal/env"
|
||||
@@ -27,14 +27,9 @@ import (
|
||||
|
||||
// optionalErrorUnwrap calls [errors.Unwrap] and returns the resulting value
|
||||
// if it is not nil, or the original value if it is.
|
||||
func optionalErrorUnwrap(err error) error {
|
||||
if underlyingErr := errors.Unwrap(err); underlyingErr != nil {
|
||||
return underlyingErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var errSuccess = errors.New("success")
|
||||
//
|
||||
//go:linkname optionalErrorUnwrap hakurei.app/container.optionalErrorUnwrap
|
||||
func optionalErrorUnwrap(err error) error
|
||||
|
||||
func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErrs, out io.Writer) command.Command {
|
||||
var (
|
||||
@@ -65,9 +60,9 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
var (
|
||||
flagIdentifierFile int
|
||||
)
|
||||
c.NewCommand("run", "Load and start container from configuration file", func(args []string) error {
|
||||
c.NewCommand("app", "Load and start container from configuration file", func(args []string) error {
|
||||
if len(args) < 1 {
|
||||
log.Fatal("run requires at least 1 argument")
|
||||
log.Fatal("app requires at least 1 argument")
|
||||
}
|
||||
|
||||
config := tryPath(msg, args[0])
|
||||
@@ -103,7 +98,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
flagWayland, flagX11, flagDBus, flagPipeWire, flagPulse bool
|
||||
)
|
||||
|
||||
c.NewCommand("exec", "Configure and start a permissive container", func(args []string) error {
|
||||
c.NewCommand("run", "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)
|
||||
}
|
||||
@@ -191,7 +186,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
config.SchedPriority = ext.Int(flagSchedPriority)
|
||||
config.SchedPriority = std.Int(flagSchedPriority)
|
||||
|
||||
// bind GPU stuff
|
||||
if et&(hst.EX11|hst.EWayland) != 0 {
|
||||
@@ -328,7 +323,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
flagShort bool
|
||||
flagNoStore bool
|
||||
)
|
||||
c.NewCommand("show", "Show live or local instance configuration", func(args []string) error {
|
||||
c.NewCommand("show", "Show live or local app configuration", func(args []string) error {
|
||||
switch len(args) {
|
||||
case 0: // system
|
||||
printShowSystem(os.Stdout, flagShort, flagJSON)
|
||||
|
||||
@@ -23,9 +23,9 @@ func TestHelp(t *testing.T) {
|
||||
Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS]
|
||||
|
||||
Commands:
|
||||
run Load and start container from configuration file
|
||||
exec Configure and start a permissive container
|
||||
show Show live or local instance configuration
|
||||
app Load and start container from configuration file
|
||||
run Configure and start a permissive container
|
||||
show Show live or local app configuration
|
||||
ps List active instances
|
||||
version Display version information
|
||||
license Show full license text
|
||||
@@ -35,8 +35,8 @@ Commands:
|
||||
`,
|
||||
},
|
||||
{
|
||||
"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]
|
||||
"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]
|
||||
|
||||
Flags:
|
||||
-X Enable direct connection to X11
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestDecodeJSON(t *testing.T) {
|
||||
|
||||
@@ -1,42 +1,8 @@
|
||||
// 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"
|
||||
@@ -47,13 +13,15 @@ import (
|
||||
"syscall"
|
||||
|
||||
"hakurei.app/container"
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
//go:generate cp ../../LICENSE .
|
||||
var (
|
||||
errSuccess = errors.New("success")
|
||||
|
||||
//go:embed LICENSE
|
||||
var license string
|
||||
license string
|
||||
)
|
||||
|
||||
// earlyHardeningErrs are errors collected while setting up early hardening feature.
|
||||
type earlyHardeningErrs struct{ yamaLSM, dumpable error }
|
||||
@@ -62,13 +30,13 @@ func main() {
|
||||
// early init path, skips root check and duplicate PR_SET_DUMPABLE
|
||||
container.TryArgv0(nil)
|
||||
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("hakurei: ")
|
||||
log.SetFlags(0)
|
||||
msg := message.New(log.Default())
|
||||
|
||||
early := earlyHardeningErrs{
|
||||
yamaLSM: ext.SetPtracer(0),
|
||||
dumpable: ext.SetDumpable(ext.SUID_DUMP_DISABLE),
|
||||
yamaLSM: container.SetPtracer(0),
|
||||
dumpable: container.SetDumpable(container.SUID_DUMP_DISABLE),
|
||||
}
|
||||
|
||||
if os.Geteuid() == 0 {
|
||||
|
||||
@@ -17,9 +17,8 @@ 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)
|
||||
@@ -47,8 +46,7 @@ func tryPath(msg message.Msg, name string) (config *hst.Config) {
|
||||
return
|
||||
}
|
||||
|
||||
// tryFd returns a [io.ReadCloser] if name represents an integer corresponding
|
||||
// to a valid file descriptor.
|
||||
// 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) {
|
||||
@@ -62,12 +60,7 @@ 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
|
||||
}
|
||||
@@ -82,12 +75,10 @@ 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())
|
||||
}
|
||||
@@ -97,8 +88,7 @@ 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
|
||||
@@ -106,8 +96,7 @@ func tryIdentifier(msg message.Msg, name string, s *store.Store) *hst.State {
|
||||
)
|
||||
|
||||
var likely uintptr
|
||||
// half the hex representation
|
||||
if len(name) >= shortLengthMin && len(name) <= len(hst.ID{}) {
|
||||
if len(name) >= shortLengthMin && len(name) <= len(hst.ID{}) { // half the hex representation
|
||||
// cannot safely decode here due to unknown alignment
|
||||
for _, c := range name {
|
||||
if c >= '0' && c <= '9' {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/hst"
|
||||
"hakurei.app/internal/store"
|
||||
"hakurei.app/message"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/hst"
|
||||
"hakurei.app/internal/store"
|
||||
"hakurei.app/message"
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
//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"
|
||||
@@ -1,7 +0,0 @@
|
||||
//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"
|
||||
@@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
/* keep in sync with hst */
|
||||
/* copied from hst and must never be changed */
|
||||
|
||||
const (
|
||||
userOffset = 100000
|
||||
|
||||
@@ -1,58 +1,7 @@
|
||||
// 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"
|
||||
@@ -67,13 +16,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// envShim is the name of the environment variable holding a single byte
|
||||
// representing the shim setup pipe file descriptor.
|
||||
// envIdentity is the name of the environment variable holding a
|
||||
// single byte representing the shim setup pipe file descriptor.
|
||||
envShim = "HAKUREI_SHIM"
|
||||
// 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
|
||||
// envGroups holds a ' ' separated list of string representations of
|
||||
// supplementary group gid. Membership requirements are enforced.
|
||||
envGroups = "HAKUREI_GROUPS"
|
||||
)
|
||||
@@ -89,6 +35,7 @@ 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")
|
||||
@@ -152,6 +99,8 @@ 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
|
||||
@@ -187,6 +136,7 @@ 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)
|
||||
}
|
||||
@@ -196,21 +146,10 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,8 @@ 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 but is perfectly adequate for this use case.
|
||||
// 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.
|
||||
func parseUint32Fast(s string) (uint32, error) {
|
||||
sLen := len(s)
|
||||
if sLen < 1 {
|
||||
@@ -41,14 +40,12 @@ 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) {
|
||||
@@ -84,6 +81,10 @@ 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.
|
||||
@@ -111,6 +112,10 @@ 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 {
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
// 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 (
|
||||
@@ -30,13 +18,12 @@ import (
|
||||
"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"
|
||||
@@ -284,7 +271,7 @@ func main() {
|
||||
return errors.New("report requires 1 argument")
|
||||
}
|
||||
|
||||
if ext.Isatty(int(w.Fd())) {
|
||||
if container.Isatty(int(w.Fd())) {
|
||||
return errors.New("output appears to be a terminal")
|
||||
}
|
||||
return rosa.WriteReport(msg, w, cache)
|
||||
@@ -448,7 +435,6 @@ func main() {
|
||||
{
|
||||
var (
|
||||
flagDump string
|
||||
flagEnter bool
|
||||
flagExport string
|
||||
)
|
||||
c.NewCommand(
|
||||
@@ -458,13 +444,9 @@ func main() {
|
||||
if len(args) != 1 {
|
||||
return errors.New("cure requires 1 argument")
|
||||
}
|
||||
p, ok := rosa.ResolveName(args[0])
|
||||
if !ok {
|
||||
if p, ok := rosa.ResolveName(args[0]); !ok {
|
||||
return fmt.Errorf("unknown artifact %q", args[0])
|
||||
}
|
||||
|
||||
switch {
|
||||
default:
|
||||
} else if flagDump == "" {
|
||||
pathname, _, err := cache.Cure(rosa.Std.Load(p))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -494,8 +476,7 @@ func main() {
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case flagDump != "":
|
||||
} else {
|
||||
f, err := os.OpenFile(
|
||||
flagDump,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
@@ -511,15 +492,6 @@ func main() {
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
|
||||
case flagEnter:
|
||||
return cache.EnterExec(
|
||||
ctx,
|
||||
rosa.Std.Load(p),
|
||||
true, os.Stdin, os.Stdout, os.Stderr,
|
||||
rosa.AbsSystem.Append("bin", "mksh"),
|
||||
"sh",
|
||||
)
|
||||
}
|
||||
},
|
||||
).
|
||||
@@ -532,11 +504,6 @@ func main() {
|
||||
&flagExport,
|
||||
"export", command.StringFlag(""),
|
||||
"Export cured artifact to specified pathname",
|
||||
).
|
||||
Flag(
|
||||
&flagEnter,
|
||||
"enter", command.BoolFlag(false),
|
||||
"Enter cure container with an interactive shell",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -559,7 +526,7 @@ func main() {
|
||||
}
|
||||
presets[i] = p
|
||||
}
|
||||
root := make(pkg.Collect, 0, 6+len(args))
|
||||
root := make(rosa.Collect, 0, 6+len(args))
|
||||
root = rosa.Std.AppendPresets(root, presets...)
|
||||
|
||||
if flagWithToolchain {
|
||||
@@ -575,7 +542,7 @@ func main() {
|
||||
|
||||
if _, _, err := cache.Cure(&root); err == nil {
|
||||
return errors.New("unreachable")
|
||||
} else if !pkg.IsCollected(err) {
|
||||
} else if !errors.Is(err, rosa.Collected{}) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -668,13 +635,13 @@ func main() {
|
||||
).
|
||||
Flag(
|
||||
&flagSession,
|
||||
"session", command.BoolFlag(true),
|
||||
"session", command.BoolFlag(false),
|
||||
"Retain session",
|
||||
).
|
||||
Flag(
|
||||
&flagWithToolchain,
|
||||
"with-toolchain", command.BoolFlag(false),
|
||||
"Include the stage2 LLVM toolchain",
|
||||
"Include the stage3 LLVM toolchain",
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
176
cmd/pkgserver/api.go
Normal file
176
cmd/pkgserver/api.go
Normal file
@@ -0,0 +1,176 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
183
cmd/pkgserver/api_test.go
Normal file
183
cmd/pkgserver/api_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
105
cmd/pkgserver/index.go
Normal file
105
cmd/pkgserver/index.go
Normal file
@@ -0,0 +1,105 @@
|
||||
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
|
||||
}
|
||||
115
cmd/pkgserver/main.go
Normal file
115
cmd/pkgserver/main.go
Normal file
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
96
cmd/pkgserver/main_test.go
Normal file
96
cmd/pkgserver/main_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
77
cmd/pkgserver/search.go
Normal file
77
cmd/pkgserver/search.go
Normal file
@@ -0,0 +1,77 @@
|
||||
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
|
||||
}
|
||||
97
cmd/pkgserver/test_ui.go
Normal file
97
cmd/pkgserver/test_ui.go
Normal file
@@ -0,0 +1,97 @@
|
||||
//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)
|
||||
}
|
||||
7
cmd/pkgserver/test_ui_stub.go
Normal file
7
cmd/pkgserver/test_ui_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !(frontend && frontend_test)
|
||||
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func testUiRoutes(mux *http.ServeMux) {}
|
||||
38
cmd/pkgserver/ui.go
Normal file
38
cmd/pkgserver/ui.go
Normal file
@@ -0,0 +1,38 @@
|
||||
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)
|
||||
}
|
||||
35
cmd/pkgserver/ui/index.html
Normal file
35
cmd/pkgserver/ui/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!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()">« Previous</a> <span id="page-number">1</span> <a href="javascript:nextPage()">Next »</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>©<a href="https://hakurei.app/">Hakurei</a> (<span id="hakurei-version">unknown</span>). Licensed under the MIT license.</p>
|
||||
</footer>
|
||||
</html>
|
||||
0
cmd/pkgserver/ui/static/_common.scss
Normal file
0
cmd/pkgserver/ui/static/_common.scss
Normal file
6
cmd/pkgserver/ui/static/dark.scss
Normal file
6
cmd/pkgserver/ui/static/dark.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
@use 'common';
|
||||
|
||||
html {
|
||||
background-color: #2c2c2c;
|
||||
color: ghostwhite;
|
||||
}
|
||||
BIN
cmd/pkgserver/ui/static/favicon.ico
Normal file
BIN
cmd/pkgserver/ui/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
161
cmd/pkgserver/ui/static/index.ts
Normal file
161
cmd/pkgserver/ui/static/index.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
6
cmd/pkgserver/ui/static/light.scss
Normal file
6
cmd/pkgserver/ui/static/light.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
@use 'common';
|
||||
|
||||
html {
|
||||
background-color: #d3d3d3;
|
||||
color: black;
|
||||
}
|
||||
6
cmd/pkgserver/ui/static/tsconfig.json
Normal file
6
cmd/pkgserver/ui/static/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ES2024"
|
||||
}
|
||||
}
|
||||
9
cmd/pkgserver/ui_full.go
Normal file
9
cmd/pkgserver/ui_full.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//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
|
||||
7
cmd/pkgserver/ui_stub.go
Normal file
7
cmd/pkgserver/ui_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !frontend
|
||||
|
||||
package main
|
||||
|
||||
import "testing/fstest"
|
||||
|
||||
var content fstest.MapFS
|
||||
2
cmd/pkgserver/ui_test/all_tests.ts
Normal file
2
cmd/pkgserver/ui_test/all_tests.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Import all test files to register their test suites.
|
||||
import "./sample_tests.js";
|
||||
48
cmd/pkgserver/ui_test/lib/cli.ts
Normal file
48
cmd/pkgserver/ui_test/lib/cli.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/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, GLOBAL_REGISTRAR } 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);
|
||||
GLOBAL_REGISTRAR.run(reporter);
|
||||
exit(reporter.succeeded() ? 0 : 1);
|
||||
13
cmd/pkgserver/ui_test/lib/failure-closed.svg
Normal file
13
cmd/pkgserver/ui_test/lib/failure-closed.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 788 B |
35
cmd/pkgserver/ui_test/lib/failure-open.svg
Normal file
35
cmd/pkgserver/ui_test/lib/failure-open.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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>
|
||||
3
cmd/pkgserver/ui_test/lib/go_test_entrypoint.ts
Normal file
3
cmd/pkgserver/ui_test/lib/go_test_entrypoint.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import "../all_tests.js";
|
||||
import { GoTestReporter, GLOBAL_REGISTRAR } from "./test.js";
|
||||
GLOBAL_REGISTRAR.run(new GoTestReporter());
|
||||
21
cmd/pkgserver/ui_test/lib/skip-closed.svg
Normal file
21
cmd/pkgserver/ui_test/lib/skip-closed.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 812 B |
21
cmd/pkgserver/ui_test/lib/skip-open.svg
Normal file
21
cmd/pkgserver/ui_test/lib/skip-open.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 812 B |
16
cmd/pkgserver/ui_test/lib/success-closed.svg
Normal file
16
cmd/pkgserver/ui_test/lib/success-closed.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 572 B |
16
cmd/pkgserver/ui_test/lib/success-open.svg
Normal file
16
cmd/pkgserver/ui_test/lib/success-open.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 572 B |
403
cmd/pkgserver/ui_test/lib/test.ts
Normal file
403
cmd/pkgserver/ui_test/lib/test.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
// =============================================================================
|
||||
// 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 GLOBAL_REGISTRAR = new TestRegistrar();
|
||||
|
||||
// Register a suite in the global registrar.
|
||||
export function suite(name: string, children: TestTree[]) {
|
||||
GLOBAL_REGISTRAR.suite(name, children);
|
||||
}
|
||||
|
||||
export function group(name: string, children: TestTree[]): TestTree {
|
||||
checkDuplicates(name, children);
|
||||
return { name, children };
|
||||
}
|
||||
export const context = group;
|
||||
export const describe = group;
|
||||
|
||||
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").hidden = false;
|
||||
}
|
||||
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");
|
||||
// The summary marker does not appear in the AOM, so setting its
|
||||
// alt text is fruitless; label the summary itself instead.
|
||||
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[];
|
||||
}
|
||||
|
||||
// Used to display results via `go test`, via some glue code from the Go side.
|
||||
export class GoTestReporter implements Reporter {
|
||||
register(suites: TestGroup[]) {
|
||||
console.log(JSON.stringify(suites.map(GoTestReporter.serialize)));
|
||||
}
|
||||
|
||||
// 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) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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, state, logs: result.logs }));
|
||||
}
|
||||
|
||||
finalize() {
|
||||
console.log("null");
|
||||
}
|
||||
}
|
||||
39
cmd/pkgserver/ui_test/lib/ui.html
Normal file
39
cmd/pkgserver/ui_test/lib/ui.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!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" 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, GLOBAL_REGISTRAR } from "/test/lib/test.js";
|
||||
GLOBAL_REGISTRAR.run(new DOMReporter());
|
||||
</script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
88
cmd/pkgserver/ui_test/lib/ui.scss
Normal file
88
cmd/pkgserver/ui_test/lib/ui.scss
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* If updating the theme colors, also update them in success-closed.svg and
|
||||
* success-open.svg!
|
||||
*/
|
||||
|
||||
: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;
|
||||
}
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
85
cmd/pkgserver/ui_test/sample_tests.ts
Normal file
85
cmd/pkgserver/ui_test/sample_tests.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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"]`);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
6
cmd/pkgserver/ui_test/tsconfig.json
Normal file
6
cmd/pkgserver/ui_test/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ES2024"
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,10 @@ import (
|
||||
"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,10 +85,7 @@ 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)
|
||||
@@ -106,11 +103,7 @@ func sharefs_init(
|
||||
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 {
|
||||
@@ -176,11 +169,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -263,8 +253,7 @@ 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}
|
||||
@@ -280,7 +269,6 @@ 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))))
|
||||
@@ -300,8 +288,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
|
||||
@@ -465,10 +453,7 @@ 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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
)
|
||||
|
||||
func TestParseOpts(t *testing.T) {
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
// 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 (
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/fhs"
|
||||
)
|
||||
|
||||
func init() { gob.Register(new(AutoEtcOp)) }
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestAutoEtcOp(t *testing.T) {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/fhs"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/stub"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ package container
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"hakurei.app/ext"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -53,15 +51,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 ext.Prctl(syscall.PR_CAPBSET_DROP, cap, 0)
|
||||
return Prctl(syscall.PR_CAPBSET_DROP, cap, 0)
|
||||
}
|
||||
|
||||
// capAmbientClearAll clears the ambient capability set of the calling thread.
|
||||
func capAmbientClearAll() error {
|
||||
return ext.Prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0)
|
||||
return 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 ext.Prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, cap)
|
||||
return Prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, cap)
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import (
|
||||
"testing"
|
||||
_ "unsafe" // for go:linkname
|
||||
|
||||
. "hakurei.app/check"
|
||||
. "hakurei.app/container/check"
|
||||
)
|
||||
|
||||
// unsafeAbs returns check.Absolute on any string value.
|
||||
//
|
||||
//go:linkname unsafeAbs hakurei.app/check.unsafeAbs
|
||||
//go:linkname unsafeAbs hakurei.app/container/check.unsafeAbs
|
||||
func unsafeAbs(pathname string) *Absolute
|
||||
|
||||
func TestAbsoluteError(t *testing.T) {
|
||||
@@ -3,7 +3,7 @@ package check_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
)
|
||||
|
||||
func TestEscapeOverlayDataSegment(t *testing.T) {
|
||||
@@ -16,11 +16,10 @@ import (
|
||||
. "syscall"
|
||||
"time"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/fhs"
|
||||
"hakurei.app/container/seccomp"
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
@@ -42,10 +41,10 @@ type (
|
||||
// Whether to set SchedPolicy and SchedPriority via sched_setscheduler(2).
|
||||
SetScheduler bool
|
||||
// Scheduling policy to set via sched_setscheduler(2).
|
||||
SchedPolicy ext.SchedPolicy
|
||||
SchedPolicy std.SchedPolicy
|
||||
// Scheduling priority to set via sched_setscheduler(2). The zero value
|
||||
// implies the minimum value supported by the current SchedPolicy.
|
||||
SchedPriority ext.Int
|
||||
SchedPriority std.Int
|
||||
// Cgroup fd, nil to disable.
|
||||
Cgroup *int
|
||||
// ExtraFiles passed through to initial process in the container, with
|
||||
@@ -186,24 +185,31 @@ 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() { closeOnExecErr = doCloseOnExec() })
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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.
|
||||
@@ -372,7 +378,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 > ext.SCHED_LAST {
|
||||
if p.SchedPolicy < 0 || p.SchedPolicy > std.SCHED_LAST {
|
||||
return &StartError{
|
||||
Fatal: false,
|
||||
Step: "set scheduling policy",
|
||||
|
||||
@@ -18,17 +18,16 @@ import (
|
||||
"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/ext"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/container/vfs"
|
||||
"hakurei.app/hst"
|
||||
"hakurei.app/ldd"
|
||||
"hakurei.app/message"
|
||||
"hakurei.app/vfs"
|
||||
)
|
||||
|
||||
// Note: this package requires cgo, which is unavailable in the Go playground.
|
||||
@@ -259,7 +258,7 @@ var containerTestCases = []struct {
|
||||
1000, 100, nil, 0, std.PresetExt},
|
||||
{"custom rules", true, true, true, false,
|
||||
emptyOps, emptyMnt,
|
||||
1, 31, []std.NativeRule{{Syscall: ext.SyscallNum(syscall.SYS_SETUID), Errno: std.ScmpErrno(syscall.EPERM)}}, 0, std.PresetExt},
|
||||
1, 31, []std.NativeRule{{Syscall: std.ScmpSyscall(syscall.SYS_SETUID), Errno: std.ScmpErrno(syscall.EPERM)}}, 0, std.PresetExt},
|
||||
|
||||
{"tmpfs", true, false, false, true,
|
||||
earlyOps(new(container.Ops).
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
@@ -14,8 +12,6 @@ import (
|
||||
|
||||
"hakurei.app/container/seccomp"
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/internal/netlink"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
@@ -67,7 +63,7 @@ type syscallDispatcher interface {
|
||||
// ensureFile provides ensureFile.
|
||||
ensureFile(name string, perm, pperm os.FileMode) error
|
||||
// mustLoopback provides mustLoopback.
|
||||
mustLoopback(ctx context.Context, msg message.Msg)
|
||||
mustLoopback(msg message.Msg)
|
||||
|
||||
// seccompLoad provides [seccomp.Load].
|
||||
seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error
|
||||
@@ -145,8 +141,8 @@ func (k direct) new(f func(k syscallDispatcher)) { go f(k) }
|
||||
|
||||
func (direct) lockOSThread() { runtime.LockOSThread() }
|
||||
|
||||
func (direct) setPtracer(pid uintptr) error { return ext.SetPtracer(pid) }
|
||||
func (direct) setDumpable(dumpable uintptr) error { return ext.SetDumpable(dumpable) }
|
||||
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) lastcap(msg message.Msg) uintptr { return LastCap(msg) }
|
||||
@@ -154,7 +150,7 @@ func (direct) capset(hdrp *capHeader, datap *[2]capData) error { return capset(h
|
||||
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 ext.Isatty(fd) }
|
||||
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)
|
||||
}
|
||||
@@ -171,50 +167,7 @@ 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(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()
|
||||
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) mustLoopback(msg message.Msg) { mustLoopback(msg) }
|
||||
|
||||
func (direct) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error {
|
||||
return seccomp.Load(rules, flags)
|
||||
|
||||
@@ -2,7 +2,6 @@ package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -19,7 +18,7 @@ import (
|
||||
|
||||
"hakurei.app/container/seccomp"
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/stub"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
@@ -469,7 +468,7 @@ func (k *kstub) ensureFile(name string, perm, pperm os.FileMode) error {
|
||||
stub.CheckArg(k.Stub, "pperm", pperm, 2))
|
||||
}
|
||||
|
||||
func (*kstub) mustLoopback(context.Context, message.Msg) { /* noop */ }
|
||||
func (*kstub) mustLoopback(message.Msg) { /* noop */ }
|
||||
|
||||
func (k *kstub) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error {
|
||||
k.Helper()
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/vfs"
|
||||
"hakurei.app/message"
|
||||
"hakurei.app/vfs"
|
||||
)
|
||||
|
||||
// messageFromError returns a printable error message for a supported concrete type.
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/vfs"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
"hakurei.app/container/vfs"
|
||||
)
|
||||
|
||||
func TestMessageFromError(t *testing.T) {
|
||||
|
||||
37
container/executable.go
Normal file
37
container/executable.go
Normal file
@@ -0,0 +1,37 @@
|
||||
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
|
||||
}
|
||||
18
container/executable_test.go
Normal file
18
container/executable_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@ package fhs
|
||||
import (
|
||||
_ "unsafe" // for go:linkname
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
)
|
||||
|
||||
/* constants in this file bypass abs check, be extremely careful when changing them! */
|
||||
|
||||
// unsafeAbs returns check.Absolute on any string value.
|
||||
//
|
||||
//go:linkname unsafeAbs hakurei.app/check.unsafeAbs
|
||||
//go:linkname unsafeAbs hakurei.app/container/check.unsafeAbs
|
||||
func unsafeAbs(pathname string) *check.Absolute
|
||||
|
||||
var (
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -16,9 +15,8 @@ import (
|
||||
. "syscall"
|
||||
"time"
|
||||
|
||||
"hakurei.app/container/fhs"
|
||||
"hakurei.app/container/seccomp"
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
@@ -176,15 +174,11 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
}
|
||||
|
||||
if !params.HostNet {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), CancelSignal,
|
||||
os.Interrupt, SIGTERM, SIGQUIT)
|
||||
defer cancel() // for panics
|
||||
k.mustLoopback(ctx, msg)
|
||||
cancel()
|
||||
k.mustLoopback(msg)
|
||||
}
|
||||
|
||||
// write uid/gid map here so parent does not need to set dumpable
|
||||
if err := k.setDumpable(ext.SUID_DUMP_USER); err != nil {
|
||||
if err := k.setDumpable(SUID_DUMP_USER); err != nil {
|
||||
k.fatalf(msg, "cannot set SUID_DUMP_USER: %v", err)
|
||||
}
|
||||
if err := k.writeFile(fhs.Proc+"self/uid_map",
|
||||
@@ -202,7 +196,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
0); err != nil {
|
||||
k.fatalf(msg, "%v", err)
|
||||
}
|
||||
if err := k.setDumpable(ext.SUID_DUMP_DISABLE); err != nil {
|
||||
if err := k.setDumpable(SUID_DUMP_DISABLE); err != nil {
|
||||
k.fatalf(msg, "cannot set SUID_DUMP_DISABLE: %v", err)
|
||||
}
|
||||
|
||||
@@ -296,7 +290,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
|
||||
{
|
||||
var fd int
|
||||
if err := ext.IgnoringEINTR(func() (err error) {
|
||||
if err := IgnoringEINTR(func() (err error) {
|
||||
fd, err = k.open(fhs.Root, O_DIRECTORY|O_RDONLY, 0)
|
||||
return
|
||||
}); err != nil {
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/seccomp"
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestInitEntrypoint(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/std"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestBindMountOp(t *testing.T) {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/fhs"
|
||||
)
|
||||
|
||||
func init() { gob.Register(new(DaemonOp)) }
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"path"
|
||||
. "syscall"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/fhs"
|
||||
)
|
||||
|
||||
func init() { gob.Register(new(MountDevOp)) }
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestMountDevOp(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
)
|
||||
|
||||
func init() { gob.Register(new(MkdirOp)) }
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestMkdirOp(t *testing.T) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/fhs"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestMountOverlayOp(t *testing.T) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/fhs"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestTmpfileOp(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
. "syscall"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
)
|
||||
|
||||
func init() { gob.Register(new(MountProcOp)) }
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestMountProcOp(t *testing.T) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
)
|
||||
|
||||
func init() { gob.Register(new(RemountOp)) }
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestRemountOp(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
)
|
||||
|
||||
func init() { gob.Register(new(SymlinkOp)) }
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestSymlinkOp(t *testing.T) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
. "syscall"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/check"
|
||||
)
|
||||
|
||||
func init() { gob.Register(new(MountTmpfsOp)) }
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/stub"
|
||||
)
|
||||
|
||||
func TestMountTmpfsOp(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/container/std"
|
||||
)
|
||||
|
||||
// include/uapi/linux/landlock.h
|
||||
@@ -223,7 +223,7 @@ func (rulesetAttr *RulesetAttr) Create(flags uintptr) (fd int, err error) {
|
||||
}
|
||||
|
||||
rulesetFd, _, errno := syscall.Syscall(
|
||||
ext.SYS_LANDLOCK_CREATE_RULESET,
|
||||
std.SYS_LANDLOCK_CREATE_RULESET,
|
||||
pointer, size,
|
||||
flags,
|
||||
)
|
||||
@@ -247,7 +247,7 @@ func LandlockGetABI() (int, error) {
|
||||
// LandlockRestrictSelf applies a loaded ruleset to the calling thread.
|
||||
func LandlockRestrictSelf(rulesetFd int, flags uintptr) error {
|
||||
r, _, errno := syscall.Syscall(
|
||||
ext.SYS_LANDLOCK_RESTRICT_SELF,
|
||||
std.SYS_LANDLOCK_RESTRICT_SELF,
|
||||
uintptr(rulesetFd),
|
||||
flags,
|
||||
0,
|
||||
|
||||
@@ -6,9 +6,8 @@ import (
|
||||
"os"
|
||||
. "syscall"
|
||||
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/container/vfs"
|
||||
"hakurei.app/message"
|
||||
"hakurei.app/vfs"
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -116,7 +115,7 @@ func (p *procPaths) remount(msg message.Msg, target string, flags uintptr) error
|
||||
var targetKFinal string
|
||||
{
|
||||
var destFd int
|
||||
if err := ext.IgnoringEINTR(func() (err error) {
|
||||
if err := IgnoringEINTR(func() (err error) {
|
||||
destFd, err = p.k.open(targetFinal, O_PATH|O_CLOEXEC, 0)
|
||||
return
|
||||
}); err != nil {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/vfs"
|
||||
"hakurei.app/container/stub"
|
||||
"hakurei.app/container/vfs"
|
||||
)
|
||||
|
||||
func TestBindMount(t *testing.T) {
|
||||
|
||||
269
container/netlink.go
Normal file
269
container/netlink.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
. "syscall"
|
||||
"unsafe"
|
||||
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
// rtnetlink represents a NETLINK_ROUTE socket.
|
||||
type rtnetlink struct {
|
||||
// Sent as part of rtnetlink messages.
|
||||
pid uint32
|
||||
// AF_NETLINK socket.
|
||||
fd int
|
||||
// Whether the socket is open.
|
||||
ok bool
|
||||
// Message sequence number.
|
||||
seq uint32
|
||||
}
|
||||
|
||||
// open creates the underlying NETLINK_ROUTE socket.
|
||||
func (s *rtnetlink) open() (err error) {
|
||||
if s.ok || s.fd < 0 {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
s.pid = uint32(Getpid())
|
||||
if s.fd, err = Socket(
|
||||
AF_NETLINK,
|
||||
SOCK_RAW|SOCK_CLOEXEC,
|
||||
NETLINK_ROUTE,
|
||||
); err != nil {
|
||||
return os.NewSyscallError("socket", err)
|
||||
} else if err = Bind(s.fd, &SockaddrNetlink{
|
||||
Family: AF_NETLINK,
|
||||
Pid: s.pid,
|
||||
}); err != nil {
|
||||
_ = s.close()
|
||||
return os.NewSyscallError("bind", err)
|
||||
} else {
|
||||
s.ok = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// close closes the underlying NETLINK_ROUTE socket.
|
||||
func (s *rtnetlink) close() error {
|
||||
if !s.ok {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
s.ok = false
|
||||
err := Close(s.fd)
|
||||
s.fd = -1
|
||||
return err
|
||||
}
|
||||
|
||||
// roundtrip sends a netlink message and handles the reply.
|
||||
func (s *rtnetlink) roundtrip(data []byte) error {
|
||||
if !s.ok {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
defer func() { s.seq++ }()
|
||||
|
||||
if err := Sendto(s.fd, data, 0, &SockaddrNetlink{
|
||||
Family: AF_NETLINK,
|
||||
}); err != nil {
|
||||
return os.NewSyscallError("sendto", err)
|
||||
}
|
||||
buf := make([]byte, Getpagesize())
|
||||
|
||||
done:
|
||||
for {
|
||||
p := buf
|
||||
if n, _, err := Recvfrom(s.fd, p, 0); err != nil {
|
||||
return os.NewSyscallError("recvfrom", err)
|
||||
} else if n < NLMSG_HDRLEN {
|
||||
return errors.ErrUnsupported
|
||||
} else {
|
||||
p = p[:n]
|
||||
}
|
||||
|
||||
if msgs, err := ParseNetlinkMessage(p); err != nil {
|
||||
return err
|
||||
} else {
|
||||
for _, m := range msgs {
|
||||
if m.Header.Seq != s.seq || m.Header.Pid != s.pid {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
if m.Header.Type == NLMSG_DONE {
|
||||
break done
|
||||
}
|
||||
if m.Header.Type == NLMSG_ERROR {
|
||||
if len(m.Data) >= 4 {
|
||||
errno := Errno(-std.Int(binary.NativeEndian.Uint32(m.Data)))
|
||||
if errno == 0 {
|
||||
return nil
|
||||
}
|
||||
return errno
|
||||
}
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustRoundtrip calls roundtrip and terminates via msg for a non-nil error.
|
||||
func (s *rtnetlink) mustRoundtrip(msg message.Msg, data []byte) {
|
||||
err := s.roundtrip(data)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if closeErr := Close(s.fd); closeErr != nil {
|
||||
msg.Verbosef("cannot close: %v", err)
|
||||
}
|
||||
|
||||
switch err.(type) {
|
||||
case *os.SyscallError:
|
||||
msg.GetLogger().Fatalf("cannot %v", err)
|
||||
|
||||
case Errno:
|
||||
msg.GetLogger().Fatalf("RTNETLINK answers: %v", err)
|
||||
|
||||
default:
|
||||
msg.GetLogger().Fatalln("RTNETLINK answers with unexpected message")
|
||||
}
|
||||
}
|
||||
|
||||
// newaddrLo represents a RTM_NEWADDR message with two addresses.
|
||||
type newaddrLo struct {
|
||||
header NlMsghdr
|
||||
data IfAddrmsg
|
||||
|
||||
r0 RtAttr
|
||||
a0 [4]byte // in_addr
|
||||
r1 RtAttr
|
||||
a1 [4]byte // in_addr
|
||||
}
|
||||
|
||||
// sizeofNewaddrLo is the expected size of newaddrLo.
|
||||
const sizeofNewaddrLo = NLMSG_HDRLEN + SizeofIfAddrmsg + (SizeofRtAttr+4)*2
|
||||
|
||||
// newaddrLo returns the address of a populated newaddrLo.
|
||||
func (s *rtnetlink) newaddrLo(lo int) *newaddrLo {
|
||||
return &newaddrLo{NlMsghdr{
|
||||
Len: sizeofNewaddrLo,
|
||||
Type: RTM_NEWADDR,
|
||||
Flags: NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL,
|
||||
Seq: s.seq,
|
||||
Pid: s.pid,
|
||||
}, IfAddrmsg{
|
||||
Family: AF_INET,
|
||||
Prefixlen: 8,
|
||||
Flags: IFA_F_PERMANENT,
|
||||
Scope: RT_SCOPE_HOST,
|
||||
Index: uint32(lo),
|
||||
}, RtAttr{
|
||||
Len: uint16(SizeofRtAttr + len(newaddrLo{}.a0)),
|
||||
Type: IFA_LOCAL,
|
||||
}, [4]byte{127, 0, 0, 1}, RtAttr{
|
||||
Len: uint16(SizeofRtAttr + len(newaddrLo{}.a1)),
|
||||
Type: IFA_ADDRESS,
|
||||
}, [4]byte{127, 0, 0, 1}}
|
||||
}
|
||||
|
||||
func (msg *newaddrLo) toWireFormat() []byte {
|
||||
var buf [sizeofNewaddrLo]byte
|
||||
|
||||
*(*uint32)(unsafe.Pointer(&buf[0:4][0])) = msg.header.Len
|
||||
*(*uint16)(unsafe.Pointer(&buf[4:6][0])) = msg.header.Type
|
||||
*(*uint16)(unsafe.Pointer(&buf[6:8][0])) = msg.header.Flags
|
||||
*(*uint32)(unsafe.Pointer(&buf[8:12][0])) = msg.header.Seq
|
||||
*(*uint32)(unsafe.Pointer(&buf[12:16][0])) = msg.header.Pid
|
||||
|
||||
buf[16] = msg.data.Family
|
||||
buf[17] = msg.data.Prefixlen
|
||||
buf[18] = msg.data.Flags
|
||||
buf[19] = msg.data.Scope
|
||||
*(*uint32)(unsafe.Pointer(&buf[20:24][0])) = msg.data.Index
|
||||
|
||||
*(*uint16)(unsafe.Pointer(&buf[24:26][0])) = msg.r0.Len
|
||||
*(*uint16)(unsafe.Pointer(&buf[26:28][0])) = msg.r0.Type
|
||||
copy(buf[28:32], msg.a0[:])
|
||||
*(*uint16)(unsafe.Pointer(&buf[32:34][0])) = msg.r1.Len
|
||||
*(*uint16)(unsafe.Pointer(&buf[34:36][0])) = msg.r1.Type
|
||||
copy(buf[36:40], msg.a1[:])
|
||||
|
||||
return buf[:]
|
||||
}
|
||||
|
||||
// newlinkLo represents a RTM_NEWLINK message.
|
||||
type newlinkLo struct {
|
||||
header NlMsghdr
|
||||
data IfInfomsg
|
||||
}
|
||||
|
||||
// sizeofNewlinkLo is the expected size of newlinkLo.
|
||||
const sizeofNewlinkLo = NLMSG_HDRLEN + SizeofIfInfomsg
|
||||
|
||||
// newlinkLo returns the address of a populated newlinkLo.
|
||||
func (s *rtnetlink) newlinkLo(lo int) *newlinkLo {
|
||||
return &newlinkLo{NlMsghdr{
|
||||
Len: sizeofNewlinkLo,
|
||||
Type: RTM_NEWLINK,
|
||||
Flags: NLM_F_REQUEST | NLM_F_ACK,
|
||||
Seq: s.seq,
|
||||
Pid: s.pid,
|
||||
}, IfInfomsg{
|
||||
Family: AF_UNSPEC,
|
||||
Index: int32(lo),
|
||||
Flags: IFF_UP,
|
||||
Change: IFF_UP,
|
||||
}}
|
||||
}
|
||||
|
||||
func (msg *newlinkLo) toWireFormat() []byte {
|
||||
var buf [sizeofNewlinkLo]byte
|
||||
|
||||
*(*uint32)(unsafe.Pointer(&buf[0:4][0])) = msg.header.Len
|
||||
*(*uint16)(unsafe.Pointer(&buf[4:6][0])) = msg.header.Type
|
||||
*(*uint16)(unsafe.Pointer(&buf[6:8][0])) = msg.header.Flags
|
||||
*(*uint32)(unsafe.Pointer(&buf[8:12][0])) = msg.header.Seq
|
||||
*(*uint32)(unsafe.Pointer(&buf[12:16][0])) = msg.header.Pid
|
||||
|
||||
buf[16] = msg.data.Family
|
||||
*(*uint16)(unsafe.Pointer(&buf[18:20][0])) = msg.data.Type
|
||||
*(*int32)(unsafe.Pointer(&buf[20:24][0])) = msg.data.Index
|
||||
*(*uint32)(unsafe.Pointer(&buf[24:28][0])) = msg.data.Flags
|
||||
*(*uint32)(unsafe.Pointer(&buf[28:32][0])) = msg.data.Change
|
||||
|
||||
return buf[:]
|
||||
}
|
||||
|
||||
// mustLoopback creates the loopback address and brings the lo interface up.
|
||||
// mustLoopback calls a fatal method of the underlying [log.Logger] of m with a
|
||||
// user-facing error message if RTNETLINK behaves unexpectedly.
|
||||
func mustLoopback(msg message.Msg) {
|
||||
log := msg.GetLogger()
|
||||
|
||||
var lo int
|
||||
if ifi, err := net.InterfaceByName("lo"); err != nil {
|
||||
log.Fatalln(err)
|
||||
} else {
|
||||
lo = ifi.Index
|
||||
}
|
||||
|
||||
var s rtnetlink
|
||||
if err := s.open(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := s.close(); err != nil {
|
||||
msg.Verbosef("cannot close netlink: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
s.mustRoundtrip(msg, s.newaddrLo(lo).toWireFormat())
|
||||
s.mustRoundtrip(msg, s.newlinkLo(lo).toWireFormat())
|
||||
}
|
||||
72
container/netlink_test.go
Normal file
72
container/netlink_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func TestSizeof(t *testing.T) {
|
||||
if got := unsafe.Sizeof(newaddrLo{}); got != sizeofNewaddrLo {
|
||||
t.Fatalf("newaddrLo: sizeof = %#x, want %#x", got, sizeofNewaddrLo)
|
||||
}
|
||||
|
||||
if got := unsafe.Sizeof(newlinkLo{}); got != sizeofNewlinkLo {
|
||||
t.Fatalf("newlinkLo: sizeof = %#x, want %#x", got, sizeofNewlinkLo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRtnetlinkMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
msg interface{ toWireFormat() []byte }
|
||||
want []byte
|
||||
}{
|
||||
{"newaddrLo", (&rtnetlink{pid: 1, seq: 0}).newaddrLo(1), []byte{
|
||||
/* Len */ 0x28, 0, 0, 0,
|
||||
/* Type */ 0x14, 0,
|
||||
/* Flags */ 5, 6,
|
||||
/* Seq */ 0, 0, 0, 0,
|
||||
/* Pid */ 1, 0, 0, 0,
|
||||
|
||||
/* Family */ 2,
|
||||
/* Prefixlen */ 8,
|
||||
/* Flags */ 0x80,
|
||||
/* Scope */ 0xfe,
|
||||
/* Index */ 1, 0, 0, 0,
|
||||
|
||||
/* Len */ 8, 0,
|
||||
/* Type */ 2, 0,
|
||||
/* in_addr */ 127, 0, 0, 1,
|
||||
|
||||
/* Len */ 8, 0,
|
||||
/* Type */ 1, 0,
|
||||
/* in_addr */ 127, 0, 0, 1,
|
||||
}},
|
||||
|
||||
{"newlinkLo", (&rtnetlink{pid: 1, seq: 1}).newlinkLo(1), []byte{
|
||||
/* Len */ 0x20, 0, 0, 0,
|
||||
/* Type */ 0x10, 0,
|
||||
/* Flags */ 5, 0,
|
||||
/* Seq */ 1, 0, 0, 0,
|
||||
/* Pid */ 1, 0, 0, 0,
|
||||
|
||||
/* Family */ 0,
|
||||
/* pad */ 0,
|
||||
/* Type */ 0, 0,
|
||||
/* Index */ 1, 0, 0, 0,
|
||||
/* Flags */ 1, 0, 0, 0,
|
||||
/* Change */ 1, 0, 0, 0,
|
||||
}},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tc.msg.toWireFormat(); string(got) != string(tc.want) {
|
||||
t.Fatalf("toWireFormat: %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/vfs"
|
||||
"hakurei.app/container/fhs"
|
||||
"hakurei.app/container/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/vfs"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/container/vfs"
|
||||
)
|
||||
|
||||
func TestToSysroot(t *testing.T) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"unsafe"
|
||||
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/ext"
|
||||
)
|
||||
|
||||
// ErrInvalidRules is returned for a zero-length rules slice.
|
||||
@@ -220,9 +219,9 @@ const (
|
||||
|
||||
// syscallResolveName resolves a syscall number by name via seccomp_syscall_resolve_name.
|
||||
// This function is only for testing the lookup tables and included here for convenience.
|
||||
func syscallResolveName(s string) (num ext.SyscallNum, ok bool) {
|
||||
func syscallResolveName(s string) (num std.ScmpSyscall, ok bool) {
|
||||
v := C.CString(s)
|
||||
num = ext.SyscallNum(C.seccomp_syscall_resolve_name(v))
|
||||
num = std.ScmpSyscall(C.seccomp_syscall_resolve_name(v))
|
||||
C.free(unsafe.Pointer(v))
|
||||
ok = num != C.__NR_SCMP_ERROR
|
||||
return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user