forked from rosa/hakurei
Compare commits
169 Commits
master
...
pkgserver-
| Author | SHA1 | Date | |
|---|---|---|---|
|
3546c54ced
|
|||
|
f64c793352
|
|||
|
5474a75f1a
|
|||
|
fa64bd7b49
|
|||
|
89859ddfa3
|
|||
|
7c81681eab
|
|||
|
a958149cb5
|
|||
|
a3ff361bc8
|
|||
|
f621cebb73
|
|||
|
aed0c98146
|
|||
|
0e31d8954d
|
|||
|
4af44605ce
|
|||
|
a2473404d8
|
|||
|
9864f8329f
|
|||
|
b12a910d60
|
|||
|
339aba4167
|
|||
|
fdbf2a43eb
|
|||
|
9d3b98e063
|
|||
|
0183064f25
|
|||
| 0db8da9cba | |||
| 4ae5468cf6 | |||
| 226b86fe38 | |||
| 23b5d0dd3e | |||
| ba06769c5f | |||
| 28e84f0445 | |||
| 51ae9496e9 | |||
| 3cd8abc451 | |||
| edf4d0d32a | |||
| 97657ad9fc | |||
| 81344a1c8a | |||
| 50514edc00 | |||
| 7affd5735a | |||
| 0a2d9dbe42 | |||
| 049f212a02 | |||
| ccecaee7ca | |||
| 365602c9b6 | |||
| 85e066ccc4 | |||
| ffe37ae439 | |||
| 222a2aa8b4 | |||
| 29384553bf | |||
| cb0c652b18 | |||
| 5552598bc4 | |||
| 3db5603e78 | |||
| 08d120a84d | |||
| 1ecdcdc243 | |||
| e425c3b769 | |||
| 3d8b89e1ab | |||
| b2777de621 | |||
| 529a641fcd | |||
| 0e34ec3093 | |||
| 31b2d5431c | |||
| d6954e6bdb | |||
| 1cda0d83c3 | |||
|
e34e3b917e
|
|||
|
b0ba165107
|
|||
|
351d6c5a35
|
|||
|
f23f73701c
|
|||
|
876917229a
|
|||
|
0558032c2d
|
|||
|
c61cdc505f
|
|||
|
062edb3487
|
|||
|
e4355279a1
|
|||
|
289fdebead
|
|||
|
9c9e190db9
|
|||
|
d7d42c69a1
|
|||
|
c758e762bd
|
|||
|
10f8b1c221
|
|||
|
6907700d67
|
|||
|
0243f3ffbd
|
|||
|
cd0beeaf8e
|
|||
|
a69273ab2a
|
|||
|
4cd0f57e48
|
|||
|
33a0e6c01b
|
|||
|
d58f5c7590
|
|||
|
1da992e342
|
|||
|
9641805ec2
|
|||
|
0738f4889a
|
|||
|
7de3cfe221
|
|||
|
8b0648dd5d
|
|||
|
4667fac76c
|
|||
|
52e5443b0e
|
|||
|
130e470b60
|
|||
|
ba5ee8e3ee
|
|||
|
d1cef30877
|
|||
|
0188a3f0c7
|
|||
|
04fe3b24ce
|
|||
|
93ad551054
|
|||
|
3d54d1f176
|
|||
|
9feac7738f
|
|||
|
591a60bac9
|
|||
|
5093a06026
|
|||
|
50c1d7f880
|
|||
|
9e63633fbc
|
|||
|
61f981a34a
|
|||
|
d717c41bbe
|
|||
|
b896eec9b7
|
|||
|
8ab99e5e40
|
|||
|
2b6160ef7d
|
|||
|
4dcac7f133
|
|||
|
966fd4df9e
|
|||
|
a2cf59b989
|
|||
|
e87f59c4e4
|
|||
|
3b221c3e77
|
|||
|
ff3b385b12
|
|||
|
c6920e6ab7
|
|||
|
59b25d45fe
|
|||
|
9b99650eb1
|
|||
|
15bff9e1a6
|
|||
|
b948525c07
|
|||
|
9acbd16e9a
|
|||
|
64e5a1068b
|
|||
|
b6cbd49d8c
|
|||
|
6913b9224a
|
|||
|
9584958ecc
|
|||
|
389844b1ea
|
|||
|
5b7ab35633
|
|||
|
52b1a5a725
|
|||
|
6b78df8714
|
|||
|
dadf170a46
|
|||
|
9594832302
|
|||
|
91a2d4d6e1
|
|||
|
a854719b9f
|
|||
|
f03c0fb249
|
|||
|
a6600be34a
|
|||
|
b5592633f5
|
|||
|
584e302168
|
|||
|
141958656f
|
|||
|
648079f42c
|
|||
|
19c76e0831
|
|||
|
71fcc972ba
|
|||
|
62002efd08
|
|||
|
e33294db9c
|
|||
|
b1ea3b4acf
|
|||
|
2c254c70b8
|
|||
|
ea014d6af2
|
|||
|
1b48484c16
|
|||
|
713bff3eb0
|
|||
|
30f459e690
|
|||
|
8766fddcb3
|
|||
|
2745602be3
|
|||
|
ee22847dde
|
|||
|
c61188649b
|
|||
|
6a87a96838
|
|||
|
2548a681e9
|
|||
|
d514d0679f
|
|||
|
4407892632
|
|||
|
e661260607
|
|||
|
044490e0a5
|
|||
|
af038c89ff
|
|||
|
d2f30173cd
|
|||
|
5319ea994c
|
|||
|
bbe178be3e
|
|||
|
ca28e9936b
|
|||
|
f61c6ade56
|
|||
|
fce3d63823
|
|||
|
722c3cc54f
|
|||
|
372d509e5c
|
|||
|
d62516ed1e
|
|||
|
d2b635eb55
|
|||
|
50403e9d60
|
|||
|
b98c5f2e21
|
|||
|
d972cffe5a
|
|||
|
d8648304bb
|
|||
|
f7bfa9a6c2
|
|||
|
7035b4b598
|
|||
|
094b8400dd
|
|||
|
4652d921d8
|
|||
|
066213c245
|
|||
|
98832c21ee
|
39
.gitignore
vendored
39
.gitignore
vendored
@@ -1,37 +1,22 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.pkg
|
||||
/hakurei
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
# produced by tools and text editors
|
||||
*.qcow2
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# go generate
|
||||
/cmd/hakurei/LICENSE
|
||||
/cmd/pkgserver/.sass-cache
|
||||
/cmd/pkgserver/ui/static/*.js
|
||||
/cmd/pkgserver/ui/static/*.css*
|
||||
/cmd/pkgserver/ui/static/*.css.map
|
||||
/cmd/pkgserver/ui_test/*.js
|
||||
/cmd/pkgserver/ui_test/lib/*.js
|
||||
/cmd/pkgserver/ui_test/lib/*.css*
|
||||
/cmd/pkgserver/ui_test/lib/*.css.map
|
||||
/internal/pkg/testdata/testtool
|
||||
/internal/rosa/hakurei_current.tar.gz
|
||||
|
||||
# release
|
||||
/dist/hakurei-*
|
||||
|
||||
# interactive nixos vm
|
||||
nixos.qcow2
|
||||
# cmd/dist default destination
|
||||
/dist
|
||||
|
||||
6
all.sh
Executable file
6
all.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
TOOLCHAIN_VERSION="$(go version)"
|
||||
cd "$(dirname -- "$0")/"
|
||||
echo "# Building cmd/dist using ${TOOLCHAIN_VERSION}."
|
||||
go run -v --tags=dist ./cmd/dist
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -61,7 +61,7 @@ func (a *Absolute) Is(v *Absolute) bool {
|
||||
|
||||
// NewAbs checks pathname and returns a new [Absolute] if pathname is absolute.
|
||||
func NewAbs(pathname string) (*Absolute, error) {
|
||||
if !path.IsAbs(pathname) {
|
||||
if !filepath.IsAbs(pathname) {
|
||||
return nil, AbsoluteError(pathname)
|
||||
}
|
||||
return unsafeAbs(pathname), nil
|
||||
@@ -76,13 +76,13 @@ func MustAbs(pathname string) *Absolute {
|
||||
}
|
||||
}
|
||||
|
||||
// Append calls [path.Join] with [Absolute] as the first element.
|
||||
// Append calls [filepath.Join] with [Absolute] as the first element.
|
||||
func (a *Absolute) Append(elem ...string) *Absolute {
|
||||
return unsafeAbs(path.Join(append([]string{a.String()}, elem...)...))
|
||||
return unsafeAbs(filepath.Join(append([]string{a.String()}, elem...)...))
|
||||
}
|
||||
|
||||
// Dir calls [path.Dir] with [Absolute] as its argument.
|
||||
func (a *Absolute) Dir() *Absolute { return unsafeAbs(path.Dir(a.String())) }
|
||||
// Dir calls [filepath.Dir] with [Absolute] as its argument.
|
||||
func (a *Absolute) Dir() *Absolute { return unsafeAbs(filepath.Dir(a.String())) }
|
||||
|
||||
// GobEncode returns the checked pathname.
|
||||
func (a *Absolute) GobEncode() ([]byte, error) {
|
||||
@@ -92,7 +92,7 @@ func (a *Absolute) GobEncode() ([]byte, error) {
|
||||
// GobDecode stores data if it represents an absolute pathname.
|
||||
func (a *Absolute) GobDecode(data []byte) error {
|
||||
pathname := string(data)
|
||||
if !path.IsAbs(pathname) {
|
||||
if !filepath.IsAbs(pathname) {
|
||||
return AbsoluteError(pathname)
|
||||
}
|
||||
a.pathname = unique.Make(pathname)
|
||||
@@ -110,7 +110,7 @@ func (a *Absolute) UnmarshalJSON(data []byte) error {
|
||||
if err := json.Unmarshal(data, &pathname); err != nil {
|
||||
return err
|
||||
}
|
||||
if !path.IsAbs(pathname) {
|
||||
if !filepath.IsAbs(pathname) {
|
||||
return AbsoluteError(pathname)
|
||||
}
|
||||
a.pathname = unique.Make(pathname)
|
||||
|
||||
10
dist/comp/_hakurei → cmd/dist/comp/_hakurei
vendored
10
dist/comp/_hakurei → cmd/dist/comp/_hakurei
vendored
@@ -1,11 +1,11 @@
|
||||
#compdef hakurei
|
||||
|
||||
_hakurei_app() {
|
||||
_hakurei_run() {
|
||||
__hakurei_files
|
||||
return $?
|
||||
}
|
||||
|
||||
_hakurei_run() {
|
||||
_hakurei_exec() {
|
||||
_arguments \
|
||||
'--id[Reverse-DNS style Application identifier, leave empty to inherit instance identifier]:id' \
|
||||
'-a[Application identity]: :_numbers' \
|
||||
@@ -57,9 +57,9 @@ __hakurei_instances() {
|
||||
{
|
||||
local -a _hakurei_cmds
|
||||
_hakurei_cmds=(
|
||||
"app:Load and start container from configuration file"
|
||||
"run:Configure and start a permissive container"
|
||||
"show:Show live or local app configuration"
|
||||
"run:Load and start container from configuration file"
|
||||
"exec:Configure and start a permissive container"
|
||||
"show:Show live or local instance configuration"
|
||||
"ps:List active instances"
|
||||
"version:Display version information"
|
||||
"license:Show full license text"
|
||||
237
cmd/dist/main.go
vendored
Normal file
237
cmd/dist/main.go
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
//go:build dist
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// getenv looks up an environment variable, and returns fallback if it is unset.
|
||||
func getenv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// mustRun runs a command with the current process's environment and panics
|
||||
// on error or non-zero exit code.
|
||||
func mustRun(ctx context.Context, name string, arg ...string) {
|
||||
cmd := exec.CommandContext(ctx, name, arg...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed comp/_hakurei
|
||||
var comp []byte
|
||||
|
||||
func main() {
|
||||
fmt.Println()
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("# ")
|
||||
|
||||
version := getenv("HAKUREI_VERSION", "untagged")
|
||||
prefix := getenv("PREFIX", "/usr")
|
||||
destdir := getenv("DESTDIR", "dist")
|
||||
|
||||
if err := os.MkdirAll(destdir, 0755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
s, err := os.MkdirTemp(destdir, ".dist.*")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
var code int
|
||||
|
||||
if err = os.RemoveAll(s); err != nil {
|
||||
code = 1
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
if r := recover(); r != nil {
|
||||
code = 1
|
||||
log.Println(r)
|
||||
}
|
||||
|
||||
os.Exit(code)
|
||||
}()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
log.Println("Building hakurei.")
|
||||
mustRun(ctx, "go", "generate", "./...")
|
||||
mustRun(
|
||||
ctx, "go", "build",
|
||||
"-trimpath",
|
||||
"-v", "-o", s,
|
||||
"-ldflags=-s -w "+
|
||||
"-buildid= -linkmode external -extldflags=-static "+
|
||||
"-X hakurei.app/internal/info.buildVersion="+version+" "+
|
||||
"-X hakurei.app/internal/info.hakureiPath="+prefix+"/bin/hakurei "+
|
||||
"-X hakurei.app/internal/info.hsuPath="+prefix+"/bin/hsu "+
|
||||
"-X main.hakureiPath="+prefix+"/bin/hakurei",
|
||||
"./...",
|
||||
)
|
||||
fmt.Println()
|
||||
|
||||
log.Println("Testing Hakurei.")
|
||||
mustRun(
|
||||
ctx, "go", "test",
|
||||
"-ldflags=-buildid= -linkmode external -extldflags=-static",
|
||||
"./...",
|
||||
)
|
||||
fmt.Println()
|
||||
|
||||
log.Println("Creating distribution.")
|
||||
const suffix = ".tar.gz"
|
||||
distName := "hakurei-" + version + "-" + runtime.GOARCH
|
||||
var f *os.File
|
||||
if f, err = os.OpenFile(
|
||||
filepath.Join(s, distName+suffix),
|
||||
os.O_CREATE|os.O_EXCL|os.O_WRONLY,
|
||||
0644,
|
||||
); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}()
|
||||
|
||||
h := sha512.New()
|
||||
gw := gzip.NewWriter(io.MultiWriter(f, h))
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
mustWriteHeader := func(name string, size int64, mode os.FileMode) {
|
||||
header := tar.Header{
|
||||
Name: filepath.Join(distName, name),
|
||||
Size: size,
|
||||
Mode: int64(mode),
|
||||
Uname: "root",
|
||||
Gname: "root",
|
||||
}
|
||||
|
||||
if mode&os.ModeDir != 0 {
|
||||
header.Typeflag = tar.TypeDir
|
||||
fmt.Printf("%s %s\n", mode, name)
|
||||
} else {
|
||||
header.Typeflag = tar.TypeReg
|
||||
fmt.Printf("%s %s (%d bytes)\n", mode, name, size)
|
||||
}
|
||||
|
||||
if err = tw.WriteHeader(&header); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
mustWriteFile := func(name string, data []byte, mode os.FileMode) {
|
||||
mustWriteHeader(name, int64(len(data)), mode)
|
||||
if mode&os.ModeDir != 0 {
|
||||
return
|
||||
}
|
||||
if _, err = tw.Write(data); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
mustWriteFromPath := func(dst, src string, mode os.FileMode) {
|
||||
var r *os.File
|
||||
if r, err = os.Open(src); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var fi os.FileInfo
|
||||
if fi, err = r.Stat(); err != nil {
|
||||
_ = r.Close()
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if mode == 0 {
|
||||
mode = fi.Mode()
|
||||
}
|
||||
|
||||
mustWriteHeader(dst, fi.Size(), mode)
|
||||
if _, err = io.Copy(tw, r); err != nil {
|
||||
_ = r.Close()
|
||||
panic(err)
|
||||
} else if err = r.Close(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
mustWriteFile(".", nil, fs.ModeDir|0755)
|
||||
mustWriteFile("comp/", nil, os.ModeDir|0755)
|
||||
mustWriteFile("comp/_hakurei", comp, 0644)
|
||||
mustWriteFile("install.sh", []byte(`#!/bin/sh -e
|
||||
cd "$(dirname -- "$0")" || exit 1
|
||||
|
||||
install -vDm0755 "bin/hakurei" "${DESTDIR}`+prefix+`/bin/hakurei"
|
||||
install -vDm0755 "bin/sharefs" "${DESTDIR}`+prefix+`/bin/sharefs"
|
||||
|
||||
install -vDm4511 "bin/hsu" "${DESTDIR}`+prefix+`/bin/hsu"
|
||||
if [ ! -f "${DESTDIR}/etc/hsurc" ]; then
|
||||
install -vDm0400 "hsurc.default" "${DESTDIR}/etc/hsurc"
|
||||
fi
|
||||
|
||||
install -vDm0644 "comp/_hakurei" "${DESTDIR}`+prefix+`/share/zsh/site-functions/_hakurei"
|
||||
`), 0755)
|
||||
|
||||
mustWriteFromPath("README.md", "README.md", 0)
|
||||
mustWriteFile("hsurc.default", []byte("1000 0"), 0400)
|
||||
mustWriteFromPath("bin/hsu", filepath.Join(s, "hsu"), 04511)
|
||||
for _, name := range []string{
|
||||
"hakurei",
|
||||
"sharefs",
|
||||
} {
|
||||
mustWriteFromPath(
|
||||
filepath.Join("bin", name),
|
||||
filepath.Join(s, name),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
if err = tw.Close(); err != nil {
|
||||
panic(err)
|
||||
} else if err = gw.Close(); err != nil {
|
||||
panic(err)
|
||||
} else if err = f.Close(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
f = nil
|
||||
|
||||
if err = os.WriteFile(
|
||||
filepath.Join(destdir, distName+suffix+".sha512"),
|
||||
append(hex.AppendEncode(nil, h.Sum(nil)), " "+distName+suffix+"\n"...),
|
||||
0644,
|
||||
); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = os.Rename(
|
||||
filepath.Join(s, distName+suffix),
|
||||
filepath.Join(destdir, distName+suffix),
|
||||
); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
// The earlyinit is part of the Rosa OS initramfs and serves as the system init.
|
||||
//
|
||||
// This program is an internal detail of Rosa OS and is not usable on its own.
|
||||
// It is not covered by the compatibility promise.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -11,7 +12,6 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
_ "unsafe" // for go:linkname
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/command"
|
||||
@@ -27,9 +27,14 @@ import (
|
||||
|
||||
// optionalErrorUnwrap calls [errors.Unwrap] and returns the resulting value
|
||||
// if it is not nil, or the original value if it is.
|
||||
//
|
||||
//go:linkname optionalErrorUnwrap hakurei.app/container.optionalErrorUnwrap
|
||||
func optionalErrorUnwrap(err error) error
|
||||
func optionalErrorUnwrap(err error) error {
|
||||
if underlyingErr := errors.Unwrap(err); underlyingErr != nil {
|
||||
return underlyingErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var errSuccess = errors.New("success")
|
||||
|
||||
func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErrs, out io.Writer) command.Command {
|
||||
var (
|
||||
@@ -60,9 +65,9 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
var (
|
||||
flagIdentifierFile int
|
||||
)
|
||||
c.NewCommand("app", "Load and start container from configuration file", func(args []string) error {
|
||||
c.NewCommand("run", "Load and start container from configuration file", func(args []string) error {
|
||||
if len(args) < 1 {
|
||||
log.Fatal("app requires at least 1 argument")
|
||||
log.Fatal("run requires at least 1 argument")
|
||||
}
|
||||
|
||||
config := tryPath(msg, args[0])
|
||||
@@ -98,7 +103,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
flagWayland, flagX11, flagDBus, flagPipeWire, flagPulse bool
|
||||
)
|
||||
|
||||
c.NewCommand("run", "Configure and start a permissive container", func(args []string) error {
|
||||
c.NewCommand("exec", "Configure and start a permissive container", func(args []string) error {
|
||||
if flagIdentity < hst.IdentityStart || flagIdentity > hst.IdentityEnd {
|
||||
log.Fatalf("identity %d out of range", flagIdentity)
|
||||
}
|
||||
@@ -323,7 +328,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
flagShort bool
|
||||
flagNoStore bool
|
||||
)
|
||||
c.NewCommand("show", "Show live or local app configuration", func(args []string) error {
|
||||
c.NewCommand("show", "Show live or local instance configuration", func(args []string) error {
|
||||
switch len(args) {
|
||||
case 0: // system
|
||||
printShowSystem(os.Stdout, flagShort, flagJSON)
|
||||
|
||||
@@ -23,9 +23,9 @@ func TestHelp(t *testing.T) {
|
||||
Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS]
|
||||
|
||||
Commands:
|
||||
app Load and start container from configuration file
|
||||
run Configure and start a permissive container
|
||||
show Show live or local app configuration
|
||||
run Load and start container from configuration file
|
||||
exec Configure and start a permissive container
|
||||
show Show live or local instance configuration
|
||||
ps List active instances
|
||||
version Display version information
|
||||
license Show full license text
|
||||
@@ -35,8 +35,8 @@ Commands:
|
||||
`,
|
||||
},
|
||||
{
|
||||
"run", []string{"run", "-h"}, `
|
||||
Usage: hakurei run [-h | --help] [--dbus-config <value>] [--dbus-system <value>] [--mpris] [--dbus-log] [--id <value>] [-a <int>] [-g <value>] [-d <value>] [-u <value>] [--policy <value>] [--priority <int>] [--private-runtime] [--private-tmpdir] [--wayland] [-X] [--dbus] [--pipewire] [--pulse] COMMAND [OPTIONS]
|
||||
"exec", []string{"exec", "-h"}, `
|
||||
Usage: hakurei exec [-h | --help] [--dbus-config <value>] [--dbus-system <value>] [--mpris] [--dbus-log] [--id <value>] [-a <int>] [-g <value>] [-d <value>] [-u <value>] [--policy <value>] [--priority <int>] [--private-runtime] [--private-tmpdir] [--wayland] [-X] [--dbus] [--pipewire] [--pulse] COMMAND [OPTIONS]
|
||||
|
||||
Flags:
|
||||
-X Enable direct connection to X11
|
||||
|
||||
@@ -1,8 +1,42 @@
|
||||
// Hakurei runs user-specified containers as subordinate users.
|
||||
//
|
||||
// This program is generally invoked by another, higher level program, which
|
||||
// creates container configuration via package [hst] or an implementation of it.
|
||||
//
|
||||
// The parent may leave files open and specify their file descriptor for various
|
||||
// uses. In these cases, standard streams and netpoll files are treated as
|
||||
// invalid file descriptors and rejected. All string representations must be in
|
||||
// decimal.
|
||||
//
|
||||
// When specifying a [hst.Config] JSON stream or file to the run subcommand, the
|
||||
// argument "-" is equivalent to stdin. Otherwise, file descriptor rules
|
||||
// described above applies. Invalid file descriptors are treated as file names
|
||||
// in their string representation, with the exception that if a netpoll file
|
||||
// descriptor is attempted, the program fails.
|
||||
//
|
||||
// The flag --identifier-fd can be optionally specified to the run subcommand to
|
||||
// receive the identifier of the newly started instance. File descriptor rules
|
||||
// described above applies, and the file must be writable. This is sent after
|
||||
// its state is made available, so the client must not attempt to poll for it.
|
||||
// This uses the internal binary format of [hst.ID].
|
||||
//
|
||||
// For the show and ps subcommands, the flag --json can be applied to the main
|
||||
// hakurei command to serialise output in JSON when applicable. Additionally,
|
||||
// the flag --short targeting each subcommand is used to omit some information
|
||||
// in both JSON and user-facing output. Only JSON-encoded output is covered
|
||||
// under the compatibility promise.
|
||||
//
|
||||
// A template for [hst.Config] demonstrating all available configuration fields
|
||||
// is returned by [hst.Template]. The JSON-encoded equivalent of this can be
|
||||
// obtained via the template subcommand. Fields left unpopulated in the template
|
||||
// (the direct_* family of fields, which are insecure under any configuration if
|
||||
// enabled) are unsupported.
|
||||
//
|
||||
// For simple (but insecure) testing scenarios, the exec subcommand can be used
|
||||
// to generate a simple, permissive configuration in-memory. See its help
|
||||
// message for all available options.
|
||||
package main
|
||||
|
||||
// this works around go:embed '..' limitation
|
||||
//go:generate cp ../../LICENSE .
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
@@ -17,12 +51,9 @@ import (
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
var (
|
||||
errSuccess = errors.New("success")
|
||||
|
||||
//go:embed LICENSE
|
||||
license string
|
||||
)
|
||||
//go:generate cp ../../LICENSE .
|
||||
//go:embed LICENSE
|
||||
var license string
|
||||
|
||||
// earlyHardeningErrs are errors collected while setting up early hardening feature.
|
||||
type earlyHardeningErrs struct{ yamaLSM, dumpable error }
|
||||
@@ -31,8 +62,8 @@ func main() {
|
||||
// early init path, skips root check and duplicate PR_SET_DUMPABLE
|
||||
container.TryArgv0(nil)
|
||||
|
||||
log.SetPrefix("hakurei: ")
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("hakurei: ")
|
||||
msg := message.New(log.Default())
|
||||
|
||||
early := earlyHardeningErrs{
|
||||
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
)
|
||||
|
||||
// tryPath attempts to read [hst.Config] from multiple sources.
|
||||
// tryPath reads from [os.Stdin] if name has value "-".
|
||||
// Otherwise, name is passed to tryFd, and if that returns nil, name is passed to [os.Open].
|
||||
//
|
||||
// tryPath reads from [os.Stdin] if name has value "-". Otherwise, name is
|
||||
// passed to tryFd, and if that returns nil, name is passed to [os.Open].
|
||||
func tryPath(msg message.Msg, name string) (config *hst.Config) {
|
||||
var r io.ReadCloser
|
||||
config = new(hst.Config)
|
||||
@@ -46,7 +47,8 @@ func tryPath(msg message.Msg, name string) (config *hst.Config) {
|
||||
return
|
||||
}
|
||||
|
||||
// tryFd returns a [io.ReadCloser] if name represents an integer corresponding to a valid file descriptor.
|
||||
// tryFd returns a [io.ReadCloser] if name represents an integer corresponding
|
||||
// to a valid file descriptor.
|
||||
func tryFd(msg message.Msg, name string) io.ReadCloser {
|
||||
if v, err := strconv.Atoi(name); err != nil {
|
||||
if !errors.Is(err, strconv.ErrSyntax) {
|
||||
@@ -60,7 +62,12 @@ func tryFd(msg message.Msg, name string) io.ReadCloser {
|
||||
|
||||
msg.Verbosef("trying config stream from %d", v)
|
||||
fd := uintptr(v)
|
||||
if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETFD, 0); errno != 0 {
|
||||
if _, _, errno := syscall.Syscall(
|
||||
syscall.SYS_FCNTL,
|
||||
fd,
|
||||
syscall.F_GETFD,
|
||||
0,
|
||||
); errno != 0 {
|
||||
if errors.Is(errno, syscall.EBADF) { // reject bad fd
|
||||
return nil
|
||||
}
|
||||
@@ -75,10 +82,12 @@ func tryFd(msg message.Msg, name string) io.ReadCloser {
|
||||
}
|
||||
}
|
||||
|
||||
// shortLengthMin is the minimum length a short form identifier can have and still be interpreted as an identifier.
|
||||
// shortLengthMin is the minimum length a short form identifier can have and
|
||||
// still be interpreted as an identifier.
|
||||
const shortLengthMin = 1 << 3
|
||||
|
||||
// shortIdentifier returns an eight character short representation of [hst.ID] from its random bytes.
|
||||
// shortIdentifier returns an eight character short representation of [hst.ID]
|
||||
// from its random bytes.
|
||||
func shortIdentifier(id *hst.ID) string {
|
||||
return shortIdentifierString(id.String())
|
||||
}
|
||||
@@ -88,7 +97,8 @@ func shortIdentifierString(s string) string {
|
||||
return s[len(hst.ID{}) : len(hst.ID{})+shortLengthMin]
|
||||
}
|
||||
|
||||
// tryIdentifier attempts to match [hst.State] from a [hex] representation of [hst.ID] or a prefix of its lower half.
|
||||
// tryIdentifier attempts to match [hst.State] from a [hex] representation of
|
||||
// [hst.ID] or a prefix of its lower half.
|
||||
func tryIdentifier(msg message.Msg, name string, s *store.Store) *hst.State {
|
||||
const (
|
||||
likeShort = 1 << iota
|
||||
@@ -96,7 +106,8 @@ func tryIdentifier(msg message.Msg, name string, s *store.Store) *hst.State {
|
||||
)
|
||||
|
||||
var likely uintptr
|
||||
if len(name) >= shortLengthMin && len(name) <= len(hst.ID{}) { // half the hex representation
|
||||
// half the hex representation
|
||||
if len(name) >= shortLengthMin && len(name) <= len(hst.ID{}) {
|
||||
// cannot safely decode here due to unknown alignment
|
||||
for _, c := range name {
|
||||
if c >= '0' && c <= '9' {
|
||||
|
||||
7
cmd/hsu/conf.go
Normal file
7
cmd/hsu/conf.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !rosa
|
||||
|
||||
package main
|
||||
|
||||
// hsuConfPath is an absolute pathname to the hsu configuration file. Its
|
||||
// contents are interpreted by parseConfig.
|
||||
const hsuConfPath = "/etc/hsurc"
|
||||
7
cmd/hsu/config_rosa.go
Normal file
7
cmd/hsu/config_rosa.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build rosa
|
||||
|
||||
package main
|
||||
|
||||
// hsuConfPath is the pathname to the hsu configuration file, specific to
|
||||
// Rosa OS. Its contents are interpreted by parseConfig.
|
||||
const hsuConfPath = "/system/etc/hsurc"
|
||||
@@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
/* copied from hst and must never be changed */
|
||||
/* keep in sync with hst */
|
||||
|
||||
const (
|
||||
userOffset = 100000
|
||||
|
||||
@@ -1,13 +1,64 @@
|
||||
// hsu starts the hakurei shim as the target subordinate user.
|
||||
//
|
||||
// The hsu program must be installed with the setuid and setgid bit set, and
|
||||
// owned by root. A configuration file must be installed at /etc/hsurc with
|
||||
// permission bits 0400, and owned by root. Each line of the file specifies a
|
||||
// hakurei userid to kernel uid mapping. A line consists of the decimal string
|
||||
// representation of the uid of the user wishing to start hakurei containers,
|
||||
// followed by a space, followed by the decimal string representation of its
|
||||
// userid. Duplicate uid entries are ignored, with the first occurrence taking
|
||||
// effect.
|
||||
//
|
||||
// For example, to map the kernel uid 1000 to the hakurei user id 0:
|
||||
//
|
||||
// 1000 0
|
||||
//
|
||||
// # Internals
|
||||
//
|
||||
// Hakurei and hsu holds pathnames pointing to each other set at link time. For
|
||||
// this reason, a distribution of hakurei has fixed installation prefix. Since
|
||||
// this program is never invoked by the user, behaviour described in the
|
||||
// following paragraphs are considered an internal detail and not covered by the
|
||||
// compatibility promise.
|
||||
//
|
||||
// After checking credentials, hsu checks via /proc/ the absolute pathname of
|
||||
// its parent process, and fails if it does not match the hakurei pathname set
|
||||
// at link time. This is not a security feature: the priv-side is considered
|
||||
// trusted, and this feature makes no attempt to address the racy nature of
|
||||
// querying /proc/, or debuggers attached to the parent process. Instead, this
|
||||
// aims to discourage misuse and reduce confusion if the user accidentally
|
||||
// stumbles upon this program. It also prevents accidental use of the incorrect
|
||||
// installation of hsu in some environments.
|
||||
//
|
||||
// Since target container environment variables are set up in shim via the
|
||||
// [container] infrastructure, the environment is used for parameters from the
|
||||
// parent process.
|
||||
//
|
||||
// HAKUREI_SHIM specifies a single byte between '3' and '9' representing the
|
||||
// setup pipe file descriptor. It is passed as is to the shim process and is the
|
||||
// only value in the environment of the shim process. Since hsurc is not
|
||||
// accessible to the parent process, leaving this unset causes hsu to print the
|
||||
// corresponding hakurei user id of the parent and terminate.
|
||||
//
|
||||
// HAKUREI_IDENTITY specifies the identity of the instance being started and is
|
||||
// used to produce the kernel uid alongside hakurei user id looked up from hsurc.
|
||||
//
|
||||
// HAKUREI_GROUPS specifies supplementary groups to inherit from the credentials
|
||||
// of the parent process in a ' ' separated list of decimal string
|
||||
// representations of gid. This has the unfortunate consequence of allowing
|
||||
// users mapped via hsurc to effectively drop group membership, so special care
|
||||
// must be taken to ensure this does not lead to an increase in access. This is
|
||||
// not applicable to Rosa OS since unsigned code execution is not permitted
|
||||
// outside hakurei containers, and is generally nonapplicable to the security
|
||||
// model of hakurei, where all untrusted code runs within containers.
|
||||
package main
|
||||
|
||||
// minimise imports to avoid inadvertently calling init or global variable functions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -16,10 +67,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// envIdentity is the name of the environment variable holding a
|
||||
// single byte representing the shim setup pipe file descriptor.
|
||||
// envShim is the name of the environment variable holding a single byte
|
||||
// representing the shim setup pipe file descriptor.
|
||||
envShim = "HAKUREI_SHIM"
|
||||
// envGroups holds a ' ' separated list of string representations of
|
||||
// envIdentity is the name of the environment variable holding a decimal
|
||||
// string representation of the current application identity.
|
||||
envIdentity = "HAKUREI_IDENTITY"
|
||||
// envGroups holds a ' ' separated list of decimal string representations of
|
||||
// supplementary group gid. Membership requirements are enforced.
|
||||
envGroups = "HAKUREI_GROUPS"
|
||||
)
|
||||
@@ -35,7 +89,6 @@ func main() {
|
||||
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("hsu: ")
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
log.Fatal("this program must be owned by uid 0 and have the setuid bit set")
|
||||
@@ -49,13 +102,13 @@ func main() {
|
||||
log.Fatal("this program must not be started by root")
|
||||
}
|
||||
|
||||
if !path.IsAbs(hakureiPath) {
|
||||
if !filepath.IsAbs(hakureiPath) {
|
||||
log.Fatal("this program is compiled incorrectly")
|
||||
return
|
||||
}
|
||||
|
||||
var toolPath string
|
||||
pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe")
|
||||
pexe := filepath.Join("/proc", strconv.Itoa(os.Getppid()), "exe")
|
||||
if p, err := os.Readlink(pexe); err != nil {
|
||||
log.Fatalf("cannot read parent executable path: %v", err)
|
||||
} else if strings.HasSuffix(p, " (deleted)") {
|
||||
@@ -99,8 +152,6 @@ func main() {
|
||||
// last possible uid outcome
|
||||
uidEnd = 999919999
|
||||
)
|
||||
|
||||
// cast to int for use with library functions
|
||||
uid := int(toUser(userid, identity))
|
||||
|
||||
// final bounds check to catch any bugs
|
||||
@@ -136,7 +187,6 @@ func main() {
|
||||
}
|
||||
|
||||
// careful! users in the allowlist is effectively allowed to drop groups via hsu
|
||||
|
||||
if err := syscall.Setresgid(uid, uid, uid); err != nil {
|
||||
log.Fatalf("cannot set gid: %v", err)
|
||||
}
|
||||
@@ -146,10 +196,21 @@ func main() {
|
||||
if err := syscall.Setresuid(uid, uid, uid); err != nil {
|
||||
log.Fatalf("cannot set uid: %v", err)
|
||||
}
|
||||
if _, _, errno := syscall.AllThreadsSyscall(syscall.SYS_PRCTL, PR_SET_NO_NEW_PRIVS, 1, 0); errno != 0 {
|
||||
|
||||
if _, _, errno := syscall.AllThreadsSyscall(
|
||||
syscall.SYS_PRCTL,
|
||||
PR_SET_NO_NEW_PRIVS, 1,
|
||||
0,
|
||||
); errno != 0 {
|
||||
log.Fatalf("cannot set no_new_privs flag: %s", errno.Error())
|
||||
}
|
||||
if err := syscall.Exec(toolPath, []string{"hakurei", "shim"}, []string{envShim + "=" + shimSetupFd}); err != nil {
|
||||
|
||||
if err := syscall.Exec(toolPath, []string{
|
||||
"hakurei",
|
||||
"shim",
|
||||
}, []string{
|
||||
envShim + "=" + shimSetupFd,
|
||||
}); err != nil {
|
||||
log.Fatalf("cannot start shim: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,9 @@ const (
|
||||
useridEnd = useridStart + rangeSize - 1
|
||||
)
|
||||
|
||||
// parseUint32Fast parses a string representation of an unsigned 32-bit integer value
|
||||
// using the fast path only. This limits the range of values it is defined in.
|
||||
// parseUint32Fast parses a string representation of an unsigned 32-bit integer
|
||||
// value using the fast path only. This limits the range of values it is defined
|
||||
// in but is perfectly adequate for this use case.
|
||||
func parseUint32Fast(s string) (uint32, error) {
|
||||
sLen := len(s)
|
||||
if sLen < 1 {
|
||||
@@ -40,12 +41,14 @@ func parseUint32Fast(s string) (uint32, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parseConfig reads a list of allowed users from r until it encounters puid or [io.EOF].
|
||||
// parseConfig reads a list of allowed users from r until it encounters puid or
|
||||
// [io.EOF].
|
||||
//
|
||||
// Each line of the file specifies a hakurei userid to kernel uid mapping. A line consists
|
||||
// of the string representation of the uid of the user wishing to start hakurei containers,
|
||||
// followed by a space, followed by the string representation of its userid. Duplicate uid
|
||||
// entries are ignored, with the first occurrence taking effect.
|
||||
// Each line of the file specifies a hakurei userid to kernel uid mapping. A
|
||||
// line consists of the string representation of the uid of the user wishing to
|
||||
// start hakurei containers, followed by a space, followed by the string
|
||||
// representation of its userid. Duplicate uid entries are ignored, with the
|
||||
// first occurrence taking effect.
|
||||
//
|
||||
// All string representations are parsed by calling parseUint32Fast.
|
||||
func parseConfig(r io.Reader, puid uint32) (userid uint32, ok bool, err error) {
|
||||
@@ -81,10 +84,6 @@ func parseConfig(r io.Reader, puid uint32) (userid uint32, ok bool, err error) {
|
||||
return useridEnd + 1, false, s.Err()
|
||||
}
|
||||
|
||||
// hsuConfPath is an absolute pathname to the hsu configuration file.
|
||||
// Its contents are interpreted by parseConfig.
|
||||
const hsuConfPath = "/etc/hsurc"
|
||||
|
||||
// mustParseConfig calls parseConfig to interpret the contents of hsuConfPath,
|
||||
// terminating the program if an error is encountered, the syntax is incorrect,
|
||||
// or the current user is not authorised to use hsu because its uid is missing.
|
||||
@@ -112,10 +111,6 @@ func mustParseConfig(puid int) (userid uint32) {
|
||||
return
|
||||
}
|
||||
|
||||
// envIdentity is the name of the environment variable holding a
|
||||
// string representation of the current application identity.
|
||||
var envIdentity = "HAKUREI_IDENTITY"
|
||||
|
||||
// mustReadIdentity calls parseUint32Fast to interpret the value stored in envIdentity,
|
||||
// terminating the program if the value is not set, malformed, or out of bounds.
|
||||
func mustReadIdentity() uint32 {
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
// The mbf program is a frontend for [hakurei.app/internal/rosa].
|
||||
//
|
||||
// This program is not covered by the compatibility promise. The command line
|
||||
// interface, available packages and their behaviour, and even the on-disk
|
||||
// format, may change at any time.
|
||||
//
|
||||
// # Name
|
||||
//
|
||||
// The name mbf stands for maiden's best friend, as a tribute to the DOOM source
|
||||
// port of [the same name]. This name is a placeholder and is subject to change.
|
||||
//
|
||||
// [the same name]: https://www.doomwiki.org/wiki/MBF
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -57,11 +69,12 @@ func main() {
|
||||
}()
|
||||
|
||||
var (
|
||||
flagQuiet bool
|
||||
flagCures int
|
||||
flagBase string
|
||||
flagTShift int
|
||||
flagIdle bool
|
||||
flagQuiet bool
|
||||
flagCures int
|
||||
flagBase string
|
||||
flagIdle bool
|
||||
|
||||
flagHostAbstract bool
|
||||
)
|
||||
c := command.New(os.Stderr, log.Printf, "mbf", func([]string) (err error) {
|
||||
msg.SwapVerbose(!flagQuiet)
|
||||
@@ -77,19 +90,15 @@ func main() {
|
||||
} else if base, err = check.NewAbs(flagBase); err != nil {
|
||||
return
|
||||
}
|
||||
if cache, err = pkg.Open(ctx, msg, flagCures, base); err == nil {
|
||||
if flagTShift < 0 {
|
||||
cache.SetThreshold(0)
|
||||
} else if flagTShift > 31 {
|
||||
cache.SetThreshold(1 << 31)
|
||||
} else {
|
||||
cache.SetThreshold(1 << flagTShift)
|
||||
}
|
||||
}
|
||||
|
||||
var flags int
|
||||
if flagIdle {
|
||||
pkg.SetSchedIdle = true
|
||||
flags |= pkg.CSchedIdle
|
||||
}
|
||||
if flagHostAbstract {
|
||||
flags |= pkg.CHostAbstract
|
||||
}
|
||||
cache, err = pkg.Open(ctx, msg, flags, flagCures, base)
|
||||
|
||||
return
|
||||
}).Flag(
|
||||
@@ -104,14 +113,17 @@ func main() {
|
||||
&flagBase,
|
||||
"d", command.StringFlag("$MBF_CACHE_DIR"),
|
||||
"Directory to store cured artifacts",
|
||||
).Flag(
|
||||
&flagTShift,
|
||||
"tshift", command.IntFlag(-1),
|
||||
"Dependency graph size exponent, to the power of 2",
|
||||
).Flag(
|
||||
&flagIdle,
|
||||
"sched-idle", command.BoolFlag(false),
|
||||
"Set SCHED_IDLE scheduling policy",
|
||||
).Flag(
|
||||
&flagHostAbstract,
|
||||
"host-abstract", command.BoolFlag(
|
||||
os.Getenv("MBF_HOST_ABSTRACT") != "",
|
||||
),
|
||||
"Do not restrict networked cure containers from connecting to host "+
|
||||
"abstract UNIX sockets",
|
||||
)
|
||||
|
||||
{
|
||||
@@ -436,6 +448,7 @@ func main() {
|
||||
{
|
||||
var (
|
||||
flagDump string
|
||||
flagEnter bool
|
||||
flagExport string
|
||||
)
|
||||
c.NewCommand(
|
||||
@@ -445,9 +458,13 @@ func main() {
|
||||
if len(args) != 1 {
|
||||
return errors.New("cure requires 1 argument")
|
||||
}
|
||||
if p, ok := rosa.ResolveName(args[0]); !ok {
|
||||
p, ok := rosa.ResolveName(args[0])
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown artifact %q", args[0])
|
||||
} else if flagDump == "" {
|
||||
}
|
||||
|
||||
switch {
|
||||
default:
|
||||
pathname, _, err := cache.Cure(rosa.Std.Load(p))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -477,7 +494,8 @@ func main() {
|
||||
}
|
||||
|
||||
return nil
|
||||
} else {
|
||||
|
||||
case flagDump != "":
|
||||
f, err := os.OpenFile(
|
||||
flagDump,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
@@ -493,6 +511,15 @@ func main() {
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
|
||||
case flagEnter:
|
||||
return cache.EnterExec(
|
||||
ctx,
|
||||
rosa.Std.Load(p),
|
||||
true, os.Stdin, os.Stdout, os.Stderr,
|
||||
rosa.AbsSystem.Append("bin", "mksh"),
|
||||
"sh",
|
||||
)
|
||||
}
|
||||
},
|
||||
).
|
||||
@@ -505,6 +532,11 @@ func main() {
|
||||
&flagExport,
|
||||
"export", command.StringFlag(""),
|
||||
"Export cured artifact to specified pathname",
|
||||
).
|
||||
Flag(
|
||||
&flagEnter,
|
||||
"enter", command.BoolFlag(false),
|
||||
"Enter cure container with an interactive shell",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -527,7 +559,7 @@ func main() {
|
||||
}
|
||||
presets[i] = p
|
||||
}
|
||||
root := make(rosa.Collect, 0, 6+len(args))
|
||||
root := make(pkg.Collect, 0, 6+len(args))
|
||||
root = rosa.Std.AppendPresets(root, presets...)
|
||||
|
||||
if flagWithToolchain {
|
||||
@@ -543,7 +575,7 @@ func main() {
|
||||
|
||||
if _, _, err := cache.Cure(&root); err == nil {
|
||||
return errors.New("unreachable")
|
||||
} else if !errors.Is(err, rosa.Collected{}) {
|
||||
} else if !pkg.IsCollected(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -586,6 +618,9 @@ func main() {
|
||||
z.Hostname = "localhost"
|
||||
z.Uid, z.Gid = (1<<10)-1, (1<<10)-1
|
||||
z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if s, ok := os.LookupEnv("TERM"); ok {
|
||||
z.Env = append(z.Env, "TERM="+s)
|
||||
}
|
||||
|
||||
var tempdir *check.Absolute
|
||||
if s, err := filepath.Abs(os.TempDir()); err != nil {
|
||||
@@ -636,13 +671,13 @@ func main() {
|
||||
).
|
||||
Flag(
|
||||
&flagSession,
|
||||
"session", command.BoolFlag(false),
|
||||
"session", command.BoolFlag(true),
|
||||
"Retain session",
|
||||
).
|
||||
Flag(
|
||||
&flagWithToolchain,
|
||||
"with-toolchain", command.BoolFlag(false),
|
||||
"Include the stage3 LLVM toolchain",
|
||||
"Include the stage2 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 execute their suite registrations.
|
||||
import "./test_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, TESTS } from "./test.js";
|
||||
|
||||
// TypeScript doesn't like process and Deno as their type definitions aren't
|
||||
// installed, but doesn't seem to complain if they're accessed through
|
||||
// globalThis.
|
||||
const process: any = (globalThis as any).process;
|
||||
const Deno: any = (globalThis as any).Deno;
|
||||
|
||||
function getArgs(): string[] {
|
||||
if (process) {
|
||||
const [runtime, program, ...args] = process.argv;
|
||||
return args;
|
||||
}
|
||||
if (Deno) return Deno.args;
|
||||
return [];
|
||||
}
|
||||
|
||||
function exit(code?: number): never {
|
||||
if (Deno) Deno.exit(code);
|
||||
if (process) process.exit(code);
|
||||
throw `exited with code ${code ?? 0}`;
|
||||
}
|
||||
|
||||
const args = getArgs();
|
||||
let verbose = false;
|
||||
if (args.length > 1) {
|
||||
console.error("Too many arguments");
|
||||
exit(1);
|
||||
}
|
||||
if (args.length === 1) {
|
||||
if (args[0] === "-v" || args[0] === "--verbose" || args[0] === "-verbose") {
|
||||
verbose = true;
|
||||
} else if (args[0] !== "--") {
|
||||
console.error(`Unknown argument '${args[0]}'`);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let reporter = new StreamReporter({ writeln: console.log }, verbose);
|
||||
TESTS.run(reporter);
|
||||
exit(reporter.succeeded() ? 0 : 1);
|
||||
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, TESTS } from "./test.js";
|
||||
TESTS.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 |
400
cmd/pkgserver/ui_test/lib/test.ts
Normal file
400
cmd/pkgserver/ui_test/lib/test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
// =============================================================================
|
||||
// DSL
|
||||
|
||||
type TestTree = TestGroup | Test;
|
||||
type TestGroup = { name: string, children: TestTree[] };
|
||||
type Test = { name: string, test: (t: TestController) => void };
|
||||
|
||||
export class TestRegistrar {
|
||||
#suites: TestGroup[];
|
||||
|
||||
constructor() {
|
||||
this.#suites = [];
|
||||
}
|
||||
|
||||
suite(name: string, children: TestTree[]) {
|
||||
checkDuplicates(name, children)
|
||||
this.#suites.push({ name, children });
|
||||
}
|
||||
|
||||
run(reporter: Reporter) {
|
||||
reporter.register(this.#suites);
|
||||
for (const suite of this.#suites) {
|
||||
for (const c of suite.children) runTests(reporter, [suite.name], c);
|
||||
}
|
||||
reporter.finalize();
|
||||
}
|
||||
}
|
||||
|
||||
export let TESTS = new TestRegistrar();
|
||||
|
||||
// Register a suite in the global registrar.
|
||||
export function suite(name: string, children: TestTree[]) {
|
||||
TESTS.suite(name, children);
|
||||
}
|
||||
|
||||
export function context(name: string, children: TestTree[]): TestTree {
|
||||
checkDuplicates(name, children);
|
||||
return { name, children };
|
||||
}
|
||||
export const group = context;
|
||||
|
||||
export function test(name: string, test: (t: TestController) => void): TestTree {
|
||||
return { name, test };
|
||||
}
|
||||
|
||||
function checkDuplicates(parent: string, names: { name: string }[]) {
|
||||
let seen = new Set<string>();
|
||||
for (const { name } of names) {
|
||||
if (seen.has(name)) {
|
||||
throw new RangeError(`duplicate name '${name}' in '${parent}'`);
|
||||
}
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
export type TestState = "success" | "failure" | "skip";
|
||||
|
||||
class AbortSentinel {}
|
||||
|
||||
export class TestController {
|
||||
#state: TestState;
|
||||
logs: string[];
|
||||
|
||||
constructor() {
|
||||
this.#state = "success";
|
||||
this.logs = [];
|
||||
}
|
||||
|
||||
getState(): TestState {
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
fail() {
|
||||
this.#state = "failure";
|
||||
}
|
||||
|
||||
failed(): boolean {
|
||||
return this.#state === "failure";
|
||||
}
|
||||
|
||||
failNow(): never {
|
||||
this.fail();
|
||||
throw new AbortSentinel();
|
||||
}
|
||||
|
||||
log(message: string) {
|
||||
this.logs.push(message);
|
||||
}
|
||||
|
||||
error(message: string) {
|
||||
this.log(message);
|
||||
this.fail();
|
||||
}
|
||||
|
||||
fatal(message: string): never {
|
||||
this.log(message);
|
||||
this.failNow();
|
||||
}
|
||||
|
||||
skip(message?: string): never {
|
||||
if (message != null) this.log(message);
|
||||
if (this.#state !== "failure") this.#state = "skip";
|
||||
throw new AbortSentinel();
|
||||
}
|
||||
|
||||
skipped(): boolean {
|
||||
return this.#state === "skip";
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Execution
|
||||
|
||||
export interface TestResult {
|
||||
state: TestState;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
function runTests(reporter: Reporter, parents: string[], node: TestTree) {
|
||||
const path = [...parents, node.name];
|
||||
if ("children" in node) {
|
||||
for (const c of node.children) runTests(reporter, path, c);
|
||||
return;
|
||||
}
|
||||
let controller = new TestController();
|
||||
try {
|
||||
node.test(controller);
|
||||
} catch (e) {
|
||||
if (!(e instanceof AbortSentinel)) {
|
||||
controller.error(extractExceptionString(e));
|
||||
}
|
||||
}
|
||||
reporter.update(path, { state: controller.getState(), logs: controller.logs });
|
||||
}
|
||||
|
||||
function extractExceptionString(e: any): string {
|
||||
// String() instead of .toString() as null and undefined don't have
|
||||
// properties.
|
||||
const s = String(e);
|
||||
if (!(e instanceof Error && e.stack)) return s;
|
||||
// v8 (Chromium, NodeJS) includes the error message, while Firefox and
|
||||
// WebKit do not.
|
||||
if (e.stack.startsWith(s)) return e.stack;
|
||||
return `${s}\n${e.stack}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Reporting
|
||||
|
||||
export interface Reporter {
|
||||
register(suites: TestGroup[]): void
|
||||
update(path: string[], result: TestResult): void;
|
||||
finalize(): void;
|
||||
}
|
||||
|
||||
export class NoOpReporter implements Reporter {
|
||||
suites: TestGroup[];
|
||||
results: ({ path: string[] } & TestResult)[];
|
||||
finalized: boolean;
|
||||
|
||||
constructor() {
|
||||
this.suites = [];
|
||||
this.results = [];
|
||||
this.finalized = false;
|
||||
}
|
||||
|
||||
register(suites: TestGroup[]) {
|
||||
this.suites = suites;
|
||||
}
|
||||
|
||||
update(path: string[], result: TestResult) {
|
||||
this.results.push({ path, ...result });
|
||||
}
|
||||
|
||||
finalize() {
|
||||
this.finalized = true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
writeln(s: string): void;
|
||||
}
|
||||
|
||||
const SEP = " ❯ ";
|
||||
|
||||
export class StreamReporter implements Reporter {
|
||||
stream: Stream;
|
||||
verbose: boolean;
|
||||
#successes: ({ path: string[] } & TestResult)[];
|
||||
#failures: ({ path: string[] } & TestResult)[];
|
||||
#skips: ({ path: string[] } & TestResult)[];
|
||||
|
||||
constructor(stream: Stream, verbose: boolean = false) {
|
||||
this.stream = stream;
|
||||
this.verbose = verbose;
|
||||
this.#successes = [];
|
||||
this.#failures = [];
|
||||
this.#skips = [];
|
||||
}
|
||||
|
||||
succeeded(): boolean {
|
||||
return this.#successes.length > 0 && this.#failures.length === 0;
|
||||
}
|
||||
|
||||
register(suites: TestGroup[]) {}
|
||||
|
||||
update(path: string[], result: TestResult) {
|
||||
if (path.length === 0) throw new RangeError("path is empty");
|
||||
const pathStr = path.join(SEP);
|
||||
switch (result.state) {
|
||||
case "success":
|
||||
this.#successes.push({ path, ...result });
|
||||
if (this.verbose) this.stream.writeln(`✅️ ${pathStr}`);
|
||||
break;
|
||||
case "failure":
|
||||
this.#failures.push({ path, ...result });
|
||||
this.stream.writeln(`⚠️ ${pathStr}`);
|
||||
break;
|
||||
case "skip":
|
||||
this.#skips.push({ path, ...result });
|
||||
this.stream.writeln(`⏭️ ${pathStr}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
finalize() {
|
||||
if (this.verbose) this.#displaySection("successes", this.#successes, true);
|
||||
this.#displaySection("failures", this.#failures);
|
||||
this.#displaySection("skips", this.#skips);
|
||||
this.stream.writeln("");
|
||||
this.stream.writeln(
|
||||
`${this.#successes.length} succeeded, ${this.#failures.length} failed`
|
||||
+ (this.#skips.length ? `, ${this.#skips.length} skipped` : "")
|
||||
);
|
||||
}
|
||||
|
||||
#displaySection(name: string, data: ({ path: string[] } & TestResult)[], ignoreEmpty: boolean = false) {
|
||||
if (!data.length) return;
|
||||
|
||||
// Transform [{ path: ["a", "b", "c"] }, { path: ["a", "b", "d"] }]
|
||||
// into { "a ❯ b": ["c", "d"] }.
|
||||
let pathMap = new Map<string, ({ name: string } & TestResult)[]>();
|
||||
for (const t of data) {
|
||||
if (t.path.length === 0) throw new RangeError("path is empty");
|
||||
const key = t.path.slice(0, -1).join(SEP);
|
||||
if (!pathMap.has(key)) pathMap.set(key, []);
|
||||
pathMap.get(key)!.push({ name: t.path.at(-1)!, ...t });
|
||||
}
|
||||
|
||||
this.stream.writeln("");
|
||||
this.stream.writeln(name.toUpperCase());
|
||||
this.stream.writeln("=".repeat(name.length));
|
||||
|
||||
for (let [path, tests] of pathMap) {
|
||||
if (ignoreEmpty) tests = tests.filter((t) => t.logs.length);
|
||||
if (tests.length === 0) continue;
|
||||
if (tests.length === 1) {
|
||||
this.#writeOutput(tests[0], path ? `${path}${SEP}` : "", false);
|
||||
} else {
|
||||
this.stream.writeln(path);
|
||||
for (const t of tests) this.#writeOutput(t, " - ", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#writeOutput(test: { name: string } & TestResult, prefix: string, nested: boolean) {
|
||||
let output = "";
|
||||
if (test.logs.length) {
|
||||
// Individual logs might span multiple lines, so join them together
|
||||
// then split it again.
|
||||
const logStr = test.logs.join("\n");
|
||||
const lines = logStr.split("\n");
|
||||
if (lines.length <= 1) {
|
||||
output = `: ${logStr}`;
|
||||
} else {
|
||||
const padding = nested ? " " : " ";
|
||||
output = ":\n" + lines.map((line) => padding + line).join("\n");
|
||||
}
|
||||
}
|
||||
this.stream.writeln(`${prefix}${test.name}${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertGetElementById(id: string): HTMLElement {
|
||||
let elem = document.getElementById(id);
|
||||
if (elem == null) throw new ReferenceError(`element with ID '${id}' missing from DOM`);
|
||||
return elem;
|
||||
}
|
||||
|
||||
export class DOMReporter implements Reporter {
|
||||
register(suites: TestGroup[]) {}
|
||||
|
||||
update(path: string[], result: TestResult) {
|
||||
if (path.length === 0) throw new RangeError("path is empty");
|
||||
if (result.state === "skip") {
|
||||
assertGetElementById("skip-counter-text").classList.remove("hidden");
|
||||
}
|
||||
const counter = assertGetElementById(`${result.state}-counter`);
|
||||
counter.innerText = (Number(counter.innerText) + 1).toString();
|
||||
|
||||
let parent = assertGetElementById("root");
|
||||
for (const node of path) {
|
||||
let child: HTMLDetailsElement | null = null;
|
||||
let summary: HTMLElement | null = null;
|
||||
let d: Element;
|
||||
outer: for (d of parent.children) {
|
||||
if (!(d instanceof HTMLDetailsElement)) continue;
|
||||
for (const s of d.children) {
|
||||
if (!(s instanceof HTMLElement)) continue;
|
||||
if (!(s.tagName === "SUMMARY" && s.innerText === node)) continue;
|
||||
child = d;
|
||||
summary = s;
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
if (!child) {
|
||||
child = document.createElement("details");
|
||||
child.className = "test-node";
|
||||
child.ariaRoleDescription = "test";
|
||||
summary = document.createElement("summary");
|
||||
summary.appendChild(document.createTextNode(node));
|
||||
summary.ariaRoleDescription = "test name";
|
||||
child.appendChild(summary);
|
||||
parent.appendChild(child);
|
||||
}
|
||||
if (!summary) throw new Error("unreachable as assigned above");
|
||||
|
||||
switch (result.state) {
|
||||
case "failure":
|
||||
child.open = true;
|
||||
child.classList.add("failure");
|
||||
child.classList.remove("skip");
|
||||
child.classList.remove("success");
|
||||
summary.setAttribute("aria-labelledby", "failure-description");
|
||||
break;
|
||||
case "skip":
|
||||
if (child.classList.contains("failure")) break;
|
||||
child.classList.add("skip");
|
||||
child.classList.remove("success");
|
||||
summary.setAttribute("aria-labelledby", "skip-description");
|
||||
break;
|
||||
case "success":
|
||||
if (child.classList.contains("failure") || child.classList.contains("skip")) break;
|
||||
child.classList.add("success");
|
||||
summary.setAttribute("aria-labelledby", "success-description");
|
||||
break;
|
||||
}
|
||||
|
||||
parent = child;
|
||||
}
|
||||
|
||||
const p = document.createElement("p");
|
||||
p.classList.add("test-desc");
|
||||
if (result.logs.length) {
|
||||
const pre = document.createElement("pre");
|
||||
pre.appendChild(document.createTextNode(result.logs.join("\n")));
|
||||
p.appendChild(pre);
|
||||
} else {
|
||||
p.classList.add("italic");
|
||||
p.appendChild(document.createTextNode("No output."));
|
||||
}
|
||||
parent.appendChild(p);
|
||||
}
|
||||
|
||||
finalize() {}
|
||||
}
|
||||
|
||||
interface GoNode {
|
||||
name: string;
|
||||
subtests: GoNode[] | null;
|
||||
}
|
||||
|
||||
// Used to display results via `go test`, via some glue code from the Go side.
|
||||
export class GoTestReporter implements Reporter {
|
||||
// Convert a test tree into the one expected by the Go code.
|
||||
static serialize(node: TestTree): GoNode {
|
||||
return {
|
||||
name: node.name,
|
||||
subtests: "children" in node ? node.children.map(GoTestReporter.serialize) : null,
|
||||
};
|
||||
}
|
||||
|
||||
register(suites: TestGroup[]) {
|
||||
console.log(JSON.stringify(suites.map(GoTestReporter.serialize)));
|
||||
}
|
||||
|
||||
update(path: string[], result: TestResult) {
|
||||
let state: number;
|
||||
switch (result.state) {
|
||||
case "success": state = 0; break;
|
||||
case "failure": state = 1; break;
|
||||
case "skip": state = 2; break;
|
||||
}
|
||||
console.log(JSON.stringify({ path: path, state, logs: result.logs }));
|
||||
}
|
||||
|
||||
finalize() {
|
||||
console.log("null");
|
||||
}
|
||||
}
|
||||
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" class="hidden">, <span id="skip-counter">0</span> skipped</span>.
|
||||
</p>
|
||||
|
||||
<p hidden id="success-description">Successful test</p>
|
||||
<p hidden id="failure-description">Failed test</p>
|
||||
<p hidden id="skip-description">Partially or fully skipped test</p>
|
||||
|
||||
<div id="root">
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import "/test/all_tests.js";
|
||||
import { DOMReporter, TESTS } from "/test/lib/test.js";
|
||||
TESTS.run(new DOMReporter());
|
||||
</script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
87
cmd/pkgserver/ui_test/lib/ui.scss
Normal file
87
cmd/pkgserver/ui_test/lib/ui.scss
Normal file
@@ -0,0 +1,87 @@
|
||||
:root {
|
||||
--bg: #d3d3d3;
|
||||
--fg: black;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #2c2c2c;
|
||||
--fg: ghostwhite;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
h1, p, summary, noscript {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
noscript {
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.root {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
details.test-node {
|
||||
margin-left: 1rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-left: 2px dashed var(--fg);
|
||||
> summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
&.success > summary::marker {
|
||||
/*
|
||||
* WebKit only supports color and font-size properties in ::marker [1],
|
||||
* and its ::-webkit-details-marker only supports hiding the marker
|
||||
* entirely [2], contrary to mdn's example [3]; thus, set a color as
|
||||
* a fallback: while it may not be accessible for colorblind
|
||||
* individuals, it's better than no indication of a test's state for
|
||||
* anyone, as that there's no other way to include an indication in the
|
||||
* marker on WebKit.
|
||||
*
|
||||
* [1]: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::marker#browser_compatibility
|
||||
* [2]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#default_style
|
||||
* [3]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#changing_the_summarys_icon
|
||||
*/
|
||||
color: var(--fg);
|
||||
content: url("/testui/success-closed.svg") / "success";
|
||||
}
|
||||
&.success[open] > summary::marker {
|
||||
content: url("/testui/success-open.svg") / "success";
|
||||
}
|
||||
&.failure > summary::marker {
|
||||
color: red;
|
||||
content: url("/testui/failure-closed.svg") / "failure";
|
||||
}
|
||||
&.failure[open] > summary::marker {
|
||||
content: url("/testui/failure-open.svg") / "failure";
|
||||
}
|
||||
&.skip > summary::marker {
|
||||
color: blue;
|
||||
content: url("/testui/skip-closed.svg") / "skip";
|
||||
}
|
||||
&.skip[open] > summary::marker {
|
||||
content: url("/testui/skip-open.svg") / "skip";
|
||||
}
|
||||
}
|
||||
|
||||
p.test-desc {
|
||||
margin: 0 0 0 1rem;
|
||||
padding: 2px 0;
|
||||
> pre {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
85
cmd/pkgserver/ui_test/test_tests.ts
Normal file
85
cmd/pkgserver/ui_test/test_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"
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
#endif
|
||||
|
||||
#define SHAREFS_MEDIA_RW_ID (1 << 10) - 1 /* owning gid presented to userspace */
|
||||
#define SHAREFS_PERM_DIR 0700 /* permission bits for directories presented to userspace */
|
||||
#define SHAREFS_PERM_REG 0600 /* permission bits for regular files presented to userspace */
|
||||
#define SHAREFS_PERM_DIR 0770 /* permission bits for directories presented to userspace */
|
||||
#define SHAREFS_PERM_REG 0660 /* permission bits for regular files presented to userspace */
|
||||
#define SHAREFS_FORBIDDEN_FLAGS O_DIRECT /* these open flags are cleared unconditionally */
|
||||
|
||||
/* sharefs_private is populated by sharefs_init and contains process-wide context */
|
||||
|
||||
@@ -19,12 +19,11 @@ import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/cgo"
|
||||
"strconv"
|
||||
@@ -85,7 +84,10 @@ func destroySetup(private_data unsafe.Pointer) (ok bool) {
|
||||
}
|
||||
|
||||
//export sharefs_init
|
||||
func sharefs_init(_ *C.struct_fuse_conn_info, cfg *C.struct_fuse_config) unsafe.Pointer {
|
||||
func sharefs_init(
|
||||
_ *C.struct_fuse_conn_info,
|
||||
cfg *C.struct_fuse_config,
|
||||
) unsafe.Pointer {
|
||||
ctx := C.fuse_get_context()
|
||||
priv := (*C.struct_sharefs_private)(ctx.private_data)
|
||||
setup := cgo.Handle(priv.setup).Value().(*setupState)
|
||||
@@ -103,7 +105,11 @@ func sharefs_init(_ *C.struct_fuse_conn_info, cfg *C.struct_fuse_config) unsafe.
|
||||
cfg.negative_timeout = 0
|
||||
|
||||
// all future filesystem operations happen through this dirfd
|
||||
if fd, err := syscall.Open(setup.Source.String(), syscall.O_DIRECTORY|syscall.O_RDONLY|syscall.O_CLOEXEC, 0); err != nil {
|
||||
if fd, err := syscall.Open(
|
||||
setup.Source.String(),
|
||||
syscall.O_DIRECTORY|syscall.O_RDONLY|syscall.O_CLOEXEC,
|
||||
0,
|
||||
); err != nil {
|
||||
log.Printf("cannot open %q: %v", setup.Source, err)
|
||||
goto fail
|
||||
} else if err = syscall.Fchdir(fd); err != nil {
|
||||
@@ -138,9 +144,9 @@ func sharefs_destroy(private_data unsafe.Pointer) {
|
||||
func showHelp(args *fuseArgs) {
|
||||
executableName := sharefsName
|
||||
if args.argc > 0 {
|
||||
executableName = path.Base(C.GoString(*args.argv))
|
||||
executableName = filepath.Base(C.GoString(*args.argv))
|
||||
} else if name, err := os.Executable(); err == nil {
|
||||
executableName = path.Base(name)
|
||||
executableName = filepath.Base(name)
|
||||
}
|
||||
|
||||
fmt.Printf("usage: %s [options] <mountpoint>\n\n", executableName)
|
||||
@@ -169,8 +175,11 @@ func parseOpts(args *fuseArgs, setup *setupState, log *log.Logger) (ok bool) {
|
||||
// Decimal string representation of gid to set when running as root.
|
||||
setgid *C.char
|
||||
|
||||
// Decimal string representation of open file descriptor to read setupState from.
|
||||
// This is an internal detail for containerisation and must not be specified directly.
|
||||
// Decimal string representation of open file descriptor to read
|
||||
// setupState from.
|
||||
//
|
||||
// This is an internal detail for containerisation and must not be
|
||||
// specified directly.
|
||||
setup *C.char
|
||||
}
|
||||
|
||||
@@ -253,7 +262,8 @@ func parseOpts(args *fuseArgs, setup *setupState, log *log.Logger) (ok bool) {
|
||||
return true
|
||||
}
|
||||
|
||||
// copyArgs returns a heap allocated copy of an argument slice in fuse_args representation.
|
||||
// copyArgs returns a heap allocated copy of an argument slice in fuse_args
|
||||
// representation.
|
||||
func copyArgs(s ...string) fuseArgs {
|
||||
if len(s) == 0 {
|
||||
return fuseArgs{argc: 0, argv: nil, allocated: 0}
|
||||
@@ -269,6 +279,7 @@ func copyArgs(s ...string) fuseArgs {
|
||||
func freeArgs(args *fuseArgs) { C.fuse_opt_free_args(args) }
|
||||
|
||||
// unsafeAddArgument adds an argument to fuseArgs via fuse_opt_add_arg.
|
||||
//
|
||||
// The last byte of arg must be 0.
|
||||
func unsafeAddArgument(args *fuseArgs, arg string) {
|
||||
C.fuse_opt_add_arg(args, (*C.char)(unsafe.Pointer(unsafe.StringData(arg))))
|
||||
@@ -288,8 +299,8 @@ func _main(s ...string) (exitCode int) {
|
||||
args := copyArgs(s...)
|
||||
defer freeArgs(&args)
|
||||
|
||||
// this causes the kernel to enforce access control based on
|
||||
// struct stat populated by sharefs_getattr
|
||||
// this causes the kernel to enforce access control based on struct stat
|
||||
// populated by sharefs_getattr
|
||||
unsafeAddArgument(&args, "-odefault_permissions\x00")
|
||||
|
||||
var priv C.struct_sharefs_private
|
||||
@@ -453,15 +464,19 @@ func _main(s ...string) (exitCode int) {
|
||||
z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
}
|
||||
z.Bind(z.Path, z.Path, 0)
|
||||
setup.Fuse = int(proc.ExtraFileSlice(&z.ExtraFiles, os.NewFile(uintptr(C.fuse_session_fd(se)), "fuse")))
|
||||
setup.Fuse = int(proc.ExtraFileSlice(
|
||||
&z.ExtraFiles,
|
||||
os.NewFile(uintptr(C.fuse_session_fd(se)), "fuse"),
|
||||
))
|
||||
|
||||
var setupWriter io.WriteCloser
|
||||
if fd, w, err := container.Setup(&z.ExtraFiles); err != nil {
|
||||
var setupPipe [2]*os.File
|
||||
if r, w, err := os.Pipe(); err != nil {
|
||||
log.Println(err)
|
||||
return 5
|
||||
} else {
|
||||
z.Args = append(z.Args, "-osetup="+strconv.Itoa(fd))
|
||||
setupWriter = w
|
||||
z.Args = append(z.Args, "-osetup="+strconv.Itoa(3+len(z.ExtraFiles)))
|
||||
z.ExtraFiles = append(z.ExtraFiles, r)
|
||||
setupPipe[0], setupPipe[1] = r, w
|
||||
}
|
||||
|
||||
if err := z.Start(); err != nil {
|
||||
@@ -472,6 +487,9 @@ func _main(s ...string) (exitCode int) {
|
||||
}
|
||||
return 5
|
||||
}
|
||||
if err := setupPipe[0].Close(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
if err := z.Serve(); err != nil {
|
||||
if m, ok := message.GetMessage(err); ok {
|
||||
log.Println(m)
|
||||
@@ -481,10 +499,10 @@ func _main(s ...string) (exitCode int) {
|
||||
return 5
|
||||
}
|
||||
|
||||
if err := gob.NewEncoder(setupWriter).Encode(&setup); err != nil {
|
||||
if err := gob.NewEncoder(setupPipe[1]).Encode(&setup); err != nil {
|
||||
log.Println(err)
|
||||
return 5
|
||||
} else if err = setupWriter.Close(); err != nil {
|
||||
} else if err = setupPipe[1].Close(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
// The sharefs FUSE filesystem is a permissionless shared filesystem.
|
||||
//
|
||||
// This filesystem is the primary means of file sharing between hakurei
|
||||
// application containers. It serves the same purpose in Rosa OS as /sdcard
|
||||
// does in AOSP.
|
||||
//
|
||||
// See help message for all available options.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
122
cmd/sharefs/test/raceattr.go
Normal file
122
cmd/sharefs/test/raceattr.go
Normal file
@@ -0,0 +1,122 @@
|
||||
//go:build raceattr
|
||||
|
||||
// The raceattr program reproduces vfs inode file attribute race.
|
||||
//
|
||||
// Even though libfuse high-level API presents the address of a struct stat
|
||||
// alongside struct fuse_context, file attributes are actually inherent to the
|
||||
// inode, instead of the specific call from userspace. The kernel implementation
|
||||
// in fs/fuse/xattr.c appears to make stale data in the inode (set by a previous
|
||||
// call) impossible or very unlikely to reach userspace via the stat family of
|
||||
// syscalls. However, when using default_permissions to have the VFS check
|
||||
// permissions, this race still happens, despite the resulting struct stat being
|
||||
// correct when overriding the check via capabilities otherwise.
|
||||
//
|
||||
// This program reproduces the failure, but because of its continuous nature, it
|
||||
// is provided independent of the vm integration test suite.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func newStatAs(
|
||||
ctx context.Context, cancel context.CancelFunc,
|
||||
n *atomic.Uint64, ok *atomic.Bool,
|
||||
uid uint32, pathname string,
|
||||
continuous bool,
|
||||
) func() {
|
||||
return func() {
|
||||
runtime.LockOSThread()
|
||||
defer cancel()
|
||||
|
||||
if _, _, errno := syscall.Syscall(
|
||||
syscall.SYS_SETUID, uintptr(uid),
|
||||
0, 0,
|
||||
); errno != 0 {
|
||||
cancel()
|
||||
log.Printf("cannot set uid to %d: %s", uid, errno)
|
||||
}
|
||||
|
||||
var stat syscall.Stat_t
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := syscall.Lstat(pathname, &stat); err != nil {
|
||||
// SHAREFS_PERM_DIR not world executable, or
|
||||
// SHAREFS_PERM_REG not world readable
|
||||
if !continuous {
|
||||
cancel()
|
||||
}
|
||||
ok.Store(true)
|
||||
log.Printf("uid %d: %v", uid, err)
|
||||
} else if stat.Uid != uid {
|
||||
// appears to be unreachable
|
||||
if !continuous {
|
||||
cancel()
|
||||
}
|
||||
ok.Store(true)
|
||||
log.Printf("got uid %d instead of %d", stat.Uid, uid)
|
||||
}
|
||||
n.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("raceattr: ")
|
||||
|
||||
p := flag.String("target", "/sdcard/raceattr", "pathname of test file")
|
||||
u0 := flag.Int("uid0", 1<<10-1, "first uid")
|
||||
u1 := flag.Int("uid1", 1<<10-2, "second uid")
|
||||
count := flag.Int("count", 1, "threads per uid")
|
||||
continuous := flag.Bool("continuous", false, "keep running even after reproduce")
|
||||
flag.Parse()
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
log.Fatal("this program must run as root")
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(
|
||||
context.Background(),
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGHUP,
|
||||
)
|
||||
|
||||
if err := os.WriteFile(*p, nil, 0); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
|
||||
n atomic.Uint64
|
||||
ok atomic.Bool
|
||||
)
|
||||
|
||||
if *count < 1 {
|
||||
*count = 1
|
||||
}
|
||||
for range *count {
|
||||
wg.Go(newStatAs(ctx, cancel, &n, &ok, uint32(*u0), *p, *continuous))
|
||||
if *u1 >= 0 {
|
||||
wg.Go(newStatAs(ctx, cancel, &n, &ok, uint32(*u1), *p, *continuous))
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
if !*continuous && ok.Load() {
|
||||
log.Printf("reproduced after %d calls", n.Load())
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,6 @@ const (
|
||||
// CancelSignal is the signal expected by container init on context cancel.
|
||||
// A custom [Container.Cancel] function must eventually deliver this signal.
|
||||
CancelSignal = SIGUSR2
|
||||
|
||||
// Timeout for writing initParams to Container.setup.
|
||||
initSetupTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -53,7 +50,7 @@ type (
|
||||
ExtraFiles []*os.File
|
||||
|
||||
// Write end of a pipe connected to the init to deliver [Params].
|
||||
setup *os.File
|
||||
setup [2]*os.File
|
||||
// Cancels the context passed to the underlying cmd.
|
||||
cancel context.CancelFunc
|
||||
// Closed after Wait returns. Keeps the spawning thread alive.
|
||||
@@ -287,14 +284,16 @@ func (p *Container) Start() error {
|
||||
}
|
||||
|
||||
// place setup pipe before user supplied extra files, this is later restored by init
|
||||
if fd, f, err := Setup(&p.cmd.ExtraFiles); err != nil {
|
||||
if r, w, err := os.Pipe(); err != nil {
|
||||
return &StartError{
|
||||
Fatal: true,
|
||||
Step: "set up params stream",
|
||||
Err: err,
|
||||
}
|
||||
} else {
|
||||
p.setup = f
|
||||
fd := 3 + len(p.cmd.ExtraFiles)
|
||||
p.cmd.ExtraFiles = append(p.cmd.ExtraFiles, r)
|
||||
p.setup[0], p.setup[1] = r, w
|
||||
p.cmd.Env = []string{setupEnv + "=" + strconv.Itoa(fd)}
|
||||
}
|
||||
p.cmd.ExtraFiles = append(p.cmd.ExtraFiles, p.ExtraFiles...)
|
||||
@@ -324,9 +323,9 @@ func (p *Container) Start() error {
|
||||
}
|
||||
|
||||
if abi, err := LandlockGetABI(); err != nil {
|
||||
if p.HostAbstract {
|
||||
if p.HostAbstract || !p.HostNet {
|
||||
// landlock can be skipped here as it restricts access
|
||||
// to resources already covered by namespaces (pid)
|
||||
// to resources already covered by namespaces (pid, net)
|
||||
goto landlockOut
|
||||
}
|
||||
return &StartError{Step: "get landlock ABI", Err: err}
|
||||
@@ -428,24 +427,33 @@ func (p *Container) Start() error {
|
||||
// Serve serves [Container.Params] to the container init.
|
||||
//
|
||||
// Serve must only be called once.
|
||||
func (p *Container) Serve() error {
|
||||
if p.setup == nil {
|
||||
func (p *Container) Serve() (err error) {
|
||||
if p.setup[0] == nil || p.setup[1] == nil {
|
||||
panic("invalid serve")
|
||||
}
|
||||
|
||||
setup := p.setup
|
||||
p.setup = nil
|
||||
if err := setup.SetDeadline(time.Now().Add(initSetupTimeout)); err != nil {
|
||||
done := make(chan struct{})
|
||||
defer func() {
|
||||
if closeErr := p.setup[1].Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
p.cancel()
|
||||
}
|
||||
close(done)
|
||||
p.setup[0], p.setup[1] = nil, nil
|
||||
}()
|
||||
if err = p.setup[0].Close(); err != nil {
|
||||
return &StartError{
|
||||
Fatal: true,
|
||||
Step: "set init pipe deadline",
|
||||
Step: "close read end of init pipe",
|
||||
Err: err,
|
||||
Passthrough: true,
|
||||
}
|
||||
}
|
||||
|
||||
if p.Path == nil {
|
||||
p.cancel()
|
||||
return &StartError{
|
||||
Step: "invalid executable pathname",
|
||||
Err: EINVAL,
|
||||
@@ -461,18 +469,27 @@ func (p *Container) Serve() error {
|
||||
p.SeccompRules = make([]std.NativeRule, 0)
|
||||
}
|
||||
|
||||
err := gob.NewEncoder(setup).Encode(&initParams{
|
||||
t := time.Now().UTC()
|
||||
go func(f *os.File) {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
if cancelErr := f.SetWriteDeadline(t); cancelErr != nil {
|
||||
p.msg.Verbose(err)
|
||||
}
|
||||
|
||||
case <-done:
|
||||
p.msg.Verbose("setup payload took", time.Since(t))
|
||||
return
|
||||
}
|
||||
}(p.setup[1])
|
||||
|
||||
return gob.NewEncoder(p.setup[1]).Encode(&initParams{
|
||||
p.Params,
|
||||
Getuid(),
|
||||
Getgid(),
|
||||
len(p.ExtraFiles),
|
||||
p.msg.IsVerbose(),
|
||||
})
|
||||
_ = setup.Close()
|
||||
if err != nil {
|
||||
p.cancel()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait blocks until the container init process to exit and releases any
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/command"
|
||||
@@ -26,6 +25,8 @@ import (
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/hst"
|
||||
"hakurei.app/internal/info"
|
||||
"hakurei.app/internal/params"
|
||||
"hakurei.app/ldd"
|
||||
"hakurei.app/message"
|
||||
"hakurei.app/vfs"
|
||||
@@ -84,9 +85,9 @@ func TestStartError(t *testing.T) {
|
||||
{"params env", &container.StartError{
|
||||
Fatal: true,
|
||||
Step: "set up params stream",
|
||||
Err: container.ErrReceiveEnv,
|
||||
Err: params.ErrReceiveEnv,
|
||||
}, "set up params stream: environment variable not set",
|
||||
container.ErrReceiveEnv, syscall.EBADF,
|
||||
params.ErrReceiveEnv, syscall.EBADF,
|
||||
"cannot set up params stream: environment variable not set"},
|
||||
|
||||
{"params", &container.StartError{
|
||||
@@ -436,11 +437,8 @@ func TestContainer(t *testing.T) {
|
||||
wantOps, wantOpsCtx := tc.ops(t)
|
||||
wantMnt := tc.mnt(t, wantOpsCtx)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), helperDefaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
var libPaths []*check.Absolute
|
||||
c := helperNewContainerLibPaths(ctx, &libPaths, "container", strconv.Itoa(i))
|
||||
c := helperNewContainerLibPaths(t.Context(), &libPaths, "container", strconv.Itoa(i))
|
||||
c.Uid = tc.uid
|
||||
c.Gid = tc.gid
|
||||
c.Hostname = hostnameFromTestCase(tc.name)
|
||||
@@ -450,7 +448,6 @@ func TestContainer(t *testing.T) {
|
||||
} else {
|
||||
c.Stdout, c.Stderr = os.Stdout, os.Stderr
|
||||
}
|
||||
c.WaitDelay = helperDefaultTimeout
|
||||
*c.Ops = append(*c.Ops, *wantOps...)
|
||||
c.SeccompRules = tc.rules
|
||||
c.SeccompFlags = tc.flags | seccomp.AllowMultiarch
|
||||
@@ -458,6 +455,15 @@ func TestContainer(t *testing.T) {
|
||||
c.SeccompDisable = !tc.filter
|
||||
c.RetainSession = tc.session
|
||||
c.HostNet = tc.net
|
||||
if info.CanDegrade {
|
||||
if _, err := container.LandlockGetABI(); err != nil {
|
||||
if !errors.Is(err, syscall.ENOSYS) {
|
||||
t.Fatalf("LandlockGetABI: error = %v", err)
|
||||
}
|
||||
c.HostAbstract = true
|
||||
t.Log("Landlock LSM is unavailable, enabling HostAbstract")
|
||||
}
|
||||
}
|
||||
|
||||
c.
|
||||
Readonly(check.MustAbs(pathReadonly), 0755).
|
||||
@@ -553,11 +559,10 @@ func testContainerCancel(
|
||||
) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), helperDefaultTimeout)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
|
||||
c := helperNewContainer(ctx, "block")
|
||||
c.Stdout, c.Stderr = os.Stdout, os.Stderr
|
||||
c.WaitDelay = helperDefaultTimeout
|
||||
if containerExtra != nil {
|
||||
containerExtra(c)
|
||||
}
|
||||
@@ -738,8 +743,7 @@ func init() {
|
||||
const (
|
||||
envDoCheck = "HAKUREI_TEST_DO_CHECK"
|
||||
|
||||
helperDefaultTimeout = 5 * time.Second
|
||||
helperInnerPath = "/usr/bin/helper"
|
||||
helperInnerPath = "/usr/bin/helper"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/internal/netlink"
|
||||
"hakurei.app/internal/params"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
@@ -55,7 +57,7 @@ type syscallDispatcher interface {
|
||||
// isatty provides [Isatty].
|
||||
isatty(fd int) bool
|
||||
// receive provides [Receive].
|
||||
receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error)
|
||||
receive(key string, e any, fdp *int) (closeFunc func() error, err error)
|
||||
|
||||
// bindMount provides procPaths.bindMount.
|
||||
bindMount(msg message.Msg, source, target string, flags uintptr) error
|
||||
@@ -66,7 +68,7 @@ type syscallDispatcher interface {
|
||||
// ensureFile provides ensureFile.
|
||||
ensureFile(name string, perm, pperm os.FileMode) error
|
||||
// mustLoopback provides mustLoopback.
|
||||
mustLoopback(msg message.Msg)
|
||||
mustLoopback(ctx context.Context, msg message.Msg)
|
||||
|
||||
// seccompLoad provides [seccomp.Load].
|
||||
seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error
|
||||
@@ -154,8 +156,8 @@ func (direct) capBoundingSetDrop(cap uintptr) error { return capBound
|
||||
func (direct) capAmbientClearAll() error { return capAmbientClearAll() }
|
||||
func (direct) capAmbientRaise(cap uintptr) error { return capAmbientRaise(cap) }
|
||||
func (direct) isatty(fd int) bool { return ext.Isatty(fd) }
|
||||
func (direct) receive(key string, e any, fdp *uintptr) (func() error, error) {
|
||||
return Receive(key, e, fdp)
|
||||
func (direct) receive(key string, e any, fdp *int) (func() error, error) {
|
||||
return params.Receive(key, e, fdp)
|
||||
}
|
||||
|
||||
func (direct) bindMount(msg message.Msg, source, target string, flags uintptr) error {
|
||||
@@ -170,7 +172,7 @@ func (k direct) mountTmpfs(fsname, target string, flags uintptr, size int, perm
|
||||
func (direct) ensureFile(name string, perm, pperm os.FileMode) error {
|
||||
return ensureFile(name, perm, pperm)
|
||||
}
|
||||
func (direct) mustLoopback(msg message.Msg) {
|
||||
func (direct) mustLoopback(ctx context.Context, msg message.Msg) {
|
||||
var lo int
|
||||
if ifi, err := net.InterfaceByName("lo"); err != nil {
|
||||
msg.GetLogger().Fatalln(err)
|
||||
@@ -178,7 +180,7 @@ func (direct) mustLoopback(msg message.Msg) {
|
||||
lo = ifi.Index
|
||||
}
|
||||
|
||||
c, err := netlink.DialRoute()
|
||||
c, err := netlink.DialRoute(0)
|
||||
if err != nil {
|
||||
msg.GetLogger().Fatalln(err)
|
||||
}
|
||||
@@ -199,11 +201,14 @@ func (direct) mustLoopback(msg message.Msg) {
|
||||
msg.GetLogger().Fatalf("RTNETLINK answers: %v", err)
|
||||
|
||||
default:
|
||||
msg.GetLogger().Fatalf("RTNETLINK answers with malformed message")
|
||||
if err == context.DeadlineExceeded || err == context.Canceled {
|
||||
msg.GetLogger().Fatalf("interrupted RTNETLINK operation")
|
||||
}
|
||||
msg.GetLogger().Fatal("RTNETLINK answers with malformed message")
|
||||
}
|
||||
}
|
||||
must(c.SendNewaddrLo(uint32(lo)))
|
||||
must(c.SendIfInfomsg(syscall.RTM_NEWLINK, 0, &syscall.IfInfomsg{
|
||||
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,
|
||||
|
||||
@@ -2,6 +2,7 @@ package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -389,7 +390,7 @@ func (k *kstub) isatty(fd int) bool {
|
||||
return expect.Ret.(bool)
|
||||
}
|
||||
|
||||
func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error) {
|
||||
func (k *kstub) receive(key string, e any, fdp *int) (closeFunc func() error, err error) {
|
||||
k.Helper()
|
||||
expect := k.Expects("receive")
|
||||
|
||||
@@ -407,10 +408,17 @@ func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// avoid changing test cases
|
||||
var fdpComp *uintptr
|
||||
if fdp != nil {
|
||||
fdpComp = new(uintptr(*fdp))
|
||||
}
|
||||
|
||||
err = expect.Error(
|
||||
stub.CheckArg(k.Stub, "key", key, 0),
|
||||
stub.CheckArgReflect(k.Stub, "e", e, 1),
|
||||
stub.CheckArgReflect(k.Stub, "fdp", fdp, 2))
|
||||
stub.CheckArgReflect(k.Stub, "fdp", fdpComp, 2))
|
||||
|
||||
// 3 is unused so stores params
|
||||
if expect.Args[3] != nil {
|
||||
@@ -425,7 +433,7 @@ func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error
|
||||
if expect.Args[4] != nil {
|
||||
if v, ok := expect.Args[4].(uintptr); ok && v >= 3 {
|
||||
if fdp != nil {
|
||||
*fdp = v
|
||||
*fdp = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,7 +476,7 @@ func (k *kstub) ensureFile(name string, perm, pperm os.FileMode) error {
|
||||
stub.CheckArg(k.Stub, "pperm", pperm, 2))
|
||||
}
|
||||
|
||||
func (*kstub) mustLoopback(message.Msg) { /* noop */ }
|
||||
func (*kstub) mustLoopback(context.Context, message.Msg) { /* noop */ }
|
||||
|
||||
func (k *kstub) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) error {
|
||||
k.Helper()
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"hakurei.app/container/seccomp"
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/internal/params"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
@@ -146,36 +148,38 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
}
|
||||
|
||||
var (
|
||||
params initParams
|
||||
closeSetup func() error
|
||||
setupFd uintptr
|
||||
offsetSetup int
|
||||
param initParams
|
||||
closeSetup func() error
|
||||
setupFd int
|
||||
)
|
||||
if f, err := k.receive(setupEnv, ¶ms, &setupFd); err != nil {
|
||||
if f, err := k.receive(setupEnv, ¶m, &setupFd); err != nil {
|
||||
if errors.Is(err, EBADF) {
|
||||
k.fatal(msg, "invalid setup descriptor")
|
||||
}
|
||||
if errors.Is(err, ErrReceiveEnv) {
|
||||
if errors.Is(err, params.ErrReceiveEnv) {
|
||||
k.fatal(msg, setupEnv+" not set")
|
||||
}
|
||||
|
||||
k.fatalf(msg, "cannot decode init setup payload: %v", err)
|
||||
} else {
|
||||
if params.Ops == nil {
|
||||
if param.Ops == nil {
|
||||
k.fatal(msg, "invalid setup parameters")
|
||||
}
|
||||
if params.ParentPerm == 0 {
|
||||
params.ParentPerm = 0755
|
||||
if param.ParentPerm == 0 {
|
||||
param.ParentPerm = 0755
|
||||
}
|
||||
|
||||
msg.SwapVerbose(params.Verbose)
|
||||
msg.SwapVerbose(param.Verbose)
|
||||
msg.Verbose("received setup parameters")
|
||||
closeSetup = f
|
||||
offsetSetup = int(setupFd + 1)
|
||||
}
|
||||
|
||||
if !params.HostNet {
|
||||
k.mustLoopback(msg)
|
||||
if !param.HostNet {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), CancelSignal,
|
||||
os.Interrupt, SIGTERM, SIGQUIT)
|
||||
defer cancel() // for panics
|
||||
k.mustLoopback(ctx, msg)
|
||||
cancel()
|
||||
}
|
||||
|
||||
// write uid/gid map here so parent does not need to set dumpable
|
||||
@@ -183,7 +187,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
k.fatalf(msg, "cannot set SUID_DUMP_USER: %v", err)
|
||||
}
|
||||
if err := k.writeFile(fhs.Proc+"self/uid_map",
|
||||
append([]byte{}, strconv.Itoa(params.Uid)+" "+strconv.Itoa(params.HostUid)+" 1\n"...),
|
||||
append([]byte{}, strconv.Itoa(param.Uid)+" "+strconv.Itoa(param.HostUid)+" 1\n"...),
|
||||
0); err != nil {
|
||||
k.fatalf(msg, "%v", err)
|
||||
}
|
||||
@@ -193,7 +197,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
k.fatalf(msg, "%v", err)
|
||||
}
|
||||
if err := k.writeFile(fhs.Proc+"self/gid_map",
|
||||
append([]byte{}, strconv.Itoa(params.Gid)+" "+strconv.Itoa(params.HostGid)+" 1\n"...),
|
||||
append([]byte{}, strconv.Itoa(param.Gid)+" "+strconv.Itoa(param.HostGid)+" 1\n"...),
|
||||
0); err != nil {
|
||||
k.fatalf(msg, "%v", err)
|
||||
}
|
||||
@@ -202,8 +206,8 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
}
|
||||
|
||||
oldmask := k.umask(0)
|
||||
if params.Hostname != "" {
|
||||
if err := k.sethostname([]byte(params.Hostname)); err != nil {
|
||||
if param.Hostname != "" {
|
||||
if err := k.sethostname([]byte(param.Hostname)); err != nil {
|
||||
k.fatalf(msg, "cannot set hostname: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -216,7 +220,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
state := &setupState{process: make(map[int]WaitStatus), Params: ¶ms.Params, Msg: msg, Context: ctx}
|
||||
state := &setupState{process: make(map[int]WaitStatus), Params: ¶m.Params, Msg: msg, Context: ctx}
|
||||
defer cancel()
|
||||
|
||||
/* early is called right before pivot_root into intermediate root;
|
||||
@@ -224,7 +228,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
difficult to obtain via library functions after pivot_root, and
|
||||
implementations are expected to avoid changing the state of the mount
|
||||
namespace */
|
||||
for i, op := range *params.Ops {
|
||||
for i, op := range *param.Ops {
|
||||
if op == nil || !op.Valid() {
|
||||
k.fatalf(msg, "invalid op at index %d", i)
|
||||
}
|
||||
@@ -267,7 +271,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
step sets up the container filesystem, and implementations are expected to
|
||||
keep the host root and sysroot mount points intact but otherwise can do
|
||||
whatever they need to. Calling chdir is allowed but discouraged. */
|
||||
for i, op := range *params.Ops {
|
||||
for i, op := range *param.Ops {
|
||||
// ops already checked during early setup
|
||||
if prefix, ok := op.prefix(); ok {
|
||||
msg.Verbosef("%s %s", prefix, op)
|
||||
@@ -323,7 +327,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
k.fatalf(msg, "cannot clear the ambient capability set: %v", err)
|
||||
}
|
||||
for i := uintptr(0); i <= lastcap; i++ {
|
||||
if params.Privileged && i == CAP_SYS_ADMIN {
|
||||
if param.Privileged && i == CAP_SYS_ADMIN {
|
||||
continue
|
||||
}
|
||||
if err := k.capBoundingSetDrop(i); err != nil {
|
||||
@@ -332,7 +336,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
}
|
||||
|
||||
var keep [2]uint32
|
||||
if params.Privileged {
|
||||
if param.Privileged {
|
||||
keep[capToIndex(CAP_SYS_ADMIN)] |= capToMask(CAP_SYS_ADMIN)
|
||||
|
||||
if err := k.capAmbientRaise(CAP_SYS_ADMIN); err != nil {
|
||||
@@ -346,13 +350,13 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
k.fatalf(msg, "cannot capset: %v", err)
|
||||
}
|
||||
|
||||
if !params.SeccompDisable {
|
||||
rules := params.SeccompRules
|
||||
if !param.SeccompDisable {
|
||||
rules := param.SeccompRules
|
||||
if len(rules) == 0 { // non-empty rules slice always overrides presets
|
||||
msg.Verbosef("resolving presets %#x", params.SeccompPresets)
|
||||
rules = seccomp.Preset(params.SeccompPresets, params.SeccompFlags)
|
||||
msg.Verbosef("resolving presets %#x", param.SeccompPresets)
|
||||
rules = seccomp.Preset(param.SeccompPresets, param.SeccompFlags)
|
||||
}
|
||||
if err := k.seccompLoad(rules, params.SeccompFlags); err != nil {
|
||||
if err := k.seccompLoad(rules, param.SeccompFlags); err != nil {
|
||||
// this also indirectly asserts PR_SET_NO_NEW_PRIVS
|
||||
k.fatalf(msg, "cannot load syscall filter: %v", err)
|
||||
}
|
||||
@@ -361,10 +365,10 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
msg.Verbose("syscall filter not configured")
|
||||
}
|
||||
|
||||
extraFiles := make([]*os.File, params.Count)
|
||||
extraFiles := make([]*os.File, param.Count)
|
||||
for i := range extraFiles {
|
||||
// setup fd is placed before all extra files
|
||||
extraFiles[i] = k.newFile(uintptr(offsetSetup+i), "extra file "+strconv.Itoa(i))
|
||||
extraFiles[i] = k.newFile(uintptr(setupFd+1+i), "extra file "+strconv.Itoa(i))
|
||||
}
|
||||
k.umask(oldmask)
|
||||
|
||||
@@ -442,7 +446,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
|
||||
// called right before startup of initial process, all state changes to the
|
||||
// current process is prohibited during late
|
||||
for i, op := range *params.Ops {
|
||||
for i, op := range *param.Ops {
|
||||
// ops already checked during early setup
|
||||
if err := op.late(state, k); err != nil {
|
||||
if m, ok := messageFromError(err); ok {
|
||||
@@ -463,14 +467,14 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
k.fatalf(msg, "cannot close setup pipe: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(params.Path.String())
|
||||
cmd := exec.Command(param.Path.String())
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
cmd.Args = params.Args
|
||||
cmd.Env = params.Env
|
||||
cmd.Args = param.Args
|
||||
cmd.Env = param.Env
|
||||
cmd.ExtraFiles = extraFiles
|
||||
cmd.Dir = params.Dir.String()
|
||||
cmd.Dir = param.Dir.String()
|
||||
|
||||
msg.Verbosef("starting initial process %s", params.Path)
|
||||
msg.Verbosef("starting initial process %s", param.Path)
|
||||
if err := k.start(cmd); err != nil {
|
||||
k.fatalf(msg, "%v", err)
|
||||
}
|
||||
@@ -488,9 +492,9 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
for {
|
||||
select {
|
||||
case s := <-sig:
|
||||
if s == CancelSignal && params.ForwardCancel && cmd.Process != nil {
|
||||
if s == CancelSignal && param.ForwardCancel && cmd.Process != nil {
|
||||
msg.Verbose("forwarding context cancellation")
|
||||
if err := k.signal(cmd, os.Interrupt); err != nil {
|
||||
if err := k.signal(cmd, os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
k.printf(msg, "cannot forward cancellation: %v", err)
|
||||
}
|
||||
continue
|
||||
@@ -520,7 +524,7 @@ func initEntrypoint(k syscallDispatcher, msg message.Msg) {
|
||||
cancel()
|
||||
|
||||
// start timeout early
|
||||
go func() { time.Sleep(params.AdoptWaitDelay); close(timeout) }()
|
||||
go func() { time.Sleep(param.AdoptWaitDelay); close(timeout) }()
|
||||
|
||||
// close initial process files; this also keeps them alive
|
||||
for _, f := range extraFiles {
|
||||
@@ -564,7 +568,7 @@ func TryArgv0(msg message.Msg) {
|
||||
msg = message.New(log.Default())
|
||||
}
|
||||
|
||||
if len(os.Args) > 0 && path.Base(os.Args[0]) == initName {
|
||||
if len(os.Args) > 0 && filepath.Base(os.Args[0]) == initName {
|
||||
Init(msg)
|
||||
msg.BeforeExit()
|
||||
os.Exit(0)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container/seccomp"
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/internal/params"
|
||||
"hakurei.app/internal/stub"
|
||||
)
|
||||
|
||||
@@ -40,7 +41,7 @@ func TestInitEntrypoint(t *testing.T) {
|
||||
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
|
||||
call("getpid", stub.ExpectArgs{}, 1, nil),
|
||||
call("setPtracer", stub.ExpectArgs{uintptr(0)}, nil, nil),
|
||||
call("receive", stub.ExpectArgs{"HAKUREI_SETUP", new(initParams), new(uintptr)}, nil, ErrReceiveEnv),
|
||||
call("receive", stub.ExpectArgs{"HAKUREI_SETUP", new(initParams), new(uintptr)}, nil, params.ErrReceiveEnv),
|
||||
call("fatal", stub.ExpectArgs{[]any{"HAKUREI_SETUP not set"}}, nil, nil),
|
||||
},
|
||||
}, nil},
|
||||
|
||||
@@ -3,7 +3,7 @@ package container
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
. "syscall"
|
||||
|
||||
"hakurei.app/check"
|
||||
@@ -46,7 +46,7 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
|
||||
}
|
||||
|
||||
for _, name := range []string{"null", "zero", "full", "random", "urandom", "tty"} {
|
||||
targetPath := path.Join(target, name)
|
||||
targetPath := filepath.Join(target, name)
|
||||
if err := k.ensureFile(targetPath, 0444, state.ParentPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
|
||||
for i, name := range []string{"stdin", "stdout", "stderr"} {
|
||||
if err := k.symlink(
|
||||
fhs.Proc+"self/fd/"+string(rune(i+'0')),
|
||||
path.Join(target, name),
|
||||
filepath.Join(target, name),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -72,13 +72,13 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
|
||||
{fhs.Proc + "kcore", "core"},
|
||||
{"pts/ptmx", "ptmx"},
|
||||
} {
|
||||
if err := k.symlink(pair[0], path.Join(target, pair[1])); err != nil {
|
||||
if err := k.symlink(pair[0], filepath.Join(target, pair[1])); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
devShmPath := path.Join(target, "shm")
|
||||
devPtsPath := path.Join(target, "pts")
|
||||
devShmPath := filepath.Join(target, "shm")
|
||||
devPtsPath := filepath.Join(target, "pts")
|
||||
for _, name := range []string{devShmPath, devPtsPath} {
|
||||
if err := k.mkdir(name, state.ParentPerm); err != nil {
|
||||
return err
|
||||
@@ -92,7 +92,7 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
|
||||
|
||||
if state.RetainSession {
|
||||
if k.isatty(Stdout) {
|
||||
consolePath := path.Join(target, "console")
|
||||
consolePath := filepath.Join(target, "console")
|
||||
if err := k.ensureFile(consolePath, 0444, state.ParentPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (d *MountDevOp) apply(state *setupState, k syscallDispatcher) error {
|
||||
}
|
||||
|
||||
if d.Mqueue {
|
||||
mqueueTarget := path.Join(target, "mqueue")
|
||||
mqueueTarget := filepath.Join(target, "mqueue")
|
||||
if err := k.mkdir(mqueueTarget, state.ParentPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package container
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"hakurei.app/check"
|
||||
)
|
||||
@@ -30,7 +30,7 @@ func (l *SymlinkOp) Valid() bool { return l != nil && l.Target != nil && l.LinkN
|
||||
|
||||
func (l *SymlinkOp) early(_ *setupState, k syscallDispatcher) error {
|
||||
if l.Dereference {
|
||||
if !path.IsAbs(l.LinkName) {
|
||||
if !filepath.IsAbs(l.LinkName) {
|
||||
return check.AbsoluteError(l.LinkName)
|
||||
}
|
||||
if name, err := k.readlink(l.LinkName); err != nil {
|
||||
@@ -44,7 +44,7 @@ func (l *SymlinkOp) early(_ *setupState, k syscallDispatcher) error {
|
||||
|
||||
func (l *SymlinkOp) apply(state *setupState, k syscallDispatcher) error {
|
||||
target := toSysroot(l.Target.String())
|
||||
if err := k.mkdirAll(path.Dir(target), state.ParentPerm); err != nil {
|
||||
if err := k.mkdirAll(filepath.Dir(target), state.ParentPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return k.symlink(l.LinkName, target)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Setup appends the read end of a pipe for setup params transmission and returns its fd.
|
||||
func Setup(extraFiles *[]*os.File) (int, *os.File, error) {
|
||||
if r, w, err := os.Pipe(); err != nil {
|
||||
return -1, nil, err
|
||||
} else {
|
||||
fd := 3 + len(*extraFiles)
|
||||
*extraFiles = append(*extraFiles, r)
|
||||
return fd, w, nil
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrReceiveEnv = errors.New("environment variable not set")
|
||||
)
|
||||
|
||||
// Receive retrieves setup fd from the environment and receives params.
|
||||
func Receive(key string, e any, fdp *uintptr) (func() error, error) {
|
||||
var setup *os.File
|
||||
|
||||
if s, ok := os.LookupEnv(key); !ok {
|
||||
return nil, ErrReceiveEnv
|
||||
} else {
|
||||
if fd, err := strconv.Atoi(s); err != nil {
|
||||
return nil, optionalErrorUnwrap(err)
|
||||
} else {
|
||||
setup = os.NewFile(uintptr(fd), "setup")
|
||||
if setup == nil {
|
||||
return nil, syscall.EDOM
|
||||
}
|
||||
if fdp != nil {
|
||||
*fdp = setup.Fd()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return setup.Close, gob.NewDecoder(setup).Decode(e)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -29,16 +29,16 @@ const (
|
||||
|
||||
func toSysroot(name string) string {
|
||||
name = strings.TrimLeftFunc(name, func(r rune) bool { return r == '/' })
|
||||
return path.Join(sysrootPath, name)
|
||||
return filepath.Join(sysrootPath, name)
|
||||
}
|
||||
|
||||
func toHost(name string) string {
|
||||
name = strings.TrimLeftFunc(name, func(r rune) bool { return r == '/' })
|
||||
return path.Join(hostPath, name)
|
||||
return filepath.Join(hostPath, name)
|
||||
}
|
||||
|
||||
func createFile(name string, perm, pperm os.FileMode, content []byte) error {
|
||||
if err := os.MkdirAll(path.Dir(name), pperm); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(name), pperm); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(name, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, perm)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"syscall"
|
||||
"testing"
|
||||
@@ -61,7 +61,7 @@ func TestCreateFile(t *testing.T) {
|
||||
Path: "/proc/nonexistent",
|
||||
Err: syscall.ENOENT,
|
||||
}
|
||||
if err := createFile(path.Join(Nonexistent, ":3"), 0644, 0755, nil); !reflect.DeepEqual(err, wantErr) {
|
||||
if err := createFile(filepath.Join(Nonexistent, ":3"), 0644, 0755, nil); !reflect.DeepEqual(err, wantErr) {
|
||||
t.Errorf("createFile: error = %#v, want %#v", err, wantErr)
|
||||
}
|
||||
})
|
||||
@@ -72,7 +72,7 @@ func TestCreateFile(t *testing.T) {
|
||||
Path: "/proc/nonexistent",
|
||||
Err: syscall.ENOENT,
|
||||
}
|
||||
if err := createFile(path.Join(Nonexistent), 0644, 0755, nil); !reflect.DeepEqual(err, wantErr) {
|
||||
if err := createFile(filepath.Join(Nonexistent), 0644, 0755, nil); !reflect.DeepEqual(err, wantErr) {
|
||||
t.Errorf("createFile: error = %#v, want %#v", err, wantErr)
|
||||
}
|
||||
})
|
||||
@@ -80,7 +80,7 @@ func TestCreateFile(t *testing.T) {
|
||||
|
||||
t.Run("touch", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
pathname := path.Join(tempDir, "empty")
|
||||
pathname := filepath.Join(tempDir, "empty")
|
||||
if err := createFile(pathname, 0644, 0755, nil); err != nil {
|
||||
t.Fatalf("createFile: error = %v", err)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestCreateFile(t *testing.T) {
|
||||
|
||||
t.Run("write", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
pathname := path.Join(tempDir, "zero")
|
||||
pathname := filepath.Join(tempDir, "zero")
|
||||
if err := createFile(pathname, 0644, 0755, []byte{0}); err != nil {
|
||||
t.Fatalf("createFile: error = %v", err)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func TestCreateFile(t *testing.T) {
|
||||
|
||||
func TestEnsureFile(t *testing.T) {
|
||||
t.Run("create", func(t *testing.T) {
|
||||
if err := ensureFile(path.Join(t.TempDir(), "ensure"), 0644, 0755); err != nil {
|
||||
if err := ensureFile(filepath.Join(t.TempDir(), "ensure"), 0644, 0755); err != nil {
|
||||
t.Errorf("ensureFile: error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -115,7 +115,7 @@ func TestEnsureFile(t *testing.T) {
|
||||
t.Run("stat", func(t *testing.T) {
|
||||
t.Run("inaccessible", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
pathname := path.Join(tempDir, "inaccessible")
|
||||
pathname := filepath.Join(tempDir, "inaccessible")
|
||||
if f, err := os.Create(pathname); err != nil {
|
||||
t.Fatalf("Create: error = %v", err)
|
||||
} else {
|
||||
@@ -150,7 +150,7 @@ func TestEnsureFile(t *testing.T) {
|
||||
|
||||
t.Run("ensure", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
pathname := path.Join(tempDir, "ensure")
|
||||
pathname := filepath.Join(tempDir, "ensure")
|
||||
if f, err := os.Create(pathname); err != nil {
|
||||
t.Fatalf("Create: error = %v", err)
|
||||
} else {
|
||||
@@ -195,12 +195,12 @@ func TestProcPaths(t *testing.T) {
|
||||
|
||||
t.Run("sample", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
if err := os.MkdirAll(path.Join(tempDir, "proc/self"), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(tempDir, "proc/self"), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll: error = %v", err)
|
||||
}
|
||||
|
||||
t.Run("clean", func(t *testing.T) {
|
||||
if err := os.WriteFile(path.Join(tempDir, "proc/self/mountinfo"), []byte(`15 20 0:3 / /proc rw,relatime - proc /proc rw
|
||||
if err := os.WriteFile(filepath.Join(tempDir, "proc/self/mountinfo"), []byte(`15 20 0:3 / /proc rw,relatime - proc /proc rw
|
||||
16 20 0:15 / /sys rw,relatime - sysfs /sys rw
|
||||
17 20 0:5 / /dev rw,relatime - devtmpfs udev rw,size=1983516k,nr_inodes=495879,mode=755`), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: error = %v", err)
|
||||
@@ -243,8 +243,8 @@ func TestProcPaths(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("malformed", func(t *testing.T) {
|
||||
path.Join(tempDir, "proc/self/mountinfo")
|
||||
if err := os.WriteFile(path.Join(tempDir, "proc/self/mountinfo"), []byte{0}, 0644); err != nil {
|
||||
filepath.Join(tempDir, "proc/self/mountinfo")
|
||||
if err := os.WriteFile(filepath.Join(tempDir, "proc/self/mountinfo"), []byte{0}, 0644); err != nil {
|
||||
t.Fatalf("WriteFile: error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
1
dist/hsurc.default
vendored
1
dist/hsurc.default
vendored
@@ -1 +0,0 @@
|
||||
1000 0
|
||||
12
dist/install.sh
vendored
12
dist/install.sh
vendored
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
cd "$(dirname -- "$0")" || exit 1
|
||||
|
||||
install -vDm0755 "bin/hakurei" "${DESTDIR}/usr/bin/hakurei"
|
||||
install -vDm0755 "bin/sharefs" "${DESTDIR}/usr/bin/sharefs"
|
||||
|
||||
install -vDm4511 "bin/hsu" "${DESTDIR}/usr/bin/hsu"
|
||||
if [ ! -f "${DESTDIR}/etc/hsurc" ]; then
|
||||
install -vDm0400 "hsurc.default" "${DESTDIR}/etc/hsurc"
|
||||
fi
|
||||
|
||||
install -vDm0644 "comp/_hakurei" "${DESTDIR}/usr/share/zsh/site-functions/_hakurei"
|
||||
31
dist/release.sh
vendored
31
dist/release.sh
vendored
@@ -1,31 +0,0 @@
|
||||
#!/bin/sh -e
|
||||
cd "$(dirname -- "$0")/.."
|
||||
VERSION="${HAKUREI_VERSION:-untagged}"
|
||||
pname="hakurei-${VERSION}-$(go env GOARCH)"
|
||||
out="${DESTDIR:-dist}/${pname}"
|
||||
|
||||
echo '# Preparing distribution files.'
|
||||
mkdir -p "${out}"
|
||||
cp -v "README.md" "dist/hsurc.default" "dist/install.sh" "${out}"
|
||||
cp -rv "dist/comp" "${out}"
|
||||
echo
|
||||
|
||||
echo '# Building hakurei.'
|
||||
go generate ./...
|
||||
go build -trimpath -v -o "${out}/bin/" -ldflags "-s -w
|
||||
-buildid= -linkmode external -extldflags=-static
|
||||
-X hakurei.app/internal/info.buildVersion=${VERSION}
|
||||
-X hakurei.app/internal/info.hakureiPath=/usr/bin/hakurei
|
||||
-X hakurei.app/internal/info.hsuPath=/usr/bin/hsu
|
||||
-X main.hakureiPath=/usr/bin/hakurei" ./...
|
||||
echo
|
||||
|
||||
echo '# Testing hakurei.'
|
||||
go test -ldflags='-buildid= -linkmode external -extldflags=-static' ./...
|
||||
echo
|
||||
|
||||
echo '# Creating distribution.'
|
||||
rm -f "${out}.tar.gz" && tar -C "${out}/.." -vczf "${out}.tar.gz" "${pname}"
|
||||
rm -rf "${out}"
|
||||
(cd "${out}/.." && sha512sum "${pname}.tar.gz" > "${pname}.tar.gz.sha512")
|
||||
echo
|
||||
@@ -137,11 +137,10 @@
|
||||
|
||||
CC="musl-clang -O3 -Werror -Qunused-arguments" \
|
||||
GOCACHE="$(mktemp -d)" \
|
||||
HAKUREI_TEST_SKIP_ACL=1 \
|
||||
PATH="${pkgs.pkgsStatic.musl.bin}/bin:$PATH" \
|
||||
DESTDIR="$out" \
|
||||
HAKUREI_VERSION="v${hakurei.version}" \
|
||||
./dist/release.sh
|
||||
./all.sh
|
||||
'';
|
||||
}
|
||||
);
|
||||
@@ -196,6 +195,7 @@
|
||||
./test/interactive/vm.nix
|
||||
./test/interactive/hakurei.nix
|
||||
./test/interactive/trace.nix
|
||||
./test/interactive/raceattr.nix
|
||||
|
||||
self.nixosModules.hakurei
|
||||
home-manager.nixosModules.home-manager
|
||||
|
||||
@@ -56,8 +56,10 @@ type Ops interface {
|
||||
|
||||
// ApplyState holds the address of [Ops] and any relevant application state.
|
||||
type ApplyState struct {
|
||||
// AutoEtcPrefix is the prefix for [FSBind] in autoetc [FSBind.Special] condition.
|
||||
// Prefix for [FSBind] in autoetc [FSBind.Special] condition.
|
||||
AutoEtcPrefix string
|
||||
// Whether to skip remounting root.
|
||||
NoRemountRoot bool
|
||||
|
||||
Ops
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package hst
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"hakurei.app/check"
|
||||
)
|
||||
@@ -28,7 +28,7 @@ func (l *FSLink) Valid() bool {
|
||||
if l == nil || l.Target == nil || l.Linkname == "" {
|
||||
return false
|
||||
}
|
||||
return !l.Dereference || path.IsAbs(l.Linkname)
|
||||
return !l.Dereference || filepath.IsAbs(l.Linkname)
|
||||
}
|
||||
|
||||
func (l *FSLink) Path() *check.Absolute {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/fhs"
|
||||
)
|
||||
|
||||
func init() { gob.Register(new(FSOverlay)) }
|
||||
@@ -69,9 +70,12 @@ func (o *FSOverlay) Apply(z *ApplyState) {
|
||||
return
|
||||
}
|
||||
|
||||
if o.Upper != nil && o.Work != nil { // rw
|
||||
if o.Upper != nil && o.Work != nil {
|
||||
z.Overlay(o.Target, o.Upper, o.Work, o.Lower...)
|
||||
} else { // ro
|
||||
if o.Target.Is(fhs.AbsRoot) {
|
||||
z.NoRemountRoot = true
|
||||
}
|
||||
} else {
|
||||
z.OverlayReadonly(o.Target, o.Lower...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,5 +49,18 @@ func TestFSOverlay(t *testing.T) {
|
||||
Lower: ms("/tmp/.src0", "/tmp/.src1"),
|
||||
}}, m("/mnt/src"), ms("/tmp/.src0", "/tmp/.src1"),
|
||||
"*/mnt/src:/tmp/.src0:/tmp/.src1"},
|
||||
|
||||
{"no remount root", &hst.FSOverlay{
|
||||
Target: m("/"),
|
||||
Lower: ms("/tmp/.src0", "/tmp/.src1"),
|
||||
Upper: m("/tmp/upper"),
|
||||
Work: m("/tmp/work"),
|
||||
}, true, container.Ops{&container.MountOverlayOp{
|
||||
Target: m("/"),
|
||||
Lower: ms("/tmp/.src0", "/tmp/.src1"),
|
||||
Upper: m("/tmp/upper"),
|
||||
Work: m("/tmp/work"),
|
||||
}}, m("/"), ms("/tmp/upper", "/tmp/work", "/tmp/.src0", "/tmp/.src1"),
|
||||
"w*/:/tmp/upper:/tmp/work:/tmp/.src0:/tmp/.src1"},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/internal/acl"
|
||||
"hakurei.app/internal/info"
|
||||
)
|
||||
|
||||
const testFileName = "acl.test"
|
||||
@@ -24,11 +26,17 @@ var (
|
||||
)
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
if os.Getenv("HAKUREI_TEST_SKIP_ACL") == "1" {
|
||||
t.Skip("acl test skipped")
|
||||
if info.CanDegrade {
|
||||
name := filepath.Join(t.TempDir(), "check-degrade")
|
||||
if err := os.WriteFile(name, nil, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := acl.Update(name, os.Geteuid()); errors.Is(err, syscall.ENOTSUP) {
|
||||
t.Skip(err)
|
||||
}
|
||||
}
|
||||
|
||||
testFilePath := path.Join(t.TempDir(), testFileName)
|
||||
testFilePath := filepath.Join(t.TempDir(), testFileName)
|
||||
|
||||
if f, err := os.Create(testFilePath); err != nil {
|
||||
t.Fatalf("Create: error = %v", err)
|
||||
|
||||
7
internal/info/optional_skip.go
Normal file
7
internal/info/optional_skip.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !noskip
|
||||
|
||||
package info
|
||||
|
||||
// CanDegrade is whether tests are allowed to transparently degrade or skip due
|
||||
// to required system features being denied or unavailable.
|
||||
const CanDegrade = true
|
||||
5
internal/info/optional_strict.go
Normal file
5
internal/info/optional_strict.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build noskip
|
||||
|
||||
package info
|
||||
|
||||
const CanDegrade = false
|
||||
90
internal/kobject/event.go
Normal file
90
internal/kobject/event.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package kobject
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"hakurei.app/internal/uevent"
|
||||
)
|
||||
|
||||
// Event is a [uevent.Message] with known environment variables processed.
|
||||
type Event struct {
|
||||
// alloc_uevent_skb: action_string
|
||||
Action uevent.KobjectAction `json:"action"`
|
||||
// alloc_uevent_skb: devpath
|
||||
DevPath string `json:"devpath"`
|
||||
|
||||
// Uninterpreted environment variable pairs. An entry missing a separator
|
||||
// gains the value "\x00".
|
||||
Env map[string]string `json:"env"`
|
||||
|
||||
// SEQNUM value set by the kernel.
|
||||
Sequence uint64 `json:"seqnum"`
|
||||
// SYNTH_UUID value set on trigger, nil denotes a non-synthetic event.
|
||||
Synth *uevent.UUID `json:"synth_uuid,omitempty"`
|
||||
// SUBSYSTEM value set by the kernel.
|
||||
Subsystem string `json:"subsystem"`
|
||||
}
|
||||
|
||||
// Populate populates e with the contents of a [uevent.Message].
|
||||
//
|
||||
// The ACTION and DEVPATH environment variables are ignored and assumed to be
|
||||
// consistent with the header.
|
||||
func (e *Event) Populate(reportErr func(error), m *uevent.Message) {
|
||||
if reportErr == nil {
|
||||
reportErr = func(error) {}
|
||||
}
|
||||
|
||||
*e = Event{
|
||||
Action: m.Action,
|
||||
DevPath: m.DevPath,
|
||||
Env: make(map[string]string),
|
||||
}
|
||||
|
||||
for _, s := range m.Env {
|
||||
k, v, ok := strings.Cut(s, "=")
|
||||
if !ok {
|
||||
if _, ok = e.Env[s]; !ok {
|
||||
e.Env[s] = "\x00"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch k {
|
||||
case "ACTION", "DEVPATH":
|
||||
continue
|
||||
|
||||
case "SEQNUM":
|
||||
seq, err := strconv.ParseUint(v, 10, 64)
|
||||
if err != nil {
|
||||
if _e := errors.Unwrap(err); _e != nil {
|
||||
err = _e
|
||||
}
|
||||
reportErr(err)
|
||||
|
||||
e.Env[k] = v
|
||||
continue
|
||||
}
|
||||
e.Sequence = seq
|
||||
|
||||
case "SYNTH_UUID":
|
||||
var uuid uevent.UUID
|
||||
err := uuid.UnmarshalText(unsafe.Slice(unsafe.StringData(v), len(v)))
|
||||
if err != nil {
|
||||
reportErr(err)
|
||||
|
||||
e.Env[k] = v
|
||||
continue
|
||||
}
|
||||
e.Synth = &uuid
|
||||
|
||||
case "SUBSYSTEM":
|
||||
e.Subsystem = v
|
||||
|
||||
default:
|
||||
e.Env[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
92
internal/kobject/event_test.go
Normal file
92
internal/kobject/event_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package kobject_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/internal/kobject"
|
||||
"hakurei.app/internal/uevent"
|
||||
)
|
||||
|
||||
func TestEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
msg uevent.Message
|
||||
want kobject.Event
|
||||
errs []error
|
||||
}{
|
||||
{"sample coldboot qemu", uevent.Message{
|
||||
Action: uevent.KOBJ_ADD,
|
||||
DevPath: "/devices/LNXSYSTM:00/LNXPWRBN:00",
|
||||
Env: []string{
|
||||
"ACTION=add",
|
||||
"DEVPATH=/devices/LNXSYSTM:00/LNXPWRBN:00",
|
||||
"SUBSYSTEM=acpi",
|
||||
"SYNTH_UUID=fe4d7c9d-b8c6-4a70-9ef1-3d8a58d18eed",
|
||||
"MODALIAS=acpi:LNXPWRBN:",
|
||||
"SEQNUM=777",
|
||||
}}, kobject.Event{
|
||||
Action: uevent.KOBJ_ADD,
|
||||
DevPath: "/devices/LNXSYSTM:00/LNXPWRBN:00",
|
||||
Env: map[string]string{
|
||||
"MODALIAS": "acpi:LNXPWRBN:",
|
||||
},
|
||||
Sequence: 777,
|
||||
Synth: &uevent.UUID{
|
||||
0xfe, 0x4d, 0x7c, 0x9d,
|
||||
0xb8, 0xc6,
|
||||
0x4a, 0x70,
|
||||
0x9e, 0xf1,
|
||||
0x3d, 0x8a, 0x58, 0xd1, 0x8e, 0xed,
|
||||
},
|
||||
Subsystem: "acpi",
|
||||
}, []error{}},
|
||||
|
||||
{"nil reportErr", uevent.Message{Env: []string{
|
||||
"SEQNUM=\x00",
|
||||
}}, kobject.Event{Env: map[string]string{
|
||||
"SEQNUM": "\x00",
|
||||
}}, nil},
|
||||
|
||||
{"bad SEQNUM SYNTH_UUID", uevent.Message{Env: []string{
|
||||
"SEQNUM=\x00",
|
||||
"SYNTH_UUID=\x00",
|
||||
"SUBSYSTEM=\x00",
|
||||
}}, kobject.Event{Subsystem: "\x00", Env: map[string]string{
|
||||
"SEQNUM": "\x00",
|
||||
"SYNTH_UUID": "\x00",
|
||||
}}, []error{strconv.ErrSyntax, uevent.UUIDSizeError(1)}},
|
||||
|
||||
{"bad sep", uevent.Message{Env: []string{
|
||||
"SYNTH_UUID",
|
||||
}}, kobject.Event{Env: map[string]string{
|
||||
"SYNTH_UUID": "\x00",
|
||||
}}, []error{}},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var f func(error)
|
||||
gotErrs := make([]error, 0)
|
||||
if tc.errs != nil {
|
||||
f = func(err error) {
|
||||
gotErrs = append(gotErrs, err)
|
||||
}
|
||||
}
|
||||
|
||||
var got kobject.Event
|
||||
got.Populate(f, &tc.msg)
|
||||
|
||||
if !reflect.DeepEqual(&got, &tc.want) {
|
||||
t.Errorf("Populate: %#v, want %#v", got, tc.want)
|
||||
}
|
||||
if tc.errs != nil && !reflect.DeepEqual(gotErrs, tc.errs) {
|
||||
t.Errorf("Populate: errs = %v, want %v", gotErrs, tc.errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,32 @@
|
||||
package netlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// AF_NETLINK socket is never shared
|
||||
var (
|
||||
nlPid uint32
|
||||
nlPidOnce sync.Once
|
||||
// net/netlink/af_netlink.c
|
||||
const maxRecvmsgLen = 32768
|
||||
|
||||
const (
|
||||
// stateOpen denotes an open conn.
|
||||
stateOpen uint32 = 1 << iota
|
||||
)
|
||||
|
||||
// getpid returns a cached pid value.
|
||||
func getpid() uint32 {
|
||||
nlPidOnce.Do(func() { nlPid = uint32(os.Getpid()) })
|
||||
return nlPid
|
||||
}
|
||||
|
||||
// A conn represents resources associated to a netlink socket.
|
||||
type conn struct {
|
||||
// A Conn represents resources associated to a netlink socket.
|
||||
type Conn struct {
|
||||
// AF_NETLINK socket.
|
||||
fd int
|
||||
f *os.File
|
||||
// For using runtime polling via f.
|
||||
raw syscall.RawConn
|
||||
// Port ID assigned by the kernel.
|
||||
port uint32
|
||||
// Internal connection status.
|
||||
state uint32
|
||||
// Kernel module or netlink group to communicate with.
|
||||
family int
|
||||
// Message sequence number.
|
||||
@@ -33,40 +36,193 @@ type conn struct {
|
||||
typ, flags uint16
|
||||
// Outgoing position in buf.
|
||||
pos int
|
||||
// A page holding incoming and outgoing messages.
|
||||
buf []byte
|
||||
// Pages holding incoming and outgoing messages.
|
||||
buf [maxRecvmsgLen]byte
|
||||
// An instant some time after conn was established, but before the first
|
||||
// I/O operation on f through raw. This serves as a cached deadline to
|
||||
// cancel blocking I/O.
|
||||
t time.Time
|
||||
}
|
||||
|
||||
// dial returns the address of a newly connected conn of specified family.
|
||||
func dial(family int) (*conn, error) {
|
||||
var c conn
|
||||
// Dial returns the address of a newly connected generic netlink connection of
|
||||
// specified family and groups.
|
||||
//
|
||||
// For a nonzero rcvbuf, the socket receive buffer size is set to its absolute
|
||||
// value via SO_RCVBUF for a positive value, or SO_RCVBUFFORCE for a negative
|
||||
// value.
|
||||
func Dial(family int, groups uint32, rcvbuf int64) (*Conn, error) {
|
||||
var c Conn
|
||||
if fd, err := syscall.Socket(
|
||||
syscall.AF_NETLINK,
|
||||
syscall.SOCK_RAW|syscall.SOCK_CLOEXEC,
|
||||
syscall.SOCK_RAW|syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC,
|
||||
family,
|
||||
); err != nil {
|
||||
return nil, os.NewSyscallError("socket", err)
|
||||
} else if err = syscall.Bind(fd, &syscall.SockaddrNetlink{
|
||||
Family: syscall.AF_NETLINK,
|
||||
Pid: getpid(),
|
||||
Groups: groups,
|
||||
}); err != nil {
|
||||
_ = syscall.Close(fd)
|
||||
return nil, os.NewSyscallError("bind", err)
|
||||
} else {
|
||||
c.fd, c.family = fd, family
|
||||
var addr syscall.Sockaddr
|
||||
if addr, err = syscall.Getsockname(fd); err != nil {
|
||||
_ = syscall.Close(fd)
|
||||
return nil, os.NewSyscallError("getsockname", err)
|
||||
}
|
||||
switch a := addr.(type) {
|
||||
case *syscall.SockaddrNetlink:
|
||||
c.port = a.Pid
|
||||
|
||||
default: // unreachable
|
||||
_ = syscall.Close(fd)
|
||||
return nil, syscall.ENOTRECOVERABLE
|
||||
}
|
||||
|
||||
if rcvbuf != 0 {
|
||||
opt := syscall.SO_RCVBUF
|
||||
if rcvbuf < 0 {
|
||||
opt = syscall.SO_RCVBUFFORCE
|
||||
rcvbuf = -rcvbuf
|
||||
}
|
||||
if err = syscall.SetsockoptInt(
|
||||
fd,
|
||||
syscall.SOL_SOCKET,
|
||||
opt,
|
||||
int(rcvbuf),
|
||||
); err != nil {
|
||||
_ = syscall.Close(fd)
|
||||
return nil, os.NewSyscallError("setsockopt", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.family = family
|
||||
c.f = os.NewFile(uintptr(fd), "netlink")
|
||||
if c.raw, err = c.f.SyscallConn(); err != nil {
|
||||
_ = c.f.Close()
|
||||
return nil, err
|
||||
}
|
||||
c.state |= stateOpen
|
||||
}
|
||||
|
||||
c.pos = syscall.NLMSG_HDRLEN
|
||||
c.buf = make([]byte, os.Getpagesize())
|
||||
c.t = time.Now().UTC()
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ok returns whether conn is still open.
|
||||
func (c *Conn) ok() bool { return c.state&stateOpen != 0 }
|
||||
|
||||
// Close closes the underlying socket.
|
||||
func (c *conn) Close() error {
|
||||
if c.buf == nil {
|
||||
func (c *Conn) Close() error {
|
||||
if !c.ok() {
|
||||
return syscall.EINVAL
|
||||
}
|
||||
c.buf = nil
|
||||
return syscall.Close(c.fd)
|
||||
c.state &= ^stateOpen
|
||||
return c.f.Close()
|
||||
}
|
||||
|
||||
// Recvmsg wraps recv(2) with nonblocking behaviour via the runtime network poller.
|
||||
//
|
||||
// The returned slice is valid until the next call to Recvmsg.
|
||||
func (c *Conn) Recvmsg(
|
||||
ctx context.Context,
|
||||
flags int,
|
||||
) (data []byte, recvflags int, from syscall.Sockaddr, err error) {
|
||||
if err = c.f.SetReadDeadline(time.Time{}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var n int
|
||||
data = c.buf[:]
|
||||
|
||||
if ctx == nil {
|
||||
rcErr := c.raw.Control(func(fd uintptr) {
|
||||
n, _, recvflags, from, err = syscall.Recvmsg(int(fd), data, nil, flags)
|
||||
})
|
||||
if n >= 0 {
|
||||
data = data[:n]
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
err = os.NewSyscallError("recvmsg", err)
|
||||
} else {
|
||||
err = rcErr
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
rcErr := c.raw.Read(func(fd uintptr) (done bool) {
|
||||
n, _, recvflags, from, err = syscall.Recvmsg(int(fd), data, nil, flags)
|
||||
return err != syscall.EWOULDBLOCK
|
||||
})
|
||||
if n >= 0 {
|
||||
data = data[:n]
|
||||
}
|
||||
done <- rcErr
|
||||
}()
|
||||
|
||||
select {
|
||||
case rcErr := <-done:
|
||||
if err != nil {
|
||||
err = os.NewSyscallError("recvmsg", err)
|
||||
} else {
|
||||
err = rcErr
|
||||
}
|
||||
return
|
||||
|
||||
case <-ctx.Done():
|
||||
cancelErr := c.f.SetReadDeadline(c.t)
|
||||
<-done
|
||||
if cancelErr != nil {
|
||||
err = cancelErr
|
||||
} else {
|
||||
err = ctx.Err()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Sendmsg wraps send(2) with nonblocking behaviour via the runtime network poller.
|
||||
func (c *Conn) Sendmsg(
|
||||
ctx context.Context,
|
||||
p []byte,
|
||||
to syscall.Sockaddr,
|
||||
flags int,
|
||||
) (err error) {
|
||||
if err = c.f.SetWriteDeadline(time.Time{}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- c.raw.Write(func(fd uintptr) (done bool) {
|
||||
err = syscall.Sendmsg(int(fd), p, nil, to, flags)
|
||||
return err != syscall.EWOULDBLOCK
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case rcErr := <-done:
|
||||
if err != nil {
|
||||
err = os.NewSyscallError("sendmsg", err)
|
||||
} else {
|
||||
err = rcErr
|
||||
}
|
||||
return
|
||||
|
||||
case <-ctx.Done():
|
||||
cancelErr := c.f.SetWriteDeadline(c.t)
|
||||
<-done
|
||||
if cancelErr != nil {
|
||||
err = cancelErr
|
||||
} else {
|
||||
err = ctx.Err()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Msg is type constraint for types sent over the wire via netlink.
|
||||
@@ -88,7 +244,7 @@ func As[M Msg](data []byte) *M {
|
||||
}
|
||||
|
||||
// add queues a value to be sent by conn.
|
||||
func add[M Msg](c *conn, p *M) bool {
|
||||
func add[M Msg](c *Conn, p *M) bool {
|
||||
pos := c.pos
|
||||
c.pos += int(unsafe.Sizeof(*p))
|
||||
if c.pos > len(c.buf) {
|
||||
@@ -122,8 +278,16 @@ func (e *InconsistentError) Error() string {
|
||||
return s
|
||||
}
|
||||
|
||||
// checkReply checks the message header of a reply from the kernel.
|
||||
func (c *Conn) checkReply(header *syscall.NlMsghdr) error {
|
||||
if header.Seq != c.seq || header.Pid != c.port {
|
||||
return &InconsistentError{*header, c.seq, c.port}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pending returns the valid slice of buf and initialises pos.
|
||||
func (c *conn) pending() []byte {
|
||||
func (c *Conn) pending() []byte {
|
||||
buf := c.buf[:c.pos]
|
||||
c.pos = syscall.NLMSG_HDRLEN
|
||||
|
||||
@@ -132,7 +296,7 @@ func (c *conn) pending() []byte {
|
||||
Type: c.typ,
|
||||
Flags: c.flags,
|
||||
Seq: c.seq,
|
||||
Pid: getpid(),
|
||||
Pid: c.port,
|
||||
}
|
||||
return buf
|
||||
}
|
||||
@@ -143,44 +307,44 @@ type Complete struct{}
|
||||
// Error returns a hardcoded string that should never be displayed to the user.
|
||||
func (Complete) Error() string { return "returning from roundtrip" }
|
||||
|
||||
// HandlerFunc handles [syscall.NetlinkMessage] and returns a non-nil error to
|
||||
// discontinue the receiving of more messages.
|
||||
type HandlerFunc func(resp []syscall.NetlinkMessage) error
|
||||
|
||||
// receive receives from a socket with specified flags until a non-nil error is
|
||||
// returned by f. An error of type [Complete] is returned as nil.
|
||||
func (c *Conn) receive(ctx context.Context, f HandlerFunc, flags int) error {
|
||||
for {
|
||||
var resp []syscall.NetlinkMessage
|
||||
if data, _, _, err := c.Recvmsg(ctx, flags); err != nil {
|
||||
return err
|
||||
} else if len(data) < syscall.NLMSG_HDRLEN {
|
||||
return syscall.EBADE
|
||||
} else if resp, err = syscall.ParseNetlinkMessage(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := f(resp); err != nil {
|
||||
if err == (Complete{}) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Roundtrip sends the pending message and handles the reply.
|
||||
func (c *conn) Roundtrip(f func(msg *syscall.NetlinkMessage) error) error {
|
||||
if c.buf == nil {
|
||||
func (c *Conn) Roundtrip(ctx context.Context, f HandlerFunc) error {
|
||||
if !c.ok() {
|
||||
return syscall.EINVAL
|
||||
}
|
||||
defer func() { c.seq++ }()
|
||||
|
||||
if err := syscall.Sendto(c.fd, c.pending(), 0, &syscall.SockaddrNetlink{
|
||||
if err := c.Sendmsg(ctx, c.pending(), &syscall.SockaddrNetlink{
|
||||
Family: syscall.AF_NETLINK,
|
||||
}); err != nil {
|
||||
return os.NewSyscallError("sendto", err)
|
||||
}, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
buf := c.buf
|
||||
if n, _, err := syscall.Recvfrom(c.fd, buf, 0); err != nil {
|
||||
return os.NewSyscallError("recvfrom", err)
|
||||
} else if n < syscall.NLMSG_HDRLEN {
|
||||
return syscall.EBADE
|
||||
} else {
|
||||
buf = buf[:n]
|
||||
}
|
||||
|
||||
msgs, err := syscall.ParseNetlinkMessage(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, msg := range msgs {
|
||||
if msg.Header.Seq != c.seq || msg.Header.Pid != getpid() {
|
||||
return &InconsistentError{msg.Header, c.seq, getpid()}
|
||||
}
|
||||
if err = f(&msg); err != nil {
|
||||
if err == (Complete{}) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.receive(ctx, f, 0)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
package netlink
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func init() { nlPidOnce.Do(func() {}); nlPid = 1 }
|
||||
|
||||
type payloadTestCase struct {
|
||||
name string
|
||||
f func(c *conn)
|
||||
f func(c *Conn)
|
||||
want []byte
|
||||
}
|
||||
|
||||
@@ -22,11 +19,9 @@ func checkPayload(t *testing.T, testCases []payloadTestCase) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
c := conn{
|
||||
pos: syscall.NLMSG_HDRLEN,
|
||||
buf: make([]byte, os.Getpagesize()),
|
||||
}
|
||||
c := Conn{port: 1, pos: syscall.NLMSG_HDRLEN}
|
||||
tc.f(&c)
|
||||
if got := c.pending(); string(got) != string(tc.want) {
|
||||
t.Errorf("pending: %#v, want %#v", got, tc.want)
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package netlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// RouteConn represents a NETLINK_ROUTE socket.
|
||||
type RouteConn struct{ *conn }
|
||||
type RouteConn struct{ conn *Conn }
|
||||
|
||||
// Close closes the underlying socket.
|
||||
func (c *RouteConn) Close() error { return c.conn.Close() }
|
||||
|
||||
// DialRoute returns the address of a newly connected [RouteConn].
|
||||
func DialRoute() (*RouteConn, error) {
|
||||
c, err := dial(syscall.NETLINK_ROUTE)
|
||||
func DialRoute(rcvbuf int64) (*RouteConn, error) {
|
||||
c, err := Dial(syscall.NETLINK_ROUTE, 0, rcvbuf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -18,23 +22,27 @@ func DialRoute() (*RouteConn, error) {
|
||||
}
|
||||
|
||||
// rtnlConsume consumes a message from rtnetlink.
|
||||
func rtnlConsume(msg *syscall.NetlinkMessage) error {
|
||||
switch msg.Header.Type {
|
||||
case syscall.NLMSG_DONE:
|
||||
return Complete{}
|
||||
|
||||
case syscall.NLMSG_ERROR:
|
||||
if e := As[syscall.NlMsgerr](msg.Data); e != nil {
|
||||
if e.Error == 0 {
|
||||
return Complete{}
|
||||
}
|
||||
return syscall.Errno(-e.Error)
|
||||
func (c *RouteConn) rtnlConsume(resp []syscall.NetlinkMessage) error {
|
||||
for i := range resp {
|
||||
if err := c.conn.checkReply(&resp[i].Header); err != nil {
|
||||
return err
|
||||
}
|
||||
return syscall.EBADE
|
||||
|
||||
default:
|
||||
return nil
|
||||
switch resp[i].Header.Type {
|
||||
case syscall.NLMSG_DONE:
|
||||
return Complete{}
|
||||
|
||||
case syscall.NLMSG_ERROR:
|
||||
if e := As[syscall.NlMsgerr](resp[i].Data); e != nil {
|
||||
if e.Error == 0 {
|
||||
return Complete{}
|
||||
}
|
||||
return syscall.Errno(-e.Error)
|
||||
}
|
||||
return syscall.EBADE
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InAddr is equivalent to struct in_addr.
|
||||
@@ -57,7 +65,7 @@ func (c *RouteConn) writeIfAddrmsg(
|
||||
msg *syscall.IfAddrmsg,
|
||||
attrs ...RtAttrMsg[InAddr],
|
||||
) bool {
|
||||
c.typ, c.flags = typ, syscall.NLM_F_REQUEST|syscall.NLM_F_ACK|flags
|
||||
c.conn.typ, c.conn.flags = typ, syscall.NLM_F_REQUEST|syscall.NLM_F_ACK|flags
|
||||
if !add(c.conn, msg) {
|
||||
return false
|
||||
}
|
||||
@@ -72,6 +80,7 @@ func (c *RouteConn) writeIfAddrmsg(
|
||||
|
||||
// SendIfAddrmsg sends an ifaddrmsg structure to rtnetlink.
|
||||
func (c *RouteConn) SendIfAddrmsg(
|
||||
ctx context.Context,
|
||||
typ, flags uint16,
|
||||
msg *syscall.IfAddrmsg,
|
||||
attrs ...RtAttrMsg[InAddr],
|
||||
@@ -79,7 +88,7 @@ func (c *RouteConn) SendIfAddrmsg(
|
||||
if !c.writeIfAddrmsg(typ, flags, msg, attrs...) {
|
||||
return syscall.ENOMEM
|
||||
}
|
||||
return c.Roundtrip(rtnlConsume)
|
||||
return c.conn.Roundtrip(ctx, c.rtnlConsume)
|
||||
}
|
||||
|
||||
// writeNewaddrLo writes a RTM_NEWADDR message for the loopback address.
|
||||
@@ -104,11 +113,11 @@ func (c *RouteConn) writeNewaddrLo(lo uint32) bool {
|
||||
}
|
||||
|
||||
// SendNewaddrLo sends a RTM_NEWADDR message for the loopback address to the kernel.
|
||||
func (c *RouteConn) SendNewaddrLo(lo uint32) error {
|
||||
func (c *RouteConn) SendNewaddrLo(ctx context.Context, lo uint32) error {
|
||||
if !c.writeNewaddrLo(lo) {
|
||||
return syscall.ENOMEM
|
||||
}
|
||||
return c.Roundtrip(rtnlConsume)
|
||||
return c.conn.Roundtrip(ctx, c.rtnlConsume)
|
||||
}
|
||||
|
||||
// writeIfInfomsg writes an ifinfomsg structure to conn.
|
||||
@@ -116,17 +125,18 @@ func (c *RouteConn) writeIfInfomsg(
|
||||
typ, flags uint16,
|
||||
msg *syscall.IfInfomsg,
|
||||
) bool {
|
||||
c.typ, c.flags = typ, syscall.NLM_F_REQUEST|syscall.NLM_F_ACK|flags
|
||||
c.conn.typ, c.conn.flags = typ, syscall.NLM_F_REQUEST|syscall.NLM_F_ACK|flags
|
||||
return add(c.conn, msg)
|
||||
}
|
||||
|
||||
// SendIfInfomsg sends an ifinfomsg structure to rtnetlink.
|
||||
func (c *RouteConn) SendIfInfomsg(
|
||||
ctx context.Context,
|
||||
typ, flags uint16,
|
||||
msg *syscall.IfInfomsg,
|
||||
) error {
|
||||
if !c.writeIfInfomsg(typ, flags, msg) {
|
||||
return syscall.ENOMEM
|
||||
}
|
||||
return c.Roundtrip(rtnlConsume)
|
||||
return c.conn.Roundtrip(ctx, c.rtnlConsume)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ func TestPayloadRTNETLINK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checkPayload(t, []payloadTestCase{
|
||||
{"RTM_NEWADDR lo", func(c *conn) {
|
||||
{"RTM_NEWADDR lo", func(c *Conn) {
|
||||
(&RouteConn{c}).writeNewaddrLo(1)
|
||||
}, []byte{
|
||||
/* Len */ 0x28, 0, 0, 0,
|
||||
@@ -33,7 +33,7 @@ func TestPayloadRTNETLINK(t *testing.T) {
|
||||
/* in_addr */ 127, 0, 0, 1,
|
||||
}},
|
||||
|
||||
{"RTM_NEWLINK", func(c *conn) {
|
||||
{"RTM_NEWLINK", func(c *Conn) {
|
||||
c.seq++
|
||||
(&RouteConn{c}).writeIfInfomsg(
|
||||
syscall.RTM_NEWLINK, 0,
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/internal/dbus"
|
||||
"hakurei.app/internal/info"
|
||||
"hakurei.app/internal/params"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
@@ -84,7 +85,7 @@ type syscallDispatcher interface {
|
||||
// setDumpable provides [container.SetDumpable].
|
||||
setDumpable(dumpable uintptr) error
|
||||
// receive provides [container.Receive].
|
||||
receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error)
|
||||
receive(key string, e any, fdp *int) (closeFunc func() error, err error)
|
||||
|
||||
// containerStart provides the Start method of [container.Container].
|
||||
containerStart(z *container.Container) error
|
||||
@@ -154,8 +155,8 @@ func (direct) prctl(op, arg2, arg3 uintptr) error { return ext.Prctl(op, arg2, a
|
||||
func (direct) overflowUid(msg message.Msg) int { return container.OverflowUid(msg) }
|
||||
func (direct) overflowGid(msg message.Msg) int { return container.OverflowGid(msg) }
|
||||
func (direct) setDumpable(dumpable uintptr) error { return ext.SetDumpable(dumpable) }
|
||||
func (direct) receive(key string, e any, fdp *uintptr) (func() error, error) {
|
||||
return container.Receive(key, e, fdp)
|
||||
func (direct) receive(key string, e any, fdp *int) (func() error, error) {
|
||||
return params.Receive(key, e, fdp)
|
||||
}
|
||||
|
||||
func (direct) containerStart(z *container.Container) error { return z.Start() }
|
||||
|
||||
@@ -401,12 +401,12 @@ func (k *kstub) setDumpable(dumpable uintptr) error {
|
||||
stub.CheckArg(k.Stub, "dumpable", dumpable, 0))
|
||||
}
|
||||
|
||||
func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error) {
|
||||
func (k *kstub) receive(key string, e any, fdp *int) (closeFunc func() error, err error) {
|
||||
k.Helper()
|
||||
expect := k.Expects("receive")
|
||||
reflect.ValueOf(e).Elem().Set(reflect.ValueOf(expect.Args[1]))
|
||||
if expect.Args[2] != nil {
|
||||
*fdp = expect.Args[2].(uintptr)
|
||||
*fdp = int(expect.Args[2].(uintptr))
|
||||
}
|
||||
return func() error { return k.Expects("closeReceive").Err }, expect.Error(
|
||||
stub.CheckArg(k.Stub, "key", key, 0))
|
||||
@@ -690,38 +690,38 @@ func (panicMsgContext) Value(any) any { panic("unreachable") }
|
||||
// This type is meant to be embedded in partial syscallDispatcher implementations.
|
||||
type panicDispatcher struct{}
|
||||
|
||||
func (panicDispatcher) new(func(k syscallDispatcher, msg message.Msg)) { panic("unreachable") }
|
||||
func (panicDispatcher) getppid() int { panic("unreachable") }
|
||||
func (panicDispatcher) getpid() int { panic("unreachable") }
|
||||
func (panicDispatcher) getuid() int { panic("unreachable") }
|
||||
func (panicDispatcher) getgid() int { panic("unreachable") }
|
||||
func (panicDispatcher) lookupEnv(string) (string, bool) { panic("unreachable") }
|
||||
func (panicDispatcher) pipe() (*os.File, *os.File, error) { panic("unreachable") }
|
||||
func (panicDispatcher) stat(string) (os.FileInfo, error) { panic("unreachable") }
|
||||
func (panicDispatcher) open(string) (osFile, error) { panic("unreachable") }
|
||||
func (panicDispatcher) readdir(string) ([]os.DirEntry, error) { panic("unreachable") }
|
||||
func (panicDispatcher) tempdir() string { panic("unreachable") }
|
||||
func (panicDispatcher) mkdir(string, os.FileMode) error { panic("unreachable") }
|
||||
func (panicDispatcher) removeAll(string) error { panic("unreachable") }
|
||||
func (panicDispatcher) exit(int) { panic("unreachable") }
|
||||
func (panicDispatcher) evalSymlinks(string) (string, error) { panic("unreachable") }
|
||||
func (panicDispatcher) prctl(uintptr, uintptr, uintptr) error { panic("unreachable") }
|
||||
func (panicDispatcher) lookupGroupId(string) (string, error) { panic("unreachable") }
|
||||
func (panicDispatcher) lookPath(string) (string, error) { panic("unreachable") }
|
||||
func (panicDispatcher) cmdOutput(*exec.Cmd) ([]byte, error) { panic("unreachable") }
|
||||
func (panicDispatcher) overflowUid(message.Msg) int { panic("unreachable") }
|
||||
func (panicDispatcher) overflowGid(message.Msg) int { panic("unreachable") }
|
||||
func (panicDispatcher) setDumpable(uintptr) error { panic("unreachable") }
|
||||
func (panicDispatcher) receive(string, any, *uintptr) (func() error, error) { panic("unreachable") }
|
||||
func (panicDispatcher) containerStart(*container.Container) error { panic("unreachable") }
|
||||
func (panicDispatcher) containerServe(*container.Container) error { panic("unreachable") }
|
||||
func (panicDispatcher) containerWait(*container.Container) error { panic("unreachable") }
|
||||
func (panicDispatcher) mustHsuPath() *check.Absolute { panic("unreachable") }
|
||||
func (panicDispatcher) dbusAddress() (string, string) { panic("unreachable") }
|
||||
func (panicDispatcher) setupContSignal(int) (io.ReadCloser, func(), error) { panic("unreachable") }
|
||||
func (panicDispatcher) getMsg() message.Msg { panic("unreachable") }
|
||||
func (panicDispatcher) fatal(...any) { panic("unreachable") }
|
||||
func (panicDispatcher) fatalf(string, ...any) { panic("unreachable") }
|
||||
func (panicDispatcher) new(func(k syscallDispatcher, msg message.Msg)) { panic("unreachable") }
|
||||
func (panicDispatcher) getppid() int { panic("unreachable") }
|
||||
func (panicDispatcher) getpid() int { panic("unreachable") }
|
||||
func (panicDispatcher) getuid() int { panic("unreachable") }
|
||||
func (panicDispatcher) getgid() int { panic("unreachable") }
|
||||
func (panicDispatcher) lookupEnv(string) (string, bool) { panic("unreachable") }
|
||||
func (panicDispatcher) pipe() (*os.File, *os.File, error) { panic("unreachable") }
|
||||
func (panicDispatcher) stat(string) (os.FileInfo, error) { panic("unreachable") }
|
||||
func (panicDispatcher) open(string) (osFile, error) { panic("unreachable") }
|
||||
func (panicDispatcher) readdir(string) ([]os.DirEntry, error) { panic("unreachable") }
|
||||
func (panicDispatcher) tempdir() string { panic("unreachable") }
|
||||
func (panicDispatcher) mkdir(string, os.FileMode) error { panic("unreachable") }
|
||||
func (panicDispatcher) removeAll(string) error { panic("unreachable") }
|
||||
func (panicDispatcher) exit(int) { panic("unreachable") }
|
||||
func (panicDispatcher) evalSymlinks(string) (string, error) { panic("unreachable") }
|
||||
func (panicDispatcher) prctl(uintptr, uintptr, uintptr) error { panic("unreachable") }
|
||||
func (panicDispatcher) lookupGroupId(string) (string, error) { panic("unreachable") }
|
||||
func (panicDispatcher) lookPath(string) (string, error) { panic("unreachable") }
|
||||
func (panicDispatcher) cmdOutput(*exec.Cmd) ([]byte, error) { panic("unreachable") }
|
||||
func (panicDispatcher) overflowUid(message.Msg) int { panic("unreachable") }
|
||||
func (panicDispatcher) overflowGid(message.Msg) int { panic("unreachable") }
|
||||
func (panicDispatcher) setDumpable(uintptr) error { panic("unreachable") }
|
||||
func (panicDispatcher) receive(string, any, *int) (func() error, error) { panic("unreachable") }
|
||||
func (panicDispatcher) containerStart(*container.Container) error { panic("unreachable") }
|
||||
func (panicDispatcher) containerServe(*container.Container) error { panic("unreachable") }
|
||||
func (panicDispatcher) containerWait(*container.Container) error { panic("unreachable") }
|
||||
func (panicDispatcher) mustHsuPath() *check.Absolute { panic("unreachable") }
|
||||
func (panicDispatcher) dbusAddress() (string, string) { panic("unreachable") }
|
||||
func (panicDispatcher) setupContSignal(int) (io.ReadCloser, func(), error) { panic("unreachable") }
|
||||
func (panicDispatcher) getMsg() message.Msg { panic("unreachable") }
|
||||
func (panicDispatcher) fatal(...any) { panic("unreachable") }
|
||||
func (panicDispatcher) fatalf(string, ...any) { panic("unreachable") }
|
||||
|
||||
func (panicDispatcher) notifyContext(context.Context, ...os.Signal) (context.Context, context.CancelFunc) {
|
||||
panic("unreachable")
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/hst"
|
||||
"hakurei.app/internal/info"
|
||||
@@ -372,17 +371,18 @@ func (k *outcome) start(ctx context.Context, msg message.Msg,
|
||||
// shim runs in the same session as monitor; see shim.go for behaviour
|
||||
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGCONT) }
|
||||
|
||||
var shimPipe *os.File
|
||||
if fd, w, err := container.Setup(&cmd.ExtraFiles); err != nil {
|
||||
var shimPipe [2]*os.File
|
||||
if r, w, err := os.Pipe(); err != nil {
|
||||
return cmd, nil, &hst.AppError{Step: "create shim setup pipe", Err: err}
|
||||
} else {
|
||||
shimPipe = w
|
||||
cmd.Env = []string{
|
||||
// passed through to shim by hsu
|
||||
shimEnv + "=" + strconv.Itoa(fd),
|
||||
shimEnv + "=" + strconv.Itoa(3+len(cmd.ExtraFiles)),
|
||||
// interpreted by hsu
|
||||
"HAKUREI_IDENTITY=" + k.state.identity.String(),
|
||||
}
|
||||
cmd.ExtraFiles = append(cmd.ExtraFiles, r)
|
||||
shimPipe[0], shimPipe[1] = r, w
|
||||
}
|
||||
|
||||
if len(k.supp) > 0 {
|
||||
@@ -393,12 +393,16 @@ func (k *outcome) start(ctx context.Context, msg message.Msg,
|
||||
|
||||
msg.Verbosef("setuid helper at %s", hsuPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
_, _ = shimPipe[0].Close(), shimPipe[1].Close()
|
||||
msg.Resume()
|
||||
return cmd, shimPipe, &hst.AppError{Step: "start setuid wrapper", Err: err}
|
||||
return cmd, nil, &hst.AppError{Step: "start setuid wrapper", Err: err}
|
||||
}
|
||||
if err := shimPipe[0].Close(); err != nil {
|
||||
msg.Verbose(err)
|
||||
}
|
||||
|
||||
*startTime = time.Now().UTC()
|
||||
return cmd, shimPipe, nil
|
||||
return cmd, shimPipe[1], nil
|
||||
}
|
||||
|
||||
// serveShim serves outcomeState through the shim setup pipe.
|
||||
@@ -411,11 +415,11 @@ func serveShim(msg message.Msg, shimPipe *os.File, state *outcomeState) error {
|
||||
msg.Verbose(err.Error())
|
||||
}
|
||||
if err := gob.NewEncoder(shimPipe).Encode(state); err != nil {
|
||||
_ = shimPipe.Close()
|
||||
msg.Resume()
|
||||
return &hst.AppError{Step: "transmit shim config", Err: err}
|
||||
}
|
||||
_ = shimPipe.Close()
|
||||
return nil
|
||||
return shimPipe.Close()
|
||||
}
|
||||
|
||||
// printMessageError prints the error message according to [message.GetMessage],
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/hst"
|
||||
"hakurei.app/internal/params"
|
||||
"hakurei.app/internal/pipewire"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
@@ -197,7 +198,7 @@ func shimEntrypoint(k syscallDispatcher) {
|
||||
if errors.Is(err, syscall.EBADF) {
|
||||
k.fatal("invalid config descriptor")
|
||||
}
|
||||
if errors.Is(err, container.ErrReceiveEnv) {
|
||||
if errors.Is(err, params.ErrReceiveEnv) {
|
||||
k.fatal(shimEnv + " not set")
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/hst"
|
||||
"hakurei.app/internal/env"
|
||||
"hakurei.app/internal/params"
|
||||
"hakurei.app/internal/stub"
|
||||
)
|
||||
|
||||
@@ -172,7 +173,7 @@ func TestShimEntrypoint(t *testing.T) {
|
||||
call("setDumpable", stub.ExpectArgs{uintptr(ext.SUID_DUMP_DISABLE)}, nil, nil),
|
||||
call("getppid", stub.ExpectArgs{}, 0xbad, nil),
|
||||
call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil),
|
||||
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", outcomeState{}, nil}, nil, container.ErrReceiveEnv),
|
||||
call("receive", stub.ExpectArgs{"HAKUREI_SHIM", outcomeState{}, nil}, nil, params.ErrReceiveEnv),
|
||||
call("fatal", stub.ExpectArgs{[]any{"HAKUREI_SHIM not set"}}, nil, nil),
|
||||
|
||||
// deferred
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"syscall"
|
||||
@@ -165,9 +165,9 @@ func (s *spFilesystemOp) toSystem(state *outcomeStateSys) error {
|
||||
}
|
||||
for _, pair := range entry.Values {
|
||||
if pair[0] == "path" {
|
||||
if path.IsAbs(pair[1]) {
|
||||
if filepath.IsAbs(pair[1]) {
|
||||
// get parent dir of socket
|
||||
dir := path.Dir(pair[1])
|
||||
dir := filepath.Dir(pair[1])
|
||||
if dir == "." || dir == fhs.Root {
|
||||
state.msg.Verbosef("dbus socket %q is in an unusual location", pair[1])
|
||||
}
|
||||
@@ -290,7 +290,9 @@ func (s *spFilesystemOp) toContainer(state *outcomeStateParams) error {
|
||||
if state.Container.Flags&hst.FDevice == 0 {
|
||||
state.params.Remount(fhs.AbsDev, syscall.MS_RDONLY)
|
||||
}
|
||||
state.params.Remount(fhs.AbsRoot, syscall.MS_RDONLY)
|
||||
if !state.as.NoRemountRoot {
|
||||
state.params.Remount(fhs.AbsRoot, syscall.MS_RDONLY)
|
||||
}
|
||||
|
||||
state.params.Env = make([]string, 0, len(state.env))
|
||||
for key, value := range state.env {
|
||||
|
||||
42
internal/params/params.go
Normal file
42
internal/params/params.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Package params provides helpers for receiving setup payload from parent.
|
||||
package params
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ErrReceiveEnv is returned by [Receive] if setup fd is not present in environment.
|
||||
var ErrReceiveEnv = errors.New("environment variable not set")
|
||||
|
||||
// Receive retrieves setup fd from the environment and receives params.
|
||||
//
|
||||
// The file descriptor written to the value pointed to by fdp must not be passed
|
||||
// to any system calls. It is made available for ordering file descriptor only.
|
||||
func Receive(key string, v any, fdp *int) (func() error, error) {
|
||||
var setup *os.File
|
||||
|
||||
if s, ok := os.LookupEnv(key); !ok {
|
||||
return nil, ErrReceiveEnv
|
||||
} else {
|
||||
if fd, err := strconv.Atoi(s); err != nil {
|
||||
if _err := errors.Unwrap(err); _err != nil {
|
||||
err = _err
|
||||
}
|
||||
return nil, err
|
||||
} else {
|
||||
setup = os.NewFile(uintptr(fd), "setup")
|
||||
if setup == nil {
|
||||
return nil, syscall.EDOM
|
||||
}
|
||||
if fdp != nil {
|
||||
*fdp = fd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return setup.Close, gob.NewDecoder(setup).Decode(v)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package container_test
|
||||
package params_test
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"hakurei.app/container"
|
||||
"hakurei.app/internal/params"
|
||||
)
|
||||
|
||||
func TestSetupReceive(t *testing.T) {
|
||||
@@ -30,8 +30,8 @@ func TestSetupReceive(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := container.Receive(key, nil, nil); !errors.Is(err, container.ErrReceiveEnv) {
|
||||
t.Errorf("Receive: error = %v, want %v", err, container.ErrReceiveEnv)
|
||||
if _, err := params.Receive(key, nil, nil); !errors.Is(err, params.ErrReceiveEnv) {
|
||||
t.Errorf("Receive: error = %v, want %v", err, params.ErrReceiveEnv)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestSetupReceive(t *testing.T) {
|
||||
const key = "TEST_ENV_FORMAT"
|
||||
t.Setenv(key, "")
|
||||
|
||||
if _, err := container.Receive(key, nil, nil); !errors.Is(err, strconv.ErrSyntax) {
|
||||
if _, err := params.Receive(key, nil, nil); !errors.Is(err, strconv.ErrSyntax) {
|
||||
t.Errorf("Receive: error = %v, want %v", err, strconv.ErrSyntax)
|
||||
}
|
||||
})
|
||||
@@ -48,7 +48,7 @@ func TestSetupReceive(t *testing.T) {
|
||||
const key = "TEST_ENV_RANGE"
|
||||
t.Setenv(key, "-1")
|
||||
|
||||
if _, err := container.Receive(key, nil, nil); !errors.Is(err, syscall.EDOM) {
|
||||
if _, err := params.Receive(key, nil, nil); !errors.Is(err, syscall.EDOM) {
|
||||
t.Errorf("Receive: error = %v, want %v", err, syscall.EDOM)
|
||||
}
|
||||
})
|
||||
@@ -60,16 +60,22 @@ func TestSetupReceive(t *testing.T) {
|
||||
|
||||
encoderDone := make(chan error, 1)
|
||||
extraFiles := make([]*os.File, 0, 1)
|
||||
deadline, _ := t.Deadline()
|
||||
if fd, f, err := container.Setup(&extraFiles); err != nil {
|
||||
if r, w, err := os.Pipe(); err != nil {
|
||||
t.Fatalf("Setup: error = %v", err)
|
||||
} else if fd != 3 {
|
||||
t.Fatalf("Setup: fd = %d, want 3", fd)
|
||||
} else {
|
||||
if err = f.SetDeadline(deadline); err != nil {
|
||||
t.Fatal(err.Error())
|
||||
t.Cleanup(func() {
|
||||
if err = errors.Join(r.Close(), w.Close()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
extraFiles = append(extraFiles, r)
|
||||
if deadline, ok := t.Deadline(); ok {
|
||||
if err = w.SetDeadline(deadline); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
go func() { encoderDone <- gob.NewEncoder(f).Encode(payload) }()
|
||||
go func() { encoderDone <- gob.NewEncoder(w).Encode(payload) }()
|
||||
}
|
||||
|
||||
if len(extraFiles) != 1 {
|
||||
@@ -87,13 +93,13 @@ func TestSetupReceive(t *testing.T) {
|
||||
|
||||
var (
|
||||
gotPayload []uint64
|
||||
fdp *uintptr
|
||||
fdp *int
|
||||
)
|
||||
if !useNilFdp {
|
||||
fdp = new(uintptr)
|
||||
fdp = new(int)
|
||||
}
|
||||
var closeFile func() error
|
||||
if f, err := container.Receive(key, &gotPayload, fdp); err != nil {
|
||||
if f, err := params.Receive(key, &gotPayload, fdp); err != nil {
|
||||
t.Fatalf("Receive: error = %v", err)
|
||||
} else {
|
||||
closeFile = f
|
||||
@@ -103,7 +109,7 @@ func TestSetupReceive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !useNilFdp {
|
||||
if int(*fdp) != dupFd {
|
||||
if *fdp != dupFd {
|
||||
t.Errorf("Fd: %d, want %d", *fdp, dupFd)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -973,23 +973,23 @@ func connectName(name string, manager bool) (conn Conn, err error) {
|
||||
return connectName(name+"-manager", false)
|
||||
}
|
||||
|
||||
if path.IsAbs(name) || (len(name) > 0 && name[0] == '@') {
|
||||
if filepath.IsAbs(name) || (len(name) > 0 && name[0] == '@') {
|
||||
return Dial(name)
|
||||
} else {
|
||||
runtimeDir, ok := os.LookupEnv("PIPEWIRE_RUNTIME_DIR")
|
||||
if !ok || !path.IsAbs(runtimeDir) {
|
||||
if !ok || !filepath.IsAbs(runtimeDir) {
|
||||
runtimeDir, ok = os.LookupEnv("XDG_RUNTIME_DIR")
|
||||
}
|
||||
if !ok || !path.IsAbs(runtimeDir) {
|
||||
if !ok || !filepath.IsAbs(runtimeDir) {
|
||||
// this is cargo culted from windows stuff and has no effect normally;
|
||||
// keeping it to maintain compatibility in case someone sets this
|
||||
runtimeDir, ok = os.LookupEnv("USERPROFILE")
|
||||
}
|
||||
|
||||
if !ok || !path.IsAbs(runtimeDir) {
|
||||
if !ok || !filepath.IsAbs(runtimeDir) {
|
||||
runtimeDir = DEFAULT_SYSTEM_RUNTIME_DIR
|
||||
}
|
||||
return Dial(path.Join(runtimeDir, name))
|
||||
return Dial(filepath.Join(runtimeDir, name))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,31 @@ func TestFlatten(t *testing.T) {
|
||||
fs.ModeCharDevice | 0400,
|
||||
)},
|
||||
|
||||
{"coldboot", fstest.MapFS{
|
||||
".": {Mode: fs.ModeDir | 0700},
|
||||
|
||||
"devices": {Mode: fs.ModeDir | 0700},
|
||||
"devices/uevent": {Mode: 0600, Data: []byte("add")},
|
||||
"devices/empty": {Mode: fs.ModeDir | 0700},
|
||||
|
||||
"devices/sub": {Mode: fs.ModeDir | 0700},
|
||||
"devices/sub/uevent": {Mode: 0600, Data: []byte("add")},
|
||||
|
||||
"block": {Mode: fs.ModeDir | 0700},
|
||||
"block/uevent": {Mode: 0600, Data: []byte{}},
|
||||
}, []pkg.FlatEntry{
|
||||
{Mode: fs.ModeDir | 0700, Path: "."},
|
||||
|
||||
{Mode: fs.ModeDir | 0700, Path: "block"},
|
||||
{Mode: 0600, Path: "block/uevent", Data: []byte{}},
|
||||
|
||||
{Mode: fs.ModeDir | 0700, Path: "devices"},
|
||||
{Mode: fs.ModeDir | 0700, Path: "devices/empty"},
|
||||
{Mode: fs.ModeDir | 0700, Path: "devices/sub"},
|
||||
{Mode: 0600, Path: "devices/sub/uevent", Data: []byte("add")},
|
||||
{Mode: 0600, Path: "devices/uevent", Data: []byte("add")},
|
||||
}, pkg.MustDecode("mEy_Lf5KotThm7OwMx7yTKZh5HCCyaB41pVAvI9uDMgVQFM91iosBLYsRm8bDsX8"), nil},
|
||||
|
||||
{"empty", fstest.MapFS{
|
||||
".": {Mode: fs.ModeDir | 0700},
|
||||
"checksum": {Mode: fs.ModeDir | 0700},
|
||||
@@ -159,6 +184,32 @@ func TestFlatten(t *testing.T) {
|
||||
{Mode: fs.ModeDir | 0700, Path: "work"},
|
||||
}, pkg.MustDecode("WVpvsVqVKg9Nsh744x57h51AuWUoUR2nnh8Md-EYBQpk6ziyTuUn6PLtF2e0Eu_d"), nil},
|
||||
|
||||
{"sample no assume checksum", fstest.MapFS{
|
||||
".": {Mode: fs.ModeDir | 0700},
|
||||
|
||||
"checksum": {Mode: fs.ModeDir | 0700},
|
||||
"checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M": {Mode: fs.ModeDir | 0500},
|
||||
"checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M/check": {Mode: 0400, Data: []byte{}},
|
||||
|
||||
"identifier": {Mode: fs.ModeDir | 0700},
|
||||
"identifier/_wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M")},
|
||||
"identifier/_wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M")},
|
||||
|
||||
"work": {Mode: fs.ModeDir | 0700},
|
||||
}, []pkg.FlatEntry{
|
||||
{Mode: fs.ModeDir | 0700, Path: "."},
|
||||
|
||||
{Mode: fs.ModeDir | 0700, Path: "checksum"},
|
||||
{Mode: fs.ModeDir | 0500, Path: "checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M"},
|
||||
{Mode: 0400, Path: "checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M/check", Data: []byte{}},
|
||||
|
||||
{Mode: fs.ModeDir | 0700, Path: "identifier"},
|
||||
{Mode: fs.ModeSymlink | 0777, Path: "identifier/_wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", Data: []byte("../checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M")},
|
||||
{Mode: fs.ModeSymlink | 0777, Path: "identifier/_wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", Data: []byte("../checksum/Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M")},
|
||||
|
||||
{Mode: fs.ModeDir | 0700, Path: "work"},
|
||||
}, pkg.MustDecode("OC290t23aimNo2Rp2pPwan5GI2KRLRdOwYxXQMD9jw0QROgHnNXWodoWdV0hwu2w"), nil},
|
||||
|
||||
{"sample tar step unpack", fstest.MapFS{
|
||||
".": {Mode: fs.ModeDir | 0500},
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"syscall"
|
||||
@@ -40,14 +40,14 @@ type ExecPath struct {
|
||||
W bool
|
||||
}
|
||||
|
||||
// SetSchedIdle is whether to set [std.SCHED_IDLE] scheduling priority.
|
||||
var SetSchedIdle bool
|
||||
// GetArtifactFunc is the function signature of [FContext.GetArtifact].
|
||||
type GetArtifactFunc func(Artifact) (*check.Absolute, unique.Handle[Checksum])
|
||||
|
||||
// PromoteLayers returns artifacts with identical-by-content layers promoted to
|
||||
// the highest priority instance, as if mounted via [ExecPath].
|
||||
func PromoteLayers(
|
||||
artifacts []Artifact,
|
||||
getArtifact func(Artifact) (*check.Absolute, unique.Handle[Checksum]),
|
||||
getArtifact GetArtifactFunc,
|
||||
report func(i int, d Artifact),
|
||||
) []*check.Absolute {
|
||||
layers := make([]*check.Absolute, 0, len(artifacts))
|
||||
@@ -67,14 +67,14 @@ func PromoteLayers(
|
||||
}
|
||||
|
||||
// layers returns pathnames collected from A deduplicated via [PromoteLayers].
|
||||
func (p *ExecPath) layers(f *FContext) []*check.Absolute {
|
||||
msg := f.GetMessage()
|
||||
return PromoteLayers(p.A, f.GetArtifact, func(i int, d Artifact) {
|
||||
func (p *ExecPath) layers(
|
||||
msg message.Msg,
|
||||
getArtifact GetArtifactFunc,
|
||||
ident func(a Artifact) unique.Handle[ID],
|
||||
) []*check.Absolute {
|
||||
return PromoteLayers(p.A, getArtifact, func(i int, d Artifact) {
|
||||
if msg.IsVerbose() {
|
||||
msg.Verbosef(
|
||||
"promoted layer %d as %s",
|
||||
i, reportName(d, f.cache.Ident(d)),
|
||||
)
|
||||
msg.Verbosef("promoted layer %d as %s", i, reportName(d, ident(d)))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func NewExec(
|
||||
paths ...ExecPath,
|
||||
) Artifact {
|
||||
if name == "" {
|
||||
name = "exec-" + path.Base(pathname.String())
|
||||
name = "exec-" + filepath.Base(pathname.String())
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = ExecTimeoutDefault
|
||||
@@ -382,17 +382,31 @@ func scanVerbose(
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidPaths is returned for an [Artifact] of [KindExec] or
|
||||
// [KindExecNet] specified with invalid paths.
|
||||
ErrInvalidPaths = errors.New("invalid mount point")
|
||||
)
|
||||
|
||||
// SeccompPresets is the [seccomp] presets used by exec artifacts.
|
||||
const SeccompPresets = std.PresetStrict &
|
||||
^(std.PresetDenyNS | std.PresetDenyDevel)
|
||||
|
||||
// cure is like Cure but allows optional host net namespace. This is used for
|
||||
// the [KnownChecksum] variant where networking is allowed.
|
||||
func (a *execArtifact) cure(f *FContext, hostNet bool) (err error) {
|
||||
// makeContainer sets up the specified temp and work directories and returns the
|
||||
// corresponding [container.Container] that would have run for cure.
|
||||
func (a *execArtifact) makeContainer(
|
||||
ctx context.Context,
|
||||
msg message.Msg,
|
||||
flags int,
|
||||
hostNet bool,
|
||||
temp, work *check.Absolute,
|
||||
getArtifact GetArtifactFunc,
|
||||
ident func(a Artifact) unique.Handle[ID],
|
||||
) (z *container.Container, err error) {
|
||||
overlayWorkIndex := -1
|
||||
for i, p := range a.paths {
|
||||
if p.P == nil || len(p.A) == 0 {
|
||||
return os.ErrInvalid
|
||||
return nil, ErrInvalidPaths
|
||||
}
|
||||
if p.P.Is(AbsWork) {
|
||||
overlayWorkIndex = i
|
||||
@@ -404,29 +418,204 @@ func (a *execArtifact) cure(f *FContext, hostNet bool) (err error) {
|
||||
artifactCount += len(p.A)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.Unwrap(), a.timeout)
|
||||
defer cancel()
|
||||
|
||||
z := container.New(ctx, f.GetMessage())
|
||||
z = container.New(ctx, msg)
|
||||
z.WaitDelay = execWaitDelay
|
||||
z.SeccompPresets = SeccompPresets
|
||||
z.SeccompFlags |= seccomp.AllowMultiarch
|
||||
z.ParentPerm = 0700
|
||||
z.HostNet = hostNet
|
||||
z.HostAbstract = flags&CHostAbstract != 0
|
||||
z.Hostname = "cure"
|
||||
z.SetScheduler = SetSchedIdle
|
||||
z.SetScheduler = flags&CSchedIdle != 0
|
||||
z.SchedPolicy = ext.SCHED_IDLE
|
||||
if z.HostNet {
|
||||
z.Hostname = "cure-net"
|
||||
}
|
||||
z.Uid, z.Gid = (1<<10)-1, (1<<10)-1
|
||||
|
||||
z.Dir, z.Env, z.Path, z.Args = a.dir, a.env, a.path, a.args
|
||||
z.Grow(len(a.paths) + 4)
|
||||
|
||||
for i, b := range a.paths {
|
||||
if i == overlayWorkIndex {
|
||||
if err = os.MkdirAll(work.String(), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
tempWork := temp.Append(".work")
|
||||
if err = os.MkdirAll(tempWork.String(), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
z.Overlay(
|
||||
AbsWork,
|
||||
work,
|
||||
tempWork,
|
||||
b.layers(msg, getArtifact, ident)...,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if a.paths[i].W {
|
||||
tempUpper, tempWork := temp.Append(
|
||||
".upper", strconv.Itoa(i),
|
||||
), temp.Append(
|
||||
".work", strconv.Itoa(i),
|
||||
)
|
||||
if err = os.MkdirAll(tempUpper.String(), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
if err = os.MkdirAll(tempWork.String(), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
z.Overlay(b.P, tempUpper, tempWork, b.layers(msg, getArtifact, ident)...)
|
||||
} else if len(b.A) == 1 {
|
||||
pathname, _ := getArtifact(b.A[0])
|
||||
z.Bind(pathname, b.P, 0)
|
||||
} else {
|
||||
z.OverlayReadonly(b.P, b.layers(msg, getArtifact, ident)...)
|
||||
}
|
||||
}
|
||||
if overlayWorkIndex < 0 {
|
||||
z.Bind(
|
||||
work,
|
||||
AbsWork,
|
||||
std.BindWritable|std.BindEnsure,
|
||||
)
|
||||
}
|
||||
z.Bind(
|
||||
temp,
|
||||
fhs.AbsTmp,
|
||||
std.BindWritable|std.BindEnsure,
|
||||
)
|
||||
z.Proc(fhs.AbsProc).Dev(fhs.AbsDev, true)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrExecBusy is returned entering [Cache.EnterExec] while another
|
||||
// goroutine has not yet returned from it.
|
||||
ErrExecBusy = errors.New("scratch directories in use")
|
||||
// ErrNotExec is returned for unsupported implementations of [Artifact]
|
||||
// passed to [Cache.EnterExec].
|
||||
ErrNotExec = errors.New("attempting to run a non-exec artifact")
|
||||
)
|
||||
|
||||
// EnterExec runs the container of an [Artifact] of [KindExec] or [KindExecNet]
|
||||
// with its entry point, argument, and standard streams replaced with values
|
||||
// supplied by the caller.
|
||||
func (c *Cache) EnterExec(
|
||||
ctx context.Context,
|
||||
a Artifact,
|
||||
retainSession bool,
|
||||
stdin io.Reader,
|
||||
stdout, stderr io.Writer,
|
||||
path *check.Absolute,
|
||||
args ...string,
|
||||
) (err error) {
|
||||
if !c.inExec.CompareAndSwap(false, true) {
|
||||
return ErrExecBusy
|
||||
}
|
||||
defer c.inExec.Store(false)
|
||||
|
||||
var hostNet bool
|
||||
var e *execArtifact
|
||||
switch f := a.(type) {
|
||||
case *execArtifact:
|
||||
e = f
|
||||
|
||||
case *execNetArtifact:
|
||||
e = &f.execArtifact
|
||||
hostNet = true
|
||||
|
||||
default:
|
||||
return ErrNotExec
|
||||
}
|
||||
|
||||
deps := Collect(a.Dependencies())
|
||||
if _, _, err = c.Cure(&deps); err == nil {
|
||||
return errors.New("unreachable")
|
||||
} else if !IsCollected(err) {
|
||||
return
|
||||
}
|
||||
|
||||
dm := make(map[Artifact]cureRes)
|
||||
for i, p := range deps {
|
||||
var res cureRes
|
||||
res.pathname, res.checksum, err = c.Cure(p)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dm[deps[i]] = res
|
||||
}
|
||||
|
||||
scratch := c.base.Append(dirExecScratch)
|
||||
temp, work := scratch.Append("temp"), scratch.Append("work")
|
||||
// work created during makeContainer
|
||||
if err = os.MkdirAll(temp.String(), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if chmodErr, removeErr := removeAll(scratch); chmodErr != nil || removeErr != nil {
|
||||
err = errors.Join(err, chmodErr, removeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
var z *container.Container
|
||||
z, err = e.makeContainer(
|
||||
ctx, c.msg,
|
||||
c.flags,
|
||||
hostNet,
|
||||
temp, work,
|
||||
func(a Artifact) (*check.Absolute, unique.Handle[Checksum]) {
|
||||
if res, ok := dm[a]; ok {
|
||||
return res.pathname, res.checksum
|
||||
}
|
||||
panic(InvalidLookupError(c.Ident(a).Value()))
|
||||
},
|
||||
c.Ident,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
z.Stdin, z.Stdout, z.Stderr = stdin, stdout, stderr
|
||||
z.Path, z.Args = path, args
|
||||
z.RetainSession = retainSession
|
||||
if stdin == os.Stdin {
|
||||
if s, ok := os.LookupEnv("TERM"); ok {
|
||||
z.Env = append(z.Env, "TERM="+s)
|
||||
}
|
||||
}
|
||||
|
||||
if err = z.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
if err = z.Serve(); err != nil {
|
||||
return
|
||||
}
|
||||
return z.Wait()
|
||||
}
|
||||
|
||||
// cure is like Cure but allows optional host net namespace.
|
||||
func (a *execArtifact) cure(f *FContext, hostNet bool) (err error) {
|
||||
ctx, cancel := context.WithTimeout(f.Unwrap(), a.timeout)
|
||||
defer cancel()
|
||||
|
||||
msg := f.GetMessage()
|
||||
var z *container.Container
|
||||
if z, err = a.makeContainer(
|
||||
ctx, msg, f.cache.flags, hostNet,
|
||||
f.GetTempDir(), f.GetWorkDir(),
|
||||
f.GetArtifact,
|
||||
f.cache.Ident,
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var status io.Writer
|
||||
if status, err = f.GetStatusWriter(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if msg := f.GetMessage(); msg.IsVerbose() {
|
||||
if msg.IsVerbose() {
|
||||
var stdout, stderr io.ReadCloser
|
||||
if stdout, err = z.StdoutPipe(); err != nil {
|
||||
return
|
||||
@@ -464,62 +653,6 @@ func (a *execArtifact) cure(f *FContext, hostNet bool) (err error) {
|
||||
z.Stdout, z.Stderr = status, status
|
||||
}
|
||||
|
||||
z.Dir, z.Env, z.Path, z.Args = a.dir, a.env, a.path, a.args
|
||||
z.Grow(len(a.paths) + 4)
|
||||
|
||||
temp, work := f.GetTempDir(), f.GetWorkDir()
|
||||
for i, b := range a.paths {
|
||||
if i == overlayWorkIndex {
|
||||
if err = os.MkdirAll(work.String(), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
tempWork := temp.Append(".work")
|
||||
if err = os.MkdirAll(tempWork.String(), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
z.Overlay(
|
||||
AbsWork,
|
||||
work,
|
||||
tempWork,
|
||||
b.layers(f)...,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if a.paths[i].W {
|
||||
tempUpper, tempWork := temp.Append(
|
||||
".upper", strconv.Itoa(i),
|
||||
), temp.Append(
|
||||
".work", strconv.Itoa(i),
|
||||
)
|
||||
if err = os.MkdirAll(tempUpper.String(), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
if err = os.MkdirAll(tempWork.String(), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
z.Overlay(b.P, tempUpper, tempWork, b.layers(f)...)
|
||||
} else if len(b.A) == 1 {
|
||||
pathname, _ := f.GetArtifact(b.A[0])
|
||||
z.Bind(pathname, b.P, 0)
|
||||
} else {
|
||||
z.OverlayReadonly(b.P, b.layers(f)...)
|
||||
}
|
||||
}
|
||||
if overlayWorkIndex < 0 {
|
||||
z.Bind(
|
||||
work,
|
||||
AbsWork,
|
||||
std.BindWritable|std.BindEnsure,
|
||||
)
|
||||
}
|
||||
z.Bind(
|
||||
f.GetTempDir(),
|
||||
fhs.AbsTmp,
|
||||
std.BindWritable|std.BindEnsure,
|
||||
)
|
||||
z.Proc(fhs.AbsProc).Dev(fhs.AbsDev, true)
|
||||
|
||||
if err = z.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -532,7 +665,7 @@ func (a *execArtifact) cure(f *FContext, hostNet bool) (err error) {
|
||||
|
||||
// do not allow empty directories to succeed
|
||||
for {
|
||||
err = syscall.Rmdir(work.String())
|
||||
err = syscall.Rmdir(f.GetWorkDir().String())
|
||||
if err != syscall.EINTR {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -33,8 +33,7 @@ func TestExec(t *testing.T) {
|
||||
)
|
||||
|
||||
checkWithCache(t, []cacheTestCase{
|
||||
{"offline", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
c.SetStrict(true)
|
||||
{"offline", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
testtool, testtoolDestroy := newTesttool()
|
||||
|
||||
cureMany(t, c, []cureStep{
|
||||
@@ -92,7 +91,7 @@ func TestExec(t *testing.T) {
|
||||
[]string{"testtool"},
|
||||
|
||||
pkg.ExecPath{},
|
||||
), nil, pkg.Checksum{}, os.ErrInvalid},
|
||||
), nil, pkg.Checksum{}, pkg.ErrInvalidPaths},
|
||||
})
|
||||
|
||||
// check init failure passthrough
|
||||
@@ -111,8 +110,7 @@ func TestExec(t *testing.T) {
|
||||
testtoolDestroy(t, base, c)
|
||||
}, pkg.MustDecode("Q5DluWQCAeohLoiGRImurwFp3vdz9IfQCoj7Fuhh73s4KQPRHpEQEnHTdNHmB8Fx")},
|
||||
|
||||
{"net", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
c.SetStrict(true)
|
||||
{"net", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
testtool, testtoolDestroy := newTesttool()
|
||||
|
||||
wantChecksum := pkg.MustDecode(
|
||||
@@ -146,8 +144,7 @@ func TestExec(t *testing.T) {
|
||||
testtoolDestroy(t, base, c)
|
||||
}, pkg.MustDecode("bPYvvqxpfV7xcC1EptqyKNK1klLJgYHMDkzBcoOyK6j_Aj5hb0mXNPwTwPSK5F6Z")},
|
||||
|
||||
{"overlay root", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
c.SetStrict(true)
|
||||
{"overlay root", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
testtool, testtoolDestroy := newTesttool()
|
||||
|
||||
cureMany(t, c, []cureStep{
|
||||
@@ -172,8 +169,7 @@ func TestExec(t *testing.T) {
|
||||
testtoolDestroy(t, base, c)
|
||||
}, pkg.MustDecode("PO2DSSCa4yoSgEYRcCSZfQfwow1yRigL3Ry-hI0RDI4aGuFBha-EfXeSJnG_5_Rl")},
|
||||
|
||||
{"overlay work", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
c.SetStrict(true)
|
||||
{"overlay work", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
testtool, testtoolDestroy := newTesttool()
|
||||
|
||||
cureMany(t, c, []cureStep{
|
||||
@@ -203,8 +199,7 @@ func TestExec(t *testing.T) {
|
||||
testtoolDestroy(t, base, c)
|
||||
}, pkg.MustDecode("iaRt6l_Wm2n-h5UsDewZxQkCmjZjyL8r7wv32QT2kyV55-Lx09Dq4gfg9BiwPnKs")},
|
||||
|
||||
{"multiple layers", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
c.SetStrict(true)
|
||||
{"multiple layers", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
testtool, testtoolDestroy := newTesttool()
|
||||
|
||||
cureMany(t, c, []cureStep{
|
||||
@@ -256,8 +251,7 @@ func TestExec(t *testing.T) {
|
||||
testtoolDestroy(t, base, c)
|
||||
}, pkg.MustDecode("O2YzyR7IUGU5J2CADy0hUZ3A5NkP_Vwzs4UadEdn2oMZZVWRtH0xZGJ3HXiimTnZ")},
|
||||
|
||||
{"overlay layer promotion", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
c.SetStrict(true)
|
||||
{"overlay layer promotion", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
testtool, testtoolDestroy := newTesttool()
|
||||
|
||||
cureMany(t, c, []cureStep{
|
||||
|
||||
@@ -11,9 +11,7 @@ func TestFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checkWithCache(t, []cacheTestCase{
|
||||
{"file", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
c.SetStrict(true)
|
||||
|
||||
{"file", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
cureMany(t, c, []cureStep{
|
||||
{"short", pkg.NewFile("null", []byte{0}), base.Append(
|
||||
"identifier",
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestIRRoundtrip(t *testing.T) {
|
||||
testCasesCache := make([]cacheTestCase, len(testCases))
|
||||
for i, tc := range testCases {
|
||||
want := tc.a
|
||||
testCasesCache[i] = cacheTestCase{tc.name, nil,
|
||||
testCasesCache[i] = cacheTestCase{tc.name, 0, nil,
|
||||
func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
r, w := io.Pipe()
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestHTTPGet(t *testing.T) {
|
||||
}))
|
||||
|
||||
checkWithCache(t, []cacheTestCase{
|
||||
{"direct", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
{"direct", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
var r pkg.RContext
|
||||
rCacheVal := reflect.ValueOf(&r).Elem().FieldByName("cache")
|
||||
reflect.NewAt(
|
||||
@@ -94,7 +94,7 @@ func TestHTTPGet(t *testing.T) {
|
||||
}
|
||||
}, pkg.MustDecode("E4vEZKhCcL2gPZ2Tt59FS3lDng-d_2SKa2i5G_RbDfwGn6EemptFaGLPUDiOa94C")},
|
||||
|
||||
{"cure", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
{"cure", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
var r pkg.RContext
|
||||
rCacheVal := reflect.ValueOf(&r).Elem().FieldByName("cache")
|
||||
reflect.NewAt(
|
||||
|
||||
@@ -13,15 +13,14 @@ import (
|
||||
"hash"
|
||||
"io"
|
||||
"io/fs"
|
||||
"iter"
|
||||
"maps"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"unique"
|
||||
@@ -331,23 +330,6 @@ type FloodArtifact interface {
|
||||
Artifact
|
||||
}
|
||||
|
||||
// Flood returns an iterator over the dependency tree of an [Artifact].
|
||||
func Flood(a Artifact) iter.Seq[Artifact] {
|
||||
return func(yield func(Artifact) bool) {
|
||||
for _, d := range a.Dependencies() {
|
||||
if !yield(d) {
|
||||
return
|
||||
}
|
||||
|
||||
for d0 := range Flood(d) {
|
||||
if !yield(d0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TrivialArtifact refers to an [Artifact] that cures without requiring that
|
||||
// any other [Artifact] is cured before it. Its dependency tree is ignored after
|
||||
// computing its identifier.
|
||||
@@ -366,7 +348,7 @@ type TrivialArtifact interface {
|
||||
}
|
||||
|
||||
// KnownIdent is optionally implemented by [Artifact] and is used instead of
|
||||
// [Kind.Ident] when it is available.
|
||||
// [Cache.Ident] when it is available.
|
||||
//
|
||||
// This is very subtle to use correctly. The implementation must ensure that
|
||||
// this value is globally unique, otherwise [Cache] can enter an inconsistent
|
||||
@@ -439,6 +421,11 @@ const (
|
||||
KindCustomOffset = 1 << 31
|
||||
)
|
||||
|
||||
const (
|
||||
// kindCollection is the kind of [Collect]. It never cures successfully.
|
||||
kindCollection Kind = KindCustomOffset - 1 - iota
|
||||
)
|
||||
|
||||
const (
|
||||
// fileLock is the file name appended to Cache.base for guaranteeing
|
||||
// exclusive access to the cache directory.
|
||||
@@ -461,6 +448,11 @@ const (
|
||||
// pathnames allocated during [Cache.Cure].
|
||||
dirTemp = "temp"
|
||||
|
||||
// dirExecScratch is the directory name appended to Cache.base for scratch
|
||||
// space setting up the container started by [Cache.EnterExec]. Exclusivity
|
||||
// via Cache.inExec.
|
||||
dirExecScratch = "scratch"
|
||||
|
||||
// checksumLinknamePrefix is prepended to the encoded [Checksum] value
|
||||
// of an [Artifact] when creating a symbolic link to dirChecksum.
|
||||
checksumLinknamePrefix = "../" + dirChecksum + "/"
|
||||
@@ -476,7 +468,7 @@ type cureRes struct {
|
||||
// subject to the cures limit. Values pointed to by result addresses are safe
|
||||
// to access after the [sync.WaitGroup] associated with this pendingArtifactDep
|
||||
// is done. pendingArtifactDep must not be reused or modified after it is sent
|
||||
// to Cache.cureDep.
|
||||
// to cure.
|
||||
type pendingArtifactDep struct {
|
||||
// Dependency artifact populated during [Cache.Cure].
|
||||
a Artifact
|
||||
@@ -496,6 +488,49 @@ type pendingArtifactDep struct {
|
||||
*sync.WaitGroup
|
||||
}
|
||||
|
||||
const (
|
||||
// CValidateKnown arranges for [KnownChecksum] outcomes to be validated to
|
||||
// match its intended checksum.
|
||||
//
|
||||
// A correct implementation of [KnownChecksum] does not successfully cure
|
||||
// with output not matching its intended checksum. When an implementation
|
||||
// fails to perform this validation correctly, the on-disk format enters
|
||||
// an inconsistent state (correctable by [Cache.Scrub]).
|
||||
//
|
||||
// This flag causes [Cache.Cure] to always compute the checksum, and reject
|
||||
// a cure if it does not match the intended checksum.
|
||||
//
|
||||
// This behaviour significantly reduces performance and is not recommended
|
||||
// outside of testing a custom [Artifact] implementation.
|
||||
CValidateKnown = 1 << iota
|
||||
|
||||
// CSchedIdle arranges for the [ext.SCHED_IDLE] scheduling priority to be
|
||||
// set for [KindExec] and [KindExecNet] containers.
|
||||
CSchedIdle
|
||||
|
||||
// CAssumeChecksum enables the use of [KnownChecksum] for duplicate function
|
||||
// call suppression via the on-disk cache.
|
||||
//
|
||||
// This may cause incorrect cure outcome if an impossible checksum is
|
||||
// specified that matches an output already present in the on-disk cache.
|
||||
// This may be avoided by purposefully specifying a statistically
|
||||
// unattainable checksum, like the zero value.
|
||||
//
|
||||
// While this optimisation might seem appealing, it is almost never
|
||||
// applicable in real world use. Almost every time this path was taken, it
|
||||
// was caused by an incorrect checksum accidentally left behind while
|
||||
// bumping a package. Only enable this if you are really sure you need it.
|
||||
CAssumeChecksum
|
||||
|
||||
// CHostAbstract disables restriction of sandboxed processes from connecting
|
||||
// to an abstract UNIX socket created by a host process.
|
||||
//
|
||||
// This is considered less secure in some systems, but does not introduce
|
||||
// impurity due to [KindExecNet] being [KnownChecksum]. This flag exists
|
||||
// to support kernels without Landlock LSM enabled.
|
||||
CHostAbstract
|
||||
)
|
||||
|
||||
// Cache is a support layer that implementations of [Artifact] can use to store
|
||||
// cured [Artifact] data in a content addressed fashion.
|
||||
type Cache struct {
|
||||
@@ -515,12 +550,8 @@ type Cache struct {
|
||||
|
||||
// Directory where all [Cache] related files are placed.
|
||||
base *check.Absolute
|
||||
|
||||
// Whether to validate [FileArtifact.Cure] for a [KnownChecksum] file. This
|
||||
// significantly reduces performance.
|
||||
strict bool
|
||||
// Maximum size of a dependency graph.
|
||||
threshold uintptr
|
||||
// Immutable cure options set by [Open].
|
||||
flags int
|
||||
|
||||
// Artifact to [unique.Handle] of identifier cache.
|
||||
artifact sync.Map
|
||||
@@ -548,24 +579,11 @@ type Cache struct {
|
||||
unlock func()
|
||||
// Synchronises calls to Close.
|
||||
closeOnce sync.Once
|
||||
|
||||
// Whether EnterExec has not yet returned.
|
||||
inExec atomic.Bool
|
||||
}
|
||||
|
||||
// IsStrict returns whether the [Cache] strictly verifies checksums.
|
||||
func (c *Cache) IsStrict() bool { return c.strict }
|
||||
|
||||
// SetStrict sets whether the [Cache] strictly verifies checksums, even when
|
||||
// the implementation promises to validate them internally. This significantly
|
||||
// reduces performance and is not recommended outside of testing.
|
||||
//
|
||||
// This method is not safe for concurrent use with any other method.
|
||||
func (c *Cache) SetStrict(strict bool) { c.strict = strict }
|
||||
|
||||
// SetThreshold imposes a maximum size on the dependency graph, checked on every
|
||||
// call to Cure. The zero value disables this check entirely.
|
||||
//
|
||||
// This method is not safe for concurrent use with any other method.
|
||||
func (c *Cache) SetThreshold(threshold uintptr) { c.threshold = threshold }
|
||||
|
||||
// extIdent is a [Kind] concatenated with [ID].
|
||||
type extIdent [wordSize + len(ID{})]byte
|
||||
|
||||
@@ -880,7 +898,7 @@ func (c *Cache) Scrub(checks int) error {
|
||||
se.DanglingIdentifiers = append(se.DanglingIdentifiers, *want)
|
||||
seMu.Unlock()
|
||||
return false
|
||||
} else if err = Decode(got, path.Base(linkname)); err != nil {
|
||||
} else if err = Decode(got, filepath.Base(linkname)); err != nil {
|
||||
seMu.Lock()
|
||||
lnp := dir.Append(linkname)
|
||||
se.Errs[lnp.Handle()] = append(se.Errs[lnp.Handle()], err)
|
||||
@@ -1045,7 +1063,7 @@ func (c *Cache) finaliseIdent(
|
||||
// [FileArtifact] to the filesystem. If err is nil, the caller is responsible
|
||||
// for closing the resulting [io.ReadCloser].
|
||||
func (c *Cache) openFile(f FileArtifact) (r io.ReadCloser, err error) {
|
||||
if kc, ok := f.(KnownChecksum); ok {
|
||||
if kc, ok := f.(KnownChecksum); c.flags&CAssumeChecksum != 0 && ok {
|
||||
c.checksumMu.RLock()
|
||||
r, err = os.Open(c.base.Append(
|
||||
dirChecksum,
|
||||
@@ -1216,14 +1234,6 @@ func (e InvalidArtifactError) Error() string {
|
||||
return "artifact " + Encode(e) + " cannot be cured"
|
||||
}
|
||||
|
||||
// DependencyError refers to an artifact with a dependency tree larger than the
|
||||
// threshold specified by a previous call to [Cache.SetThreshold].
|
||||
type DependencyError struct{ A Artifact }
|
||||
|
||||
func (e DependencyError) Error() string {
|
||||
return "artifact has too many dependencies"
|
||||
}
|
||||
|
||||
// Cure cures the [Artifact] and returns its pathname and [Checksum]. Direct
|
||||
// calls to Cure are not subject to the cures limit.
|
||||
func (c *Cache) Cure(a Artifact) (
|
||||
@@ -1239,18 +1249,6 @@ func (c *Cache) Cure(a Artifact) (
|
||||
default:
|
||||
}
|
||||
|
||||
if c.threshold > 0 {
|
||||
var n uintptr
|
||||
for range Flood(a) {
|
||||
if n == c.threshold {
|
||||
err = DependencyError{a}
|
||||
return
|
||||
}
|
||||
n++
|
||||
}
|
||||
c.msg.Verbosef("visited %d artifacts", n)
|
||||
}
|
||||
|
||||
return c.cure(a, true)
|
||||
}
|
||||
|
||||
@@ -1474,7 +1472,7 @@ func (c *Cache) cure(a Artifact, curesExempt bool) (
|
||||
return
|
||||
}
|
||||
buf := c.getIdentBuf()
|
||||
err = Decode((*Checksum)(buf[:]), path.Base(name))
|
||||
err = Decode((*Checksum)(buf[:]), filepath.Base(name))
|
||||
if err == nil {
|
||||
checksum = unique.Make(Checksum(buf[:]))
|
||||
}
|
||||
@@ -1508,16 +1506,18 @@ func (c *Cache) cure(a Artifact, curesExempt bool) (
|
||||
checksums,
|
||||
)
|
||||
|
||||
c.checksumMu.RLock()
|
||||
checksumFi, err = os.Stat(checksumPathname.String())
|
||||
c.checksumMu.RUnlock()
|
||||
if c.flags&CAssumeChecksum != 0 {
|
||||
c.checksumMu.RLock()
|
||||
checksumFi, err = os.Stat(checksumPathname.String())
|
||||
c.checksumMu.RUnlock()
|
||||
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
|
||||
checksumFi, err = nil, nil
|
||||
}
|
||||
|
||||
checksumFi, err = nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1573,7 +1573,7 @@ func (c *Cache) cure(a Artifact, curesExempt bool) (
|
||||
}
|
||||
r, err = f.Cure(&RContext{common{c}})
|
||||
if err == nil {
|
||||
if checksumPathname == nil || c.IsStrict() {
|
||||
if checksumPathname == nil || c.flags&CValidateKnown != 0 {
|
||||
h := sha512.New384()
|
||||
hbw := c.getWriter(h)
|
||||
_, err = io.Copy(w, io.TeeReader(r, hbw))
|
||||
@@ -1590,7 +1590,7 @@ func (c *Cache) cure(a Artifact, curesExempt bool) (
|
||||
if checksumPathname == nil {
|
||||
checksum = unique.Make(Checksum(buf[:]))
|
||||
checksums = Encode(Checksum(buf[:]))
|
||||
} else if c.IsStrict() {
|
||||
} else if c.flags&CValidateKnown != 0 {
|
||||
if got := Checksum(buf[:]); got != checksum.Value() {
|
||||
err = &ChecksumMismatchError{
|
||||
Got: got,
|
||||
@@ -1828,10 +1828,10 @@ func (c *Cache) Close() {
|
||||
func Open(
|
||||
ctx context.Context,
|
||||
msg message.Msg,
|
||||
cures int,
|
||||
flags, cures int,
|
||||
base *check.Absolute,
|
||||
) (*Cache, error) {
|
||||
return open(ctx, msg, cures, base, true)
|
||||
return open(ctx, msg, flags, cures, base, true)
|
||||
}
|
||||
|
||||
// open implements Open but allows omitting the [lockedfile] lock when called
|
||||
@@ -1839,7 +1839,7 @@ func Open(
|
||||
func open(
|
||||
ctx context.Context,
|
||||
msg message.Msg,
|
||||
cures int,
|
||||
flags, cures int,
|
||||
base *check.Absolute,
|
||||
lock bool,
|
||||
) (*Cache, error) {
|
||||
@@ -1861,6 +1861,7 @@ func open(
|
||||
|
||||
c := Cache{
|
||||
cures: make(chan struct{}, cures),
|
||||
flags: flags,
|
||||
|
||||
msg: msg,
|
||||
base: base,
|
||||
@@ -1890,3 +1891,33 @@ func open(
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Collected is returned by [Collect.Cure] to indicate a successful collection.
|
||||
type Collected struct{}
|
||||
|
||||
// Error returns a constant string to satisfy error, but should never be seen
|
||||
// by the user.
|
||||
func (Collected) Error() string { return "artifacts successfully collected" }
|
||||
|
||||
// IsCollected returns whether the underlying error contains that of the result
|
||||
// of curing a [Collect] helper.
|
||||
func IsCollected(err error) bool { return errors.As(err, new(Collected)) }
|
||||
|
||||
// Collect implements [pkg.FloodArtifact] to concurrently cure multiple
|
||||
// [pkg.Artifact]. It returns [Collected].
|
||||
type Collect []Artifact
|
||||
|
||||
// Cure returns [Collected].
|
||||
func (*Collect) Cure(*FContext) error { return Collected{} }
|
||||
|
||||
// Kind returns the hardcoded [pkg.Kind] value.
|
||||
func (*Collect) Kind() Kind { return kindCollection }
|
||||
|
||||
// Params is a noop: dependencies are already represented in the header.
|
||||
func (*Collect) Params(*IContext) {}
|
||||
|
||||
// Dependencies returns [Collect] as is.
|
||||
func (c *Collect) Dependencies() []Artifact { return *c }
|
||||
|
||||
// IsExclusive returns false: Cure is a noop.
|
||||
func (*Collect) IsExclusive() bool { return false }
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"hakurei.app/check"
|
||||
"hakurei.app/container"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/internal/info"
|
||||
"hakurei.app/internal/pkg"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/message"
|
||||
@@ -33,7 +34,7 @@ import (
|
||||
func unsafeOpen(
|
||||
ctx context.Context,
|
||||
msg message.Msg,
|
||||
cures int,
|
||||
flags, cures int,
|
||||
base *check.Absolute,
|
||||
lock bool,
|
||||
) (*pkg.Cache, error)
|
||||
@@ -228,7 +229,7 @@ func TestIdent(t *testing.T) {
|
||||
var cache *pkg.Cache
|
||||
if a, err := check.NewAbs(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if cache, err = pkg.Open(t.Context(), msg, 0, a); err != nil {
|
||||
} else if cache, err = pkg.Open(t.Context(), msg, 0, 0, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(cache.Close)
|
||||
@@ -252,6 +253,7 @@ func TestIdent(t *testing.T) {
|
||||
// on test completion.
|
||||
type cacheTestCase struct {
|
||||
name string
|
||||
flags int
|
||||
early func(t *testing.T, base *check.Absolute)
|
||||
f func(t *testing.T, base *check.Absolute, c *pkg.Cache)
|
||||
want pkg.Checksum
|
||||
@@ -288,8 +290,20 @@ func checkWithCache(t *testing.T, testCases []cacheTestCase) {
|
||||
msg := message.New(log.New(os.Stderr, "cache: ", 0))
|
||||
msg.SwapVerbose(testing.Verbose())
|
||||
|
||||
flags := tc.flags
|
||||
|
||||
if info.CanDegrade {
|
||||
if _, err := container.LandlockGetABI(); err != nil {
|
||||
if !errors.Is(err, syscall.ENOSYS) {
|
||||
t.Fatalf("LandlockGetABI: error = %v", err)
|
||||
}
|
||||
flags |= pkg.CHostAbstract
|
||||
t.Log("Landlock LSM is unavailable, setting CHostAbstract")
|
||||
}
|
||||
}
|
||||
|
||||
var scrubFunc func() error // scrub after hashing
|
||||
if c, err := pkg.Open(t.Context(), msg, 1<<4, base); err != nil {
|
||||
if c, err := pkg.Open(t.Context(), msg, flags, 1<<4, base); err != nil {
|
||||
t.Fatalf("Open: error = %v", err)
|
||||
} else {
|
||||
t.Cleanup(c.Close)
|
||||
@@ -468,9 +482,7 @@ func TestCache(t *testing.T) {
|
||||
}()
|
||||
|
||||
testCases := []cacheTestCase{
|
||||
{"file", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
c.SetStrict(true)
|
||||
|
||||
{"file", pkg.CValidateKnown | pkg.CAssumeChecksum, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
identifier := (pkg.ID)(bytes.Repeat([]byte{
|
||||
0x75, 0xe6, 0x9d, 0x6d, 0xe7, 0x9f,
|
||||
}, 8))
|
||||
@@ -593,7 +605,7 @@ func TestCache(t *testing.T) {
|
||||
if c0, err := unsafeOpen(
|
||||
t.Context(),
|
||||
message.New(nil),
|
||||
0, base, false,
|
||||
0, 0, base, false,
|
||||
); err != nil {
|
||||
t.Fatalf("open: error = %v", err)
|
||||
} else {
|
||||
@@ -627,7 +639,7 @@ func TestCache(t *testing.T) {
|
||||
}
|
||||
}, pkg.MustDecode("St9rlE-mGZ5gXwiv_hzQ_B8bZP-UUvSNmf4nHUZzCMOumb6hKnheZSe0dmnuc4Q2")},
|
||||
|
||||
{"directory", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
{"directory", pkg.CAssumeChecksum, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
id := pkg.MustDecode(
|
||||
"HnySzeLQvSBZuTUcvfmLEX_OmH4yJWWH788NxuLuv7kVn8_uPM6Ks4rqFWM2NZJY",
|
||||
)
|
||||
@@ -804,9 +816,7 @@ func TestCache(t *testing.T) {
|
||||
})
|
||||
}, pkg.MustDecode("WVpvsVqVKg9Nsh744x57h51AuWUoUR2nnh8Md-EYBQpk6ziyTuUn6PLtF2e0Eu_d")},
|
||||
|
||||
{"pending", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
c.SetStrict(true)
|
||||
|
||||
{"pending", pkg.CValidateKnown, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
wantErr := stub.UniqueError(0xcafe)
|
||||
n, ready := make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
@@ -876,7 +886,54 @@ func TestCache(t *testing.T) {
|
||||
<-wCureDone
|
||||
}, pkg.MustDecode("E4vEZKhCcL2gPZ2Tt59FS3lDng-d_2SKa2i5G_RbDfwGn6EemptFaGLPUDiOa94C")},
|
||||
|
||||
{"scrub", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
{"no assume checksum", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
makeGarbage := func(work *check.Absolute, wantErr error) error {
|
||||
if err := os.Mkdir(work.String(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(work.Append(
|
||||
"check",
|
||||
).String(), nil, 0400); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return wantErr
|
||||
}
|
||||
|
||||
wantChecksum := pkg.MustDecode("Aubi5EG4_Y8DhL9bQ3Q4HFBhLRF7X5gt9D3CNCQfT-TeBtlRXc7Zi_JYZEMoCC7M")
|
||||
|
||||
cureMany(t, c, []cureStep{
|
||||
{"create", overrideChecksum{wantChecksum, overrideIdent{pkg.ID{0xff, 0}, &stubArtifact{
|
||||
kind: pkg.KindTar,
|
||||
cure: func(t *pkg.TContext) error {
|
||||
return makeGarbage(t.GetWorkDir(), nil)
|
||||
},
|
||||
}}}, base.Append(
|
||||
"identifier",
|
||||
pkg.Encode(pkg.ID{0xff, 0}),
|
||||
), wantChecksum, nil},
|
||||
|
||||
{"reject", overrideChecksum{wantChecksum, overrideIdent{pkg.ID{0xfe, 1}, &stubArtifact{
|
||||
kind: pkg.KindTar,
|
||||
cure: func(t *pkg.TContext) error {
|
||||
return makeGarbage(t.GetWorkDir(), stub.UniqueError(0xbad))
|
||||
},
|
||||
}}}, nil, pkg.Checksum{}, stub.UniqueError(0xbad)},
|
||||
|
||||
{"match", overrideChecksum{wantChecksum, overrideIdent{pkg.ID{0xff, 1}, &stubArtifact{
|
||||
kind: pkg.KindTar,
|
||||
cure: func(t *pkg.TContext) error {
|
||||
return makeGarbage(t.GetWorkDir(), nil)
|
||||
},
|
||||
}}}, base.Append(
|
||||
"identifier",
|
||||
pkg.Encode(pkg.ID{0xff, 1}),
|
||||
), wantChecksum, nil},
|
||||
})
|
||||
}, pkg.MustDecode("OC290t23aimNo2Rp2pPwan5GI2KRLRdOwYxXQMD9jw0QROgHnNXWodoWdV0hwu2w")},
|
||||
|
||||
{"scrub", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
cureMany(t, c, []cureStep{
|
||||
{"bad measured file", newStubFile(
|
||||
pkg.KindHTTPGet,
|
||||
@@ -1182,7 +1239,7 @@ func (a earlyFailureF) Cure(*pkg.FContext) error {
|
||||
|
||||
func TestDependencyCureErrorEarly(t *testing.T) {
|
||||
checkWithCache(t, []cacheTestCase{
|
||||
{"early", nil, func(t *testing.T, _ *check.Absolute, c *pkg.Cache) {
|
||||
{"early", 0, nil, func(t *testing.T, _ *check.Absolute, c *pkg.Cache) {
|
||||
_, _, err := c.Cure(earlyFailureF(8))
|
||||
if !errors.Is(err, stub.UniqueError(0xcafe)) {
|
||||
t.Fatalf("Cure: error = %v", err)
|
||||
@@ -1205,7 +1262,7 @@ func TestNew(t *testing.T) {
|
||||
if _, err := pkg.Open(
|
||||
t.Context(),
|
||||
message.New(nil),
|
||||
0, check.MustAbs(container.Nonexistent),
|
||||
0, 0, check.MustAbs(container.Nonexistent),
|
||||
); !reflect.DeepEqual(err, wantErr) {
|
||||
t.Errorf("Open: error = %#v, want %#v", err, wantErr)
|
||||
}
|
||||
@@ -1233,7 +1290,7 @@ func TestNew(t *testing.T) {
|
||||
if _, err := pkg.Open(
|
||||
t.Context(),
|
||||
message.New(nil),
|
||||
0, tempDir.Append("cache"),
|
||||
0, 0, tempDir.Append("cache"),
|
||||
); !reflect.DeepEqual(err, wantErr) {
|
||||
t.Errorf("Open: error = %#v, want %#v", err, wantErr)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -169,7 +169,7 @@ func (a *tarArtifact) Cure(t *TContext) (err error) {
|
||||
}
|
||||
|
||||
if typeflag >= '0' && typeflag <= '9' && typeflag != tar.TypeDir {
|
||||
if err = root.MkdirAll(path.Dir(header.Name), 0700); err != nil {
|
||||
if err = root.MkdirAll(filepath.Dir(header.Name), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestTar(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checkWithCache(t, []cacheTestCase{
|
||||
{"http", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
{"http", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
checkTarHTTP(t, base, c, fstest.MapFS{
|
||||
".": {Mode: fs.ModeDir | 0700},
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestTar(t *testing.T) {
|
||||
))
|
||||
}, pkg.MustDecode("NQTlc466JmSVLIyWklm_u8_g95jEEb98PxJU-kjwxLpfdjwMWJq0G8ze9R4Vo1Vu")},
|
||||
|
||||
{"http expand", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
{"http expand", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
|
||||
checkTarHTTP(t, base, c, fstest.MapFS{
|
||||
".": {Mode: fs.ModeDir | 0700},
|
||||
|
||||
|
||||
18
internal/pkg/testdata/main.go
vendored
18
internal/pkg/testdata/main.go
vendored
@@ -7,7 +7,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -68,7 +68,7 @@ func main() {
|
||||
if got, err := os.Executable(); err != nil {
|
||||
log.Fatalf("Executable: error = %v", err)
|
||||
} else {
|
||||
iftPath = path.Join(path.Dir(path.Dir(got)), "ift")
|
||||
iftPath = filepath.Join(filepath.Dir(filepath.Dir(got)), "ift")
|
||||
|
||||
if got != wantExec {
|
||||
switch got {
|
||||
@@ -161,7 +161,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
if !layers {
|
||||
if path.Base(lowerdir) != checksumEmptyDir {
|
||||
if filepath.Base(lowerdir) != checksumEmptyDir {
|
||||
log.Fatal("unexpected artifact checksum")
|
||||
}
|
||||
} else {
|
||||
@@ -187,8 +187,8 @@ func main() {
|
||||
}
|
||||
|
||||
if len(lowerdirs) != 2 ||
|
||||
path.Base(lowerdirs[0]) != "MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU" ||
|
||||
path.Base(lowerdirs[1]) != "nY_CUdiaUM1OL4cPr5TS92FCJ3rCRV7Hm5oVTzAvMXwC03_QnTRfQ5PPs7mOU9fK" {
|
||||
filepath.Base(lowerdirs[0]) != "MGWmEfjut2QE2xPJwTsmUzpff4BN_FEnQ7T0j7gvUCCiugJQNwqt9m151fm9D1yU" ||
|
||||
filepath.Base(lowerdirs[1]) != "nY_CUdiaUM1OL4cPr5TS92FCJ3rCRV7Hm5oVTzAvMXwC03_QnTRfQ5PPs7mOU9fK" {
|
||||
log.Fatalf("unexpected lowerdirs %s", strings.Join(lowerdirs, ", "))
|
||||
}
|
||||
}
|
||||
@@ -202,12 +202,12 @@ func main() {
|
||||
}
|
||||
|
||||
next()
|
||||
if path.Base(m.Root) != "OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb" {
|
||||
if filepath.Base(m.Root) != "OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb" {
|
||||
log.Fatal("unexpected file artifact checksum")
|
||||
}
|
||||
|
||||
next()
|
||||
if path.Base(m.Root) != checksumEmptyDir {
|
||||
if filepath.Base(m.Root) != checksumEmptyDir {
|
||||
log.Fatal("unexpected artifact checksum")
|
||||
}
|
||||
}
|
||||
@@ -226,13 +226,13 @@ func main() {
|
||||
log.Fatal("unexpected work mount entry")
|
||||
}
|
||||
} else {
|
||||
if path.Base(m.Root) != ident || m.Target != "/work" {
|
||||
if filepath.Base(m.Root) != ident || m.Target != "/work" {
|
||||
log.Fatal("unexpected work mount entry")
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
if path.Base(m.Root) != ident || m.Target != "/tmp" {
|
||||
if filepath.Base(m.Root) != ident || m.Target != "/tmp" {
|
||||
log.Fatal("unexpected temp mount entry")
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user