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
|
# go generate
|
||||||
/cmd/hakurei/LICENSE
|
/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/pkg/testdata/testtool
|
||||||
/internal/rosa/hakurei_current.tar.gz
|
/internal/rosa/hakurei_current.tar.gz
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://git.gensokyo.uk/rosa/hakurei">
|
<a href="https://git.gensokyo.uk/security/hakurei">
|
||||||
<picture>
|
<picture>
|
||||||
<img src="https://basement.gensokyo.uk/images/yukari1.png" width="200px" alt="Yukari">
|
<img src="https://basement.gensokyo.uk/images/yukari1.png" width="200px" alt="Yukari">
|
||||||
</picture>
|
</picture>
|
||||||
@@ -8,16 +8,16 @@
|
|||||||
|
|
||||||
<p align="center">
|
<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://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/>
|
<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://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>
|
<a href="https://hakurei.app"><img src="https://img.shields.io/website?url=https%3A%2F%2Fhakurei.app" alt="Website" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Hakurei is a tool for running sandboxed desktop applications as dedicated
|
Hakurei is a tool for running sandboxed desktop applications as dedicated
|
||||||
subordinate users on the Linux kernel. It implements the application container
|
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.
|
self-contained Android-like package manager with modern security features.
|
||||||
|
|
||||||
Interaction with hakurei happens entirely through structures described by
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
. "syscall"
|
. "syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,22 +12,6 @@ func main() {
|
|||||||
log.SetFlags(0)
|
log.SetFlags(0)
|
||||||
log.SetPrefix("earlyinit: ")
|
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(
|
if err := Mount(
|
||||||
"devtmpfs",
|
"devtmpfs",
|
||||||
"/dev/",
|
"/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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -12,11 +11,12 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
_ "unsafe" // for go:linkname
|
||||||
|
|
||||||
"hakurei.app/check"
|
|
||||||
"hakurei.app/command"
|
"hakurei.app/command"
|
||||||
"hakurei.app/ext"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/fhs"
|
"hakurei.app/container/fhs"
|
||||||
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/hst"
|
"hakurei.app/hst"
|
||||||
"hakurei.app/internal/dbus"
|
"hakurei.app/internal/dbus"
|
||||||
"hakurei.app/internal/env"
|
"hakurei.app/internal/env"
|
||||||
@@ -27,14 +27,9 @@ import (
|
|||||||
|
|
||||||
// optionalErrorUnwrap calls [errors.Unwrap] and returns the resulting value
|
// optionalErrorUnwrap calls [errors.Unwrap] and returns the resulting value
|
||||||
// if it is not nil, or the original value if it is.
|
// if it is not nil, or the original value if it is.
|
||||||
func optionalErrorUnwrap(err error) error {
|
//
|
||||||
if underlyingErr := errors.Unwrap(err); underlyingErr != nil {
|
//go:linkname optionalErrorUnwrap hakurei.app/container.optionalErrorUnwrap
|
||||||
return underlyingErr
|
func optionalErrorUnwrap(err error) error
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var errSuccess = errors.New("success")
|
|
||||||
|
|
||||||
func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErrs, out io.Writer) command.Command {
|
func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErrs, out io.Writer) command.Command {
|
||||||
var (
|
var (
|
||||||
@@ -65,9 +60,9 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
|||||||
var (
|
var (
|
||||||
flagIdentifierFile int
|
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 {
|
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])
|
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
|
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 {
|
if flagIdentity < hst.IdentityStart || flagIdentity > hst.IdentityEnd {
|
||||||
log.Fatalf("identity %d out of range", flagIdentity)
|
log.Fatalf("identity %d out of range", flagIdentity)
|
||||||
}
|
}
|
||||||
@@ -191,7 +186,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
config.SchedPriority = ext.Int(flagSchedPriority)
|
config.SchedPriority = std.Int(flagSchedPriority)
|
||||||
|
|
||||||
// bind GPU stuff
|
// bind GPU stuff
|
||||||
if et&(hst.EX11|hst.EWayland) != 0 {
|
if et&(hst.EX11|hst.EWayland) != 0 {
|
||||||
@@ -328,7 +323,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
|||||||
flagShort bool
|
flagShort bool
|
||||||
flagNoStore 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) {
|
switch len(args) {
|
||||||
case 0: // system
|
case 0: // system
|
||||||
printShowSystem(os.Stdout, flagShort, flagJSON)
|
printShowSystem(os.Stdout, flagShort, flagJSON)
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ func TestHelp(t *testing.T) {
|
|||||||
Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS]
|
Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
run Load and start container from configuration file
|
app Load and start container from configuration file
|
||||||
exec Configure and start a permissive container
|
run Configure and start a permissive container
|
||||||
show Show live or local instance configuration
|
show Show live or local app configuration
|
||||||
ps List active instances
|
ps List active instances
|
||||||
version Display version information
|
version Display version information
|
||||||
license Show full license text
|
license Show full license text
|
||||||
@@ -35,8 +35,8 @@ Commands:
|
|||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"exec", []string{"exec", "-h"}, `
|
"run", []string{"run", "-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]
|
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:
|
Flags:
|
||||||
-X Enable direct connection to X11
|
-X Enable direct connection to X11
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDecodeJSON(t *testing.T) {
|
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
|
package main
|
||||||
|
|
||||||
|
// this works around go:embed '..' limitation
|
||||||
|
//go:generate cp ../../LICENSE .
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
@@ -47,13 +13,15 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"hakurei.app/container"
|
"hakurei.app/container"
|
||||||
"hakurei.app/ext"
|
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate cp ../../LICENSE .
|
var (
|
||||||
|
errSuccess = errors.New("success")
|
||||||
|
|
||||||
//go:embed LICENSE
|
//go:embed LICENSE
|
||||||
var license string
|
license string
|
||||||
|
)
|
||||||
|
|
||||||
// earlyHardeningErrs are errors collected while setting up early hardening feature.
|
// earlyHardeningErrs are errors collected while setting up early hardening feature.
|
||||||
type earlyHardeningErrs struct{ yamaLSM, dumpable error }
|
type earlyHardeningErrs struct{ yamaLSM, dumpable error }
|
||||||
@@ -62,13 +30,13 @@ func main() {
|
|||||||
// early init path, skips root check and duplicate PR_SET_DUMPABLE
|
// early init path, skips root check and duplicate PR_SET_DUMPABLE
|
||||||
container.TryArgv0(nil)
|
container.TryArgv0(nil)
|
||||||
|
|
||||||
log.SetFlags(0)
|
|
||||||
log.SetPrefix("hakurei: ")
|
log.SetPrefix("hakurei: ")
|
||||||
|
log.SetFlags(0)
|
||||||
msg := message.New(log.Default())
|
msg := message.New(log.Default())
|
||||||
|
|
||||||
early := earlyHardeningErrs{
|
early := earlyHardeningErrs{
|
||||||
yamaLSM: ext.SetPtracer(0),
|
yamaLSM: container.SetPtracer(0),
|
||||||
dumpable: ext.SetDumpable(ext.SUID_DUMP_DISABLE),
|
dumpable: container.SetDumpable(container.SUID_DUMP_DISABLE),
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Geteuid() == 0 {
|
if os.Geteuid() == 0 {
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// tryPath attempts to read [hst.Config] from multiple sources.
|
// tryPath attempts to read [hst.Config] from multiple sources.
|
||||||
//
|
// tryPath reads from [os.Stdin] if name has value "-".
|
||||||
// tryPath reads from [os.Stdin] if name has value "-". Otherwise, name is
|
// Otherwise, name is passed to tryFd, and if that returns nil, name is passed to [os.Open].
|
||||||
// passed to tryFd, and if that returns nil, name is passed to [os.Open].
|
|
||||||
func tryPath(msg message.Msg, name string) (config *hst.Config) {
|
func tryPath(msg message.Msg, name string) (config *hst.Config) {
|
||||||
var r io.ReadCloser
|
var r io.ReadCloser
|
||||||
config = new(hst.Config)
|
config = new(hst.Config)
|
||||||
@@ -47,8 +46,7 @@ func tryPath(msg message.Msg, name string) (config *hst.Config) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryFd returns a [io.ReadCloser] if name represents an integer corresponding
|
// tryFd returns a [io.ReadCloser] if name represents an integer corresponding to a valid file descriptor.
|
||||||
// to a valid file descriptor.
|
|
||||||
func tryFd(msg message.Msg, name string) io.ReadCloser {
|
func tryFd(msg message.Msg, name string) io.ReadCloser {
|
||||||
if v, err := strconv.Atoi(name); err != nil {
|
if v, err := strconv.Atoi(name); err != nil {
|
||||||
if !errors.Is(err, strconv.ErrSyntax) {
|
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)
|
msg.Verbosef("trying config stream from %d", v)
|
||||||
fd := uintptr(v)
|
fd := uintptr(v)
|
||||||
if _, _, errno := syscall.Syscall(
|
if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETFD, 0); errno != 0 {
|
||||||
syscall.SYS_FCNTL,
|
|
||||||
fd,
|
|
||||||
syscall.F_GETFD,
|
|
||||||
0,
|
|
||||||
); errno != 0 {
|
|
||||||
if errors.Is(errno, syscall.EBADF) { // reject bad fd
|
if errors.Is(errno, syscall.EBADF) { // reject bad fd
|
||||||
return nil
|
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
|
// shortLengthMin is the minimum length a short form identifier can have and still be interpreted as an identifier.
|
||||||
// still be interpreted as an identifier.
|
|
||||||
const shortLengthMin = 1 << 3
|
const shortLengthMin = 1 << 3
|
||||||
|
|
||||||
// shortIdentifier returns an eight character short representation of [hst.ID]
|
// shortIdentifier returns an eight character short representation of [hst.ID] from its random bytes.
|
||||||
// from its random bytes.
|
|
||||||
func shortIdentifier(id *hst.ID) string {
|
func shortIdentifier(id *hst.ID) string {
|
||||||
return shortIdentifierString(id.String())
|
return shortIdentifierString(id.String())
|
||||||
}
|
}
|
||||||
@@ -97,8 +88,7 @@ func shortIdentifierString(s string) string {
|
|||||||
return s[len(hst.ID{}) : len(hst.ID{})+shortLengthMin]
|
return s[len(hst.ID{}) : len(hst.ID{})+shortLengthMin]
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryIdentifier attempts to match [hst.State] from a [hex] representation of
|
// tryIdentifier attempts to match [hst.State] from a [hex] representation of [hst.ID] or a prefix of its lower half.
|
||||||
// [hst.ID] or a prefix of its lower half.
|
|
||||||
func tryIdentifier(msg message.Msg, name string, s *store.Store) *hst.State {
|
func tryIdentifier(msg message.Msg, name string, s *store.Store) *hst.State {
|
||||||
const (
|
const (
|
||||||
likeShort = 1 << iota
|
likeShort = 1 << iota
|
||||||
@@ -106,8 +96,7 @@ func tryIdentifier(msg message.Msg, name string, s *store.Store) *hst.State {
|
|||||||
)
|
)
|
||||||
|
|
||||||
var likely uintptr
|
var likely uintptr
|
||||||
// half the hex representation
|
if len(name) >= shortLengthMin && len(name) <= len(hst.ID{}) { // half the hex representation
|
||||||
if len(name) >= shortLengthMin && len(name) <= len(hst.ID{}) {
|
|
||||||
// cannot safely decode here due to unknown alignment
|
// cannot safely decode here due to unknown alignment
|
||||||
for _, c := range name {
|
for _, c := range name {
|
||||||
if c >= '0' && c <= '9' {
|
if c >= '0' && c <= '9' {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/hst"
|
"hakurei.app/hst"
|
||||||
"hakurei.app/internal/store"
|
"hakurei.app/internal/store"
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/hst"
|
"hakurei.app/hst"
|
||||||
"hakurei.app/internal/store"
|
"hakurei.app/internal/store"
|
||||||
"hakurei.app/message"
|
"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
|
package main
|
||||||
|
|
||||||
/* keep in sync with hst */
|
/* copied from hst and must never be changed */
|
||||||
|
|
||||||
const (
|
const (
|
||||||
userOffset = 100000
|
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
|
package main
|
||||||
|
|
||||||
|
// minimise imports to avoid inadvertently calling init or global variable functions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -67,13 +16,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// envShim is the name of the environment variable holding a single byte
|
// envIdentity is the name of the environment variable holding a
|
||||||
// representing the shim setup pipe file descriptor.
|
// single byte representing the shim setup pipe file descriptor.
|
||||||
envShim = "HAKUREI_SHIM"
|
envShim = "HAKUREI_SHIM"
|
||||||
// envIdentity is the name of the environment variable holding a decimal
|
// envGroups holds a ' ' separated list of string representations of
|
||||||
// string representation of the current application identity.
|
|
||||||
envIdentity = "HAKUREI_IDENTITY"
|
|
||||||
// envGroups holds a ' ' separated list of decimal string representations of
|
|
||||||
// supplementary group gid. Membership requirements are enforced.
|
// supplementary group gid. Membership requirements are enforced.
|
||||||
envGroups = "HAKUREI_GROUPS"
|
envGroups = "HAKUREI_GROUPS"
|
||||||
)
|
)
|
||||||
@@ -89,6 +35,7 @@ func main() {
|
|||||||
|
|
||||||
log.SetFlags(0)
|
log.SetFlags(0)
|
||||||
log.SetPrefix("hsu: ")
|
log.SetPrefix("hsu: ")
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
log.Fatal("this program must be owned by uid 0 and have the setuid bit set")
|
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
|
// last possible uid outcome
|
||||||
uidEnd = 999919999
|
uidEnd = 999919999
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// cast to int for use with library functions
|
||||||
uid := int(toUser(userid, identity))
|
uid := int(toUser(userid, identity))
|
||||||
|
|
||||||
// final bounds check to catch any bugs
|
// 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
|
// careful! users in the allowlist is effectively allowed to drop groups via hsu
|
||||||
|
|
||||||
if err := syscall.Setresgid(uid, uid, uid); err != nil {
|
if err := syscall.Setresgid(uid, uid, uid); err != nil {
|
||||||
log.Fatalf("cannot set gid: %v", err)
|
log.Fatalf("cannot set gid: %v", err)
|
||||||
}
|
}
|
||||||
@@ -196,21 +146,10 @@ func main() {
|
|||||||
if err := syscall.Setresuid(uid, uid, uid); err != nil {
|
if err := syscall.Setresuid(uid, uid, uid); err != nil {
|
||||||
log.Fatalf("cannot set uid: %v", err)
|
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())
|
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)
|
log.Fatalf("cannot start shim: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ const (
|
|||||||
useridEnd = useridStart + rangeSize - 1
|
useridEnd = useridStart + rangeSize - 1
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseUint32Fast parses a string representation of an unsigned 32-bit integer
|
// parseUint32Fast parses a string representation of an unsigned 32-bit integer value
|
||||||
// value using the fast path only. This limits the range of values it is defined
|
// using the fast path only. This limits the range of values it is defined in.
|
||||||
// in but is perfectly adequate for this use case.
|
|
||||||
func parseUint32Fast(s string) (uint32, error) {
|
func parseUint32Fast(s string) (uint32, error) {
|
||||||
sLen := len(s)
|
sLen := len(s)
|
||||||
if sLen < 1 {
|
if sLen < 1 {
|
||||||
@@ -41,14 +40,12 @@ func parseUint32Fast(s string) (uint32, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseConfig reads a list of allowed users from r until it encounters puid or
|
// parseConfig reads a list of allowed users from r until it encounters puid or [io.EOF].
|
||||||
// [io.EOF].
|
|
||||||
//
|
//
|
||||||
// Each line of the file specifies a hakurei userid to kernel uid mapping. A
|
// Each line of the file specifies a hakurei userid to kernel uid mapping. A line consists
|
||||||
// line consists of the string representation of the uid of the user wishing to
|
// of the string representation of the uid of the user wishing to start hakurei containers,
|
||||||
// start hakurei containers, followed by a space, followed by the string
|
// followed by a space, followed by the string representation of its userid. Duplicate uid
|
||||||
// representation of its userid. Duplicate uid entries are ignored, with the
|
// entries are ignored, with the first occurrence taking effect.
|
||||||
// first occurrence taking effect.
|
|
||||||
//
|
//
|
||||||
// All string representations are parsed by calling parseUint32Fast.
|
// All string representations are parsed by calling parseUint32Fast.
|
||||||
func parseConfig(r io.Reader, puid uint32) (userid uint32, ok bool, err error) {
|
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()
|
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,
|
// mustParseConfig calls parseConfig to interpret the contents of hsuConfPath,
|
||||||
// terminating the program if an error is encountered, the syntax is incorrect,
|
// 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.
|
// 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
|
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,
|
// mustReadIdentity calls parseUint32Fast to interpret the value stored in envIdentity,
|
||||||
// terminating the program if the value is not set, malformed, or out of bounds.
|
// terminating the program if the value is not set, malformed, or out of bounds.
|
||||||
func mustReadIdentity() uint32 {
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,13 +18,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unique"
|
"unique"
|
||||||
|
|
||||||
"hakurei.app/check"
|
|
||||||
"hakurei.app/command"
|
"hakurei.app/command"
|
||||||
"hakurei.app/container"
|
"hakurei.app/container"
|
||||||
|
"hakurei.app/container/check"
|
||||||
|
"hakurei.app/container/fhs"
|
||||||
"hakurei.app/container/seccomp"
|
"hakurei.app/container/seccomp"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/ext"
|
|
||||||
"hakurei.app/fhs"
|
|
||||||
"hakurei.app/internal/pkg"
|
"hakurei.app/internal/pkg"
|
||||||
"hakurei.app/internal/rosa"
|
"hakurei.app/internal/rosa"
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
@@ -284,7 +271,7 @@ func main() {
|
|||||||
return errors.New("report requires 1 argument")
|
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 errors.New("output appears to be a terminal")
|
||||||
}
|
}
|
||||||
return rosa.WriteReport(msg, w, cache)
|
return rosa.WriteReport(msg, w, cache)
|
||||||
@@ -448,7 +435,6 @@ func main() {
|
|||||||
{
|
{
|
||||||
var (
|
var (
|
||||||
flagDump string
|
flagDump string
|
||||||
flagEnter bool
|
|
||||||
flagExport string
|
flagExport string
|
||||||
)
|
)
|
||||||
c.NewCommand(
|
c.NewCommand(
|
||||||
@@ -458,13 +444,9 @@ func main() {
|
|||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
return errors.New("cure requires 1 argument")
|
return errors.New("cure requires 1 argument")
|
||||||
}
|
}
|
||||||
p, ok := rosa.ResolveName(args[0])
|
if p, ok := rosa.ResolveName(args[0]); !ok {
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unknown artifact %q", args[0])
|
return fmt.Errorf("unknown artifact %q", args[0])
|
||||||
}
|
} else if flagDump == "" {
|
||||||
|
|
||||||
switch {
|
|
||||||
default:
|
|
||||||
pathname, _, err := cache.Cure(rosa.Std.Load(p))
|
pathname, _, err := cache.Cure(rosa.Std.Load(p))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -494,8 +476,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
} else {
|
||||||
case flagDump != "":
|
|
||||||
f, err := os.OpenFile(
|
f, err := os.OpenFile(
|
||||||
flagDump,
|
flagDump,
|
||||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||||
@@ -511,15 +492,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return f.Close()
|
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,
|
&flagExport,
|
||||||
"export", command.StringFlag(""),
|
"export", command.StringFlag(""),
|
||||||
"Export cured artifact to specified pathname",
|
"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
|
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...)
|
root = rosa.Std.AppendPresets(root, presets...)
|
||||||
|
|
||||||
if flagWithToolchain {
|
if flagWithToolchain {
|
||||||
@@ -575,7 +542,7 @@ func main() {
|
|||||||
|
|
||||||
if _, _, err := cache.Cure(&root); err == nil {
|
if _, _, err := cache.Cure(&root); err == nil {
|
||||||
return errors.New("unreachable")
|
return errors.New("unreachable")
|
||||||
} else if !pkg.IsCollected(err) {
|
} else if !errors.Is(err, rosa.Collected{}) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,13 +635,13 @@ func main() {
|
|||||||
).
|
).
|
||||||
Flag(
|
Flag(
|
||||||
&flagSession,
|
&flagSession,
|
||||||
"session", command.BoolFlag(true),
|
"session", command.BoolFlag(false),
|
||||||
"Retain session",
|
"Retain session",
|
||||||
).
|
).
|
||||||
Flag(
|
Flag(
|
||||||
&flagWithToolchain,
|
&flagWithToolchain,
|
||||||
"with-toolchain", command.BoolFlag(false),
|
"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"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"hakurei.app/check"
|
|
||||||
"hakurei.app/container"
|
"hakurei.app/container"
|
||||||
|
"hakurei.app/container/check"
|
||||||
|
"hakurei.app/container/fhs"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/fhs"
|
|
||||||
"hakurei.app/hst"
|
"hakurei.app/hst"
|
||||||
"hakurei.app/internal/helper/proc"
|
"hakurei.app/internal/helper/proc"
|
||||||
"hakurei.app/internal/info"
|
"hakurei.app/internal/info"
|
||||||
@@ -85,10 +85,7 @@ func destroySetup(private_data unsafe.Pointer) (ok bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//export sharefs_init
|
//export sharefs_init
|
||||||
func sharefs_init(
|
func sharefs_init(_ *C.struct_fuse_conn_info, cfg *C.struct_fuse_config) unsafe.Pointer {
|
||||||
_ *C.struct_fuse_conn_info,
|
|
||||||
cfg *C.struct_fuse_config,
|
|
||||||
) unsafe.Pointer {
|
|
||||||
ctx := C.fuse_get_context()
|
ctx := C.fuse_get_context()
|
||||||
priv := (*C.struct_sharefs_private)(ctx.private_data)
|
priv := (*C.struct_sharefs_private)(ctx.private_data)
|
||||||
setup := cgo.Handle(priv.setup).Value().(*setupState)
|
setup := cgo.Handle(priv.setup).Value().(*setupState)
|
||||||
@@ -106,11 +103,7 @@ func sharefs_init(
|
|||||||
cfg.negative_timeout = 0
|
cfg.negative_timeout = 0
|
||||||
|
|
||||||
// all future filesystem operations happen through this dirfd
|
// all future filesystem operations happen through this dirfd
|
||||||
if fd, err := syscall.Open(
|
if fd, err := syscall.Open(setup.Source.String(), syscall.O_DIRECTORY|syscall.O_RDONLY|syscall.O_CLOEXEC, 0); err != nil {
|
||||||
setup.Source.String(),
|
|
||||||
syscall.O_DIRECTORY|syscall.O_RDONLY|syscall.O_CLOEXEC,
|
|
||||||
0,
|
|
||||||
); err != nil {
|
|
||||||
log.Printf("cannot open %q: %v", setup.Source, err)
|
log.Printf("cannot open %q: %v", setup.Source, err)
|
||||||
goto fail
|
goto fail
|
||||||
} else if err = syscall.Fchdir(fd); err != nil {
|
} 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.
|
// Decimal string representation of gid to set when running as root.
|
||||||
setgid *C.char
|
setgid *C.char
|
||||||
|
|
||||||
// Decimal string representation of open file descriptor to read
|
// Decimal string representation of open file descriptor to read setupState from.
|
||||||
// setupState from.
|
// This is an internal detail for containerisation and must not be specified directly.
|
||||||
//
|
|
||||||
// This is an internal detail for containerisation and must not be
|
|
||||||
// specified directly.
|
|
||||||
setup *C.char
|
setup *C.char
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,8 +253,7 @@ func parseOpts(args *fuseArgs, setup *setupState, log *log.Logger) (ok bool) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyArgs returns a heap allocated copy of an argument slice in fuse_args
|
// copyArgs returns a heap allocated copy of an argument slice in fuse_args representation.
|
||||||
// representation.
|
|
||||||
func copyArgs(s ...string) fuseArgs {
|
func copyArgs(s ...string) fuseArgs {
|
||||||
if len(s) == 0 {
|
if len(s) == 0 {
|
||||||
return fuseArgs{argc: 0, argv: nil, allocated: 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) }
|
func freeArgs(args *fuseArgs) { C.fuse_opt_free_args(args) }
|
||||||
|
|
||||||
// unsafeAddArgument adds an argument to fuseArgs via fuse_opt_add_arg.
|
// unsafeAddArgument adds an argument to fuseArgs via fuse_opt_add_arg.
|
||||||
//
|
|
||||||
// The last byte of arg must be 0.
|
// The last byte of arg must be 0.
|
||||||
func unsafeAddArgument(args *fuseArgs, arg string) {
|
func unsafeAddArgument(args *fuseArgs, arg string) {
|
||||||
C.fuse_opt_add_arg(args, (*C.char)(unsafe.Pointer(unsafe.StringData(arg))))
|
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...)
|
args := copyArgs(s...)
|
||||||
defer freeArgs(&args)
|
defer freeArgs(&args)
|
||||||
|
|
||||||
// this causes the kernel to enforce access control based on struct stat
|
// this causes the kernel to enforce access control based on
|
||||||
// populated by sharefs_getattr
|
// struct stat populated by sharefs_getattr
|
||||||
unsafeAddArgument(&args, "-odefault_permissions\x00")
|
unsafeAddArgument(&args, "-odefault_permissions\x00")
|
||||||
|
|
||||||
var priv C.struct_sharefs_private
|
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.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||||
}
|
}
|
||||||
z.Bind(z.Path, z.Path, 0)
|
z.Bind(z.Path, z.Path, 0)
|
||||||
setup.Fuse = int(proc.ExtraFileSlice(
|
setup.Fuse = int(proc.ExtraFileSlice(&z.ExtraFiles, os.NewFile(uintptr(C.fuse_session_fd(se)), "fuse")))
|
||||||
&z.ExtraFiles,
|
|
||||||
os.NewFile(uintptr(C.fuse_session_fd(se)), "fuse"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var setupWriter io.WriteCloser
|
var setupWriter io.WriteCloser
|
||||||
if fd, w, err := container.Setup(&z.ExtraFiles); err != nil {
|
if fd, w, err := container.Setup(&z.ExtraFiles); err != nil {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseOpts(t *testing.T) {
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/fhs"
|
"hakurei.app/container/fhs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { gob.Register(new(AutoEtcOp)) }
|
func init() { gob.Register(new(AutoEtcOp)) }
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAutoEtcOp(t *testing.T) {
|
func TestAutoEtcOp(t *testing.T) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/fhs"
|
"hakurei.app/container/fhs"
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package container
|
|||||||
import (
|
import (
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"hakurei.app/ext"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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.
|
// capBoundingSetDrop drops a capability from the calling thread's capability bounding set.
|
||||||
func capBoundingSetDrop(cap uintptr) error {
|
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.
|
// capAmbientClearAll clears the ambient capability set of the calling thread.
|
||||||
func capAmbientClearAll() error {
|
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.
|
// capAmbientRaise adds to the ambient capability set of the calling thread.
|
||||||
func capAmbientRaise(cap uintptr) error {
|
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"
|
"testing"
|
||||||
_ "unsafe" // for go:linkname
|
_ "unsafe" // for go:linkname
|
||||||
|
|
||||||
. "hakurei.app/check"
|
. "hakurei.app/container/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
// unsafeAbs returns check.Absolute on any string value.
|
// 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 unsafeAbs(pathname string) *Absolute
|
||||||
|
|
||||||
func TestAbsoluteError(t *testing.T) {
|
func TestAbsoluteError(t *testing.T) {
|
||||||
@@ -3,7 +3,7 @@ package check_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEscapeOverlayDataSegment(t *testing.T) {
|
func TestEscapeOverlayDataSegment(t *testing.T) {
|
||||||
@@ -16,11 +16,10 @@ import (
|
|||||||
. "syscall"
|
. "syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
|
"hakurei.app/container/fhs"
|
||||||
"hakurei.app/container/seccomp"
|
"hakurei.app/container/seccomp"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/ext"
|
|
||||||
"hakurei.app/fhs"
|
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,10 +41,10 @@ type (
|
|||||||
// Whether to set SchedPolicy and SchedPriority via sched_setscheduler(2).
|
// Whether to set SchedPolicy and SchedPriority via sched_setscheduler(2).
|
||||||
SetScheduler bool
|
SetScheduler bool
|
||||||
// Scheduling policy to set via sched_setscheduler(2).
|
// 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
|
// Scheduling priority to set via sched_setscheduler(2). The zero value
|
||||||
// implies the minimum value supported by the current SchedPolicy.
|
// implies the minimum value supported by the current SchedPolicy.
|
||||||
SchedPriority ext.Int
|
SchedPriority std.Int
|
||||||
// Cgroup fd, nil to disable.
|
// Cgroup fd, nil to disable.
|
||||||
Cgroup *int
|
Cgroup *int
|
||||||
// ExtraFiles passed through to initial process in the container, with
|
// ExtraFiles passed through to initial process in the container, with
|
||||||
@@ -186,24 +185,31 @@ var (
|
|||||||
closeOnExecErr error
|
closeOnExecErr error
|
||||||
)
|
)
|
||||||
|
|
||||||
// ensureCloseOnExec ensures all currently open file descriptors have the
|
// ensureCloseOnExec ensures all currently open file descriptors have the syscall.FD_CLOEXEC flag set.
|
||||||
// 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.
|
||||||
// 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 {
|
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 {
|
if closeOnExecErr == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &StartError{
|
return &StartError{Fatal: true, Step: "set FD_CLOEXEC on all open files", Err: closeOnExecErr, Passthrough: true}
|
||||||
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.
|
// 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
|
// sched_setscheduler: thread-directed but acts on all processes
|
||||||
// created from the calling thread
|
// created from the calling thread
|
||||||
if p.SetScheduler {
|
if p.SetScheduler {
|
||||||
if p.SchedPolicy < 0 || p.SchedPolicy > ext.SCHED_LAST {
|
if p.SchedPolicy < 0 || p.SchedPolicy > std.SCHED_LAST {
|
||||||
return &StartError{
|
return &StartError{
|
||||||
Fatal: false,
|
Fatal: false,
|
||||||
Step: "set scheduling policy",
|
Step: "set scheduling policy",
|
||||||
|
|||||||
@@ -18,17 +18,16 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hakurei.app/check"
|
|
||||||
"hakurei.app/command"
|
"hakurei.app/command"
|
||||||
"hakurei.app/container"
|
"hakurei.app/container"
|
||||||
|
"hakurei.app/container/check"
|
||||||
|
"hakurei.app/container/fhs"
|
||||||
"hakurei.app/container/seccomp"
|
"hakurei.app/container/seccomp"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/ext"
|
"hakurei.app/container/vfs"
|
||||||
"hakurei.app/fhs"
|
|
||||||
"hakurei.app/hst"
|
"hakurei.app/hst"
|
||||||
"hakurei.app/ldd"
|
"hakurei.app/ldd"
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
"hakurei.app/vfs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Note: this package requires cgo, which is unavailable in the Go playground.
|
// 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},
|
1000, 100, nil, 0, std.PresetExt},
|
||||||
{"custom rules", true, true, true, false,
|
{"custom rules", true, true, true, false,
|
||||||
emptyOps, emptyMnt,
|
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,
|
{"tmpfs", true, false, false, true,
|
||||||
earlyOps(new(container.Ops).
|
earlyOps(new(container.Ops).
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -14,8 +12,6 @@ import (
|
|||||||
|
|
||||||
"hakurei.app/container/seccomp"
|
"hakurei.app/container/seccomp"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/ext"
|
|
||||||
"hakurei.app/internal/netlink"
|
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,7 +63,7 @@ type syscallDispatcher interface {
|
|||||||
// ensureFile provides ensureFile.
|
// ensureFile provides ensureFile.
|
||||||
ensureFile(name string, perm, pperm os.FileMode) error
|
ensureFile(name string, perm, pperm os.FileMode) error
|
||||||
// mustLoopback provides mustLoopback.
|
// mustLoopback provides mustLoopback.
|
||||||
mustLoopback(ctx context.Context, msg message.Msg)
|
mustLoopback(msg message.Msg)
|
||||||
|
|
||||||
// seccompLoad provides [seccomp.Load].
|
// seccompLoad provides [seccomp.Load].
|
||||||
seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error
|
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) lockOSThread() { runtime.LockOSThread() }
|
||||||
|
|
||||||
func (direct) setPtracer(pid uintptr) error { return ext.SetPtracer(pid) }
|
func (direct) setPtracer(pid uintptr) error { return SetPtracer(pid) }
|
||||||
func (direct) setDumpable(dumpable uintptr) error { return ext.SetDumpable(dumpable) }
|
func (direct) setDumpable(dumpable uintptr) error { return SetDumpable(dumpable) }
|
||||||
func (direct) setNoNewPrivs() error { return SetNoNewPrivs() }
|
func (direct) setNoNewPrivs() error { return SetNoNewPrivs() }
|
||||||
|
|
||||||
func (direct) lastcap(msg message.Msg) uintptr { return LastCap(msg) }
|
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) capBoundingSetDrop(cap uintptr) error { return capBoundingSetDrop(cap) }
|
||||||
func (direct) capAmbientClearAll() error { return capAmbientClearAll() }
|
func (direct) capAmbientClearAll() error { return capAmbientClearAll() }
|
||||||
func (direct) capAmbientRaise(cap uintptr) error { return capAmbientRaise(cap) }
|
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) {
|
func (direct) receive(key string, e any, fdp *uintptr) (func() error, error) {
|
||||||
return Receive(key, e, fdp)
|
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 {
|
func (direct) ensureFile(name string, perm, pperm os.FileMode) error {
|
||||||
return ensureFile(name, perm, pperm)
|
return ensureFile(name, perm, pperm)
|
||||||
}
|
}
|
||||||
func (direct) mustLoopback(ctx context.Context, msg message.Msg) {
|
func (direct) mustLoopback(msg message.Msg) { mustLoopback(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) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error {
|
func (direct) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error {
|
||||||
return seccomp.Load(rules, flags)
|
return seccomp.Load(rules, flags)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -19,7 +18,7 @@ import (
|
|||||||
|
|
||||||
"hakurei.app/container/seccomp"
|
"hakurei.app/container/seccomp"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
"hakurei.app/message"
|
"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))
|
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 {
|
func (k *kstub) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error {
|
||||||
k.Helper()
|
k.Helper()
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
|
"hakurei.app/container/vfs"
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
"hakurei.app/vfs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// messageFromError returns a printable error message for a supported concrete type.
|
// messageFromError returns a printable error message for a supported concrete type.
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
"hakurei.app/vfs"
|
"hakurei.app/container/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMessageFromError(t *testing.T) {
|
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 (
|
import (
|
||||||
_ "unsafe" // for go:linkname
|
_ "unsafe" // for go:linkname
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
/* constants in this file bypass abs check, be extremely careful when changing them! */
|
/* constants in this file bypass abs check, be extremely careful when changing them! */
|
||||||
|
|
||||||
// unsafeAbs returns check.Absolute on any string value.
|
// 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
|
func unsafeAbs(pathname string) *check.Absolute
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -16,9 +15,8 @@ import (
|
|||||||
. "syscall"
|
. "syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hakurei.app/container/fhs"
|
||||||
"hakurei.app/container/seccomp"
|
"hakurei.app/container/seccomp"
|
||||||
"hakurei.app/ext"
|
|
||||||
"hakurei.app/fhs"
|
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,15 +174,11 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !params.HostNet {
|
if !params.HostNet {
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), CancelSignal,
|
k.mustLoopback(msg)
|
||||||
os.Interrupt, SIGTERM, SIGQUIT)
|
|
||||||
defer cancel() // for panics
|
|
||||||
k.mustLoopback(ctx, msg)
|
|
||||||
cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// write uid/gid map here so parent does not need to set dumpable
|
// 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)
|
k.fatalf(msg, "cannot set SUID_DUMP_USER: %v", err)
|
||||||
}
|
}
|
||||||
if err := k.writeFile(fhs.Proc+"self/uid_map",
|
if err := k.writeFile(fhs.Proc+"self/uid_map",
|
||||||
@@ -202,7 +196,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
|||||||
0); err != nil {
|
0); err != nil {
|
||||||
k.fatalf(msg, "%v", err)
|
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)
|
k.fatalf(msg, "cannot set SUID_DUMP_DISABLE: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +290,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
|||||||
|
|
||||||
{
|
{
|
||||||
var fd int
|
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)
|
fd, err = k.open(fhs.Root, O_DIRECTORY|O_RDONLY, 0)
|
||||||
return
|
return
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/container/seccomp"
|
"hakurei.app/container/seccomp"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInitEntrypoint(t *testing.T) {
|
func TestInitEntrypoint(t *testing.T) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBindMountOp(t *testing.T) {
|
func TestBindMountOp(t *testing.T) {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/fhs"
|
"hakurei.app/container/fhs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { gob.Register(new(DaemonOp)) }
|
func init() { gob.Register(new(DaemonOp)) }
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
. "syscall"
|
. "syscall"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/fhs"
|
"hakurei.app/container/fhs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { gob.Register(new(MountDevOp)) }
|
func init() { gob.Register(new(MountDevOp)) }
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMountDevOp(t *testing.T) {
|
func TestMountDevOp(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { gob.Register(new(MkdirOp)) }
|
func init() { gob.Register(new(MkdirOp)) }
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMkdirOp(t *testing.T) {
|
func TestMkdirOp(t *testing.T) {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/fhs"
|
"hakurei.app/container/fhs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMountOverlayOp(t *testing.T) {
|
func TestMountOverlayOp(t *testing.T) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/fhs"
|
"hakurei.app/container/fhs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTmpfileOp(t *testing.T) {
|
func TestTmpfileOp(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
. "syscall"
|
. "syscall"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { gob.Register(new(MountProcOp)) }
|
func init() { gob.Register(new(MountProcOp)) }
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMountProcOp(t *testing.T) {
|
func TestMountProcOp(t *testing.T) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { gob.Register(new(RemountOp)) }
|
func init() { gob.Register(new(RemountOp)) }
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRemountOp(t *testing.T) {
|
func TestRemountOp(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { gob.Register(new(SymlinkOp)) }
|
func init() { gob.Register(new(SymlinkOp)) }
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSymlinkOp(t *testing.T) {
|
func TestSymlinkOp(t *testing.T) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
. "syscall"
|
. "syscall"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { gob.Register(new(MountTmpfsOp)) }
|
func init() { gob.Register(new(MountTmpfsOp)) }
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMountTmpfsOp(t *testing.T) {
|
func TestMountTmpfsOp(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"hakurei.app/ext"
|
"hakurei.app/container/std"
|
||||||
)
|
)
|
||||||
|
|
||||||
// include/uapi/linux/landlock.h
|
// include/uapi/linux/landlock.h
|
||||||
@@ -223,7 +223,7 @@ func (rulesetAttr *RulesetAttr) Create(flags uintptr) (fd int, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rulesetFd, _, errno := syscall.Syscall(
|
rulesetFd, _, errno := syscall.Syscall(
|
||||||
ext.SYS_LANDLOCK_CREATE_RULESET,
|
std.SYS_LANDLOCK_CREATE_RULESET,
|
||||||
pointer, size,
|
pointer, size,
|
||||||
flags,
|
flags,
|
||||||
)
|
)
|
||||||
@@ -247,7 +247,7 @@ func LandlockGetABI() (int, error) {
|
|||||||
// LandlockRestrictSelf applies a loaded ruleset to the calling thread.
|
// LandlockRestrictSelf applies a loaded ruleset to the calling thread.
|
||||||
func LandlockRestrictSelf(rulesetFd int, flags uintptr) error {
|
func LandlockRestrictSelf(rulesetFd int, flags uintptr) error {
|
||||||
r, _, errno := syscall.Syscall(
|
r, _, errno := syscall.Syscall(
|
||||||
ext.SYS_LANDLOCK_RESTRICT_SELF,
|
std.SYS_LANDLOCK_RESTRICT_SELF,
|
||||||
uintptr(rulesetFd),
|
uintptr(rulesetFd),
|
||||||
flags,
|
flags,
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
. "syscall"
|
. "syscall"
|
||||||
|
|
||||||
"hakurei.app/ext"
|
"hakurei.app/container/vfs"
|
||||||
"hakurei.app/message"
|
"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 targetKFinal string
|
||||||
{
|
{
|
||||||
var destFd int
|
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)
|
destFd, err = p.k.open(targetFinal, O_PATH|O_CLOEXEC, 0)
|
||||||
return
|
return
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hakurei.app/internal/stub"
|
"hakurei.app/container/stub"
|
||||||
"hakurei.app/vfs"
|
"hakurei.app/container/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBindMount(t *testing.T) {
|
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"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"hakurei.app/fhs"
|
"hakurei.app/container/fhs"
|
||||||
"hakurei.app/vfs"
|
"hakurei.app/container/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"hakurei.app/check"
|
"hakurei.app/container/check"
|
||||||
"hakurei.app/vfs"
|
"hakurei.app/container/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestToSysroot(t *testing.T) {
|
func TestToSysroot(t *testing.T) {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"hakurei.app/container/std"
|
"hakurei.app/container/std"
|
||||||
"hakurei.app/ext"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidRules is returned for a zero-length rules slice.
|
// 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.
|
// 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.
|
// 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)
|
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))
|
C.free(unsafe.Pointer(v))
|
||||||
ok = num != C.__NR_SCMP_ERROR
|
ok = num != C.__NR_SCMP_ERROR
|
||||||
return
|
return
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user