Compare commits
53 Commits
v0.4.0
...
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 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -7,6 +7,14 @@
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
@@ -30,16 +30,6 @@ func (e AbsoluteError) Is(target error) bool {
|
||||
// Absolute holds a pathname checked to be absolute.
|
||||
type Absolute struct{ pathname unique.Handle[string] }
|
||||
|
||||
var (
|
||||
_ encoding.TextAppender = new(Absolute)
|
||||
_ encoding.TextMarshaler = new(Absolute)
|
||||
_ encoding.TextUnmarshaler = new(Absolute)
|
||||
|
||||
_ encoding.BinaryAppender = new(Absolute)
|
||||
_ encoding.BinaryMarshaler = new(Absolute)
|
||||
_ encoding.BinaryUnmarshaler = new(Absolute)
|
||||
)
|
||||
|
||||
// ok returns whether [Absolute] is not the zero value.
|
||||
func (a *Absolute) ok() bool { return a != nil && *a != (Absolute{}) }
|
||||
|
||||
@@ -94,16 +84,13 @@ func (a *Absolute) Append(elem ...string) *Absolute {
|
||||
// Dir calls [filepath.Dir] with [Absolute] as its argument.
|
||||
func (a *Absolute) Dir() *Absolute { return unsafeAbs(filepath.Dir(a.String())) }
|
||||
|
||||
// AppendText appends the checked pathname.
|
||||
func (a *Absolute) AppendText(data []byte) ([]byte, error) {
|
||||
return append(data, a.String()...), nil
|
||||
// GobEncode returns the checked pathname.
|
||||
func (a *Absolute) GobEncode() ([]byte, error) {
|
||||
return []byte(a.String()), nil
|
||||
}
|
||||
|
||||
// MarshalText returns the checked pathname.
|
||||
func (a *Absolute) MarshalText() ([]byte, error) { return a.AppendText(nil) }
|
||||
|
||||
// UnmarshalText stores data if it represents an absolute pathname.
|
||||
func (a *Absolute) UnmarshalText(data []byte) error {
|
||||
// GobDecode stores data if it represents an absolute pathname.
|
||||
func (a *Absolute) GobDecode(data []byte) error {
|
||||
pathname := string(data)
|
||||
if !filepath.IsAbs(pathname) {
|
||||
return AbsoluteError(pathname)
|
||||
@@ -112,9 +99,23 @@ func (a *Absolute) UnmarshalText(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Absolute) AppendBinary(data []byte) ([]byte, error) { return a.AppendText(data) }
|
||||
func (a *Absolute) MarshalBinary() ([]byte, error) { return a.MarshalText() }
|
||||
func (a *Absolute) UnmarshalBinary(data []byte) error { return a.UnmarshalText(data) }
|
||||
// MarshalJSON returns a JSON representation of the checked pathname.
|
||||
func (a *Absolute) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(a.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON stores data if it represents an absolute pathname.
|
||||
func (a *Absolute) UnmarshalJSON(data []byte) error {
|
||||
var pathname string
|
||||
if err := json.Unmarshal(data, &pathname); err != nil {
|
||||
return err
|
||||
}
|
||||
if !filepath.IsAbs(pathname) {
|
||||
return AbsoluteError(pathname)
|
||||
}
|
||||
a.pathname = unique.Make(pathname)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortAbs calls [slices.SortFunc] for a slice of [Absolute].
|
||||
func SortAbs(x []*Absolute) {
|
||||
|
||||
@@ -170,20 +170,20 @@ func TestCodecAbsolute(t *testing.T) {
|
||||
|
||||
{"good", MustAbs("/etc"),
|
||||
nil,
|
||||
"\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\b\xff\x80\x00\x04/etc",
|
||||
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\x0f\xff\x84\x01\x04/etc\x01\xfc\xc0\xed\x00\x00\x00",
|
||||
"\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\b\xff\x80\x00\x04/etc",
|
||||
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\x0f\xff\x84\x01\x04/etc\x01\xfc\xc0\xed\x00\x00\x00",
|
||||
|
||||
`"/etc"`, `{"val":"/etc","magic":3236757504}`},
|
||||
{"not absolute", nil,
|
||||
AbsoluteError("etc"),
|
||||
"\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\a\xff\x80\x00\x03etc",
|
||||
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\x0f\xff\x84\x01\x03etc\x01\xfb\x01\x81\xda\x00\x00\x00",
|
||||
"\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\a\xff\x80\x00\x03etc",
|
||||
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\x0f\xff\x84\x01\x03etc\x01\xfb\x01\x81\xda\x00\x00\x00",
|
||||
|
||||
`"etc"`, `{"val":"etc","magic":3236757504}`},
|
||||
{"zero", nil,
|
||||
new(AbsoluteError),
|
||||
"\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\x04\xff\x80\x00\x00",
|
||||
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x06\x01\x02\xff\x82\x00\x00\x00\f\xff\x84\x01\x00\x01\xfb\x01\x81\xda\x00\x00\x00",
|
||||
"\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\x04\xff\x80\x00\x00",
|
||||
",\xff\x83\x03\x01\x01\x06sCheck\x01\xff\x84\x00\x01\x02\x01\bPathname\x01\xff\x80\x00\x01\x05Magic\x01\x06\x00\x00\x00\t\x7f\x05\x01\x02\xff\x82\x00\x00\x00\f\xff\x84\x01\x00\x01\xfb\x01\x81\xda\x00\x00\x00",
|
||||
`""`, `{"val":"","magic":3236757504}`},
|
||||
}
|
||||
|
||||
@@ -347,6 +347,15 @@ func TestCodecAbsolute(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("json passthrough", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantErr := "invalid character ':' looking for beginning of value"
|
||||
if err := new(Absolute).UnmarshalJSON([]byte(":3")); err == nil || err.Error() != wantErr {
|
||||
t.Errorf("UnmarshalJSON: error = %v, want %s", err, wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAbsoluteWrap(t *testing.T) {
|
||||
|
||||
@@ -38,9 +38,8 @@ var errSuccess = errors.New("success")
|
||||
|
||||
func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErrs, out io.Writer) command.Command {
|
||||
var (
|
||||
flagVerbose bool
|
||||
flagInsecure bool
|
||||
flagJSON bool
|
||||
flagVerbose bool
|
||||
flagJSON bool
|
||||
)
|
||||
c := command.New(out, log.Printf, "hakurei", func([]string) error {
|
||||
msg.SwapVerbose(flagVerbose)
|
||||
@@ -58,7 +57,6 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
return nil
|
||||
}).
|
||||
Flag(&flagVerbose, "v", command.BoolFlag(false), "Increase log verbosity").
|
||||
Flag(&flagInsecure, "insecure", command.BoolFlag(false), "Allow use of insecure compatibility options").
|
||||
Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output in JSON when applicable")
|
||||
|
||||
c.Command("shim", command.UsageInternal, func([]string) error { outcome.Shim(msg); return errSuccess })
|
||||
@@ -77,12 +75,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
config.Container.Args = append(config.Container.Args, args[1:]...)
|
||||
}
|
||||
|
||||
var flags int
|
||||
if flagInsecure {
|
||||
flags |= hst.VAllowInsecure
|
||||
}
|
||||
|
||||
outcome.Main(ctx, msg, config, flags, flagIdentifierFile)
|
||||
outcome.Main(ctx, msg, config, flagIdentifierFile)
|
||||
panic("unreachable")
|
||||
}).
|
||||
Flag(&flagIdentifierFile, "identifier-fd", command.IntFlag(-1),
|
||||
@@ -152,7 +145,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
}
|
||||
}
|
||||
|
||||
var et hst.Enablements
|
||||
var et hst.Enablement
|
||||
if flagWayland {
|
||||
et |= hst.EWayland
|
||||
}
|
||||
@@ -170,7 +163,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
ID: flagID,
|
||||
Identity: flagIdentity,
|
||||
Groups: flagGroups,
|
||||
Enablements: &et,
|
||||
Enablements: hst.NewEnablements(et),
|
||||
|
||||
Container: &hst.ContainerConfig{
|
||||
Filesystem: []hst.FilesystemConfigJSON{
|
||||
@@ -289,7 +282,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
|
||||
}
|
||||
}
|
||||
|
||||
outcome.Main(ctx, msg, &config, 0, -1)
|
||||
outcome.Main(ctx, msg, &config, -1)
|
||||
panic("unreachable")
|
||||
}).
|
||||
Flag(&flagDBusConfigSession, "dbus-config", command.StringFlag("builtin"),
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestHelp(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
"main", []string{}, `
|
||||
Usage: hakurei [-h | --help] [-v] [--insecure] [--json] COMMAND [OPTIONS]
|
||||
Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS]
|
||||
|
||||
Commands:
|
||||
run Load and start container from configuration file
|
||||
|
||||
@@ -56,7 +56,7 @@ func printShowInstance(
|
||||
t := newPrinter(output)
|
||||
defer t.MustFlush()
|
||||
|
||||
if err := config.Validate(hst.VAllowInsecure); err != nil {
|
||||
if err := config.Validate(); err != nil {
|
||||
valid = false
|
||||
if m, ok := message.GetMessage(err); ok {
|
||||
mustPrint(output, "Error: "+m+"!\n\n")
|
||||
|
||||
@@ -32,7 +32,7 @@ var (
|
||||
PID: 0xbeef,
|
||||
ShimPID: 0xcafe,
|
||||
Config: &hst.Config{
|
||||
Enablements: new(hst.EWayland | hst.EPipeWire),
|
||||
Enablements: hst.NewEnablements(hst.EWayland | hst.EPipeWire),
|
||||
Identity: 1,
|
||||
Container: &hst.ContainerConfig{
|
||||
Shell: check.MustAbs("/bin/sh"),
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"hakurei.app/container/std"
|
||||
"hakurei.app/ext"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/internal/landlock"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
@@ -308,7 +307,7 @@ func (p *Container) Start() error {
|
||||
done <- func() error {
|
||||
// PR_SET_NO_NEW_PRIVS: thread-directed but acts on all processes
|
||||
// created from the calling thread
|
||||
if err := setNoNewPrivs(); err != nil {
|
||||
if err := SetNoNewPrivs(); err != nil {
|
||||
return &StartError{
|
||||
Fatal: true,
|
||||
Step: "prctl(PR_SET_NO_NEW_PRIVS)",
|
||||
@@ -318,14 +317,12 @@ func (p *Container) Start() error {
|
||||
|
||||
// landlock: depends on per-thread state but acts on a process group
|
||||
{
|
||||
rulesetAttr := &landlock.RulesetAttr{
|
||||
Scoped: landlock.LANDLOCK_SCOPE_SIGNAL,
|
||||
}
|
||||
rulesetAttr := &RulesetAttr{Scoped: LANDLOCK_SCOPE_SIGNAL}
|
||||
if !p.HostAbstract {
|
||||
rulesetAttr.Scoped |= landlock.LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET
|
||||
rulesetAttr.Scoped |= LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET
|
||||
}
|
||||
|
||||
if abi, err := landlock.GetABI(); err != nil {
|
||||
if abi, err := LandlockGetABI(); err != nil {
|
||||
if p.HostAbstract || !p.HostNet {
|
||||
// landlock can be skipped here as it restricts access
|
||||
// to resources already covered by namespaces (pid, net)
|
||||
@@ -354,7 +351,7 @@ func (p *Container) Start() error {
|
||||
}
|
||||
} else {
|
||||
p.msg.Verbosef("enforcing landlock ruleset %s", rulesetAttr)
|
||||
if err = landlock.RestrictSelf(rulesetFd, 0); err != nil {
|
||||
if err = LandlockRestrictSelf(rulesetFd, 0); err != nil {
|
||||
_ = Close(rulesetFd)
|
||||
return &StartError{
|
||||
Fatal: true,
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/hst"
|
||||
"hakurei.app/internal/info"
|
||||
"hakurei.app/internal/landlock"
|
||||
"hakurei.app/internal/params"
|
||||
"hakurei.app/ldd"
|
||||
"hakurei.app/message"
|
||||
@@ -457,7 +456,7 @@ func TestContainer(t *testing.T) {
|
||||
c.RetainSession = tc.session
|
||||
c.HostNet = tc.net
|
||||
if info.CanDegrade {
|
||||
if _, err := landlock.GetABI(); err != nil {
|
||||
if _, err := container.LandlockGetABI(); err != nil {
|
||||
if !errors.Is(err, syscall.ENOSYS) {
|
||||
t.Fatalf("LandlockGetABI: error = %v", err)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func (direct) lockOSThread() { runtime.LockOSThread() }
|
||||
|
||||
func (direct) setPtracer(pid uintptr) error { return ext.SetPtracer(pid) }
|
||||
func (direct) setDumpable(dumpable uintptr) error { return ext.SetDumpable(dumpable) }
|
||||
func (direct) setNoNewPrivs() error { return setNoNewPrivs() }
|
||||
func (direct) setNoNewPrivs() error { return SetNoNewPrivs() }
|
||||
|
||||
func (direct) lastcap(msg message.Msg) uintptr { return LastCap(msg) }
|
||||
func (direct) capset(hdrp *capHeader, datap *[2]capData) error { return capset(hdrp, datap) }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package landlock
|
||||
package container
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -14,11 +14,11 @@ const (
|
||||
LANDLOCK_CREATE_RULESET_VERSION = 1 << iota
|
||||
)
|
||||
|
||||
// AccessFS is bitmask of handled filesystem actions.
|
||||
type AccessFS uint64
|
||||
// LandlockAccessFS is bitmask of handled filesystem actions.
|
||||
type LandlockAccessFS uint64
|
||||
|
||||
const (
|
||||
LANDLOCK_ACCESS_FS_EXECUTE AccessFS = 1 << iota
|
||||
LANDLOCK_ACCESS_FS_EXECUTE LandlockAccessFS = 1 << iota
|
||||
LANDLOCK_ACCESS_FS_WRITE_FILE
|
||||
LANDLOCK_ACCESS_FS_READ_FILE
|
||||
LANDLOCK_ACCESS_FS_READ_DIR
|
||||
@@ -38,8 +38,8 @@ const (
|
||||
_LANDLOCK_ACCESS_FS_DELIM
|
||||
)
|
||||
|
||||
// String returns a space-separated string of [AccessFS] flags.
|
||||
func (f AccessFS) String() string {
|
||||
// String returns a space-separated string of [LandlockAccessFS] flags.
|
||||
func (f LandlockAccessFS) String() string {
|
||||
switch f {
|
||||
case LANDLOCK_ACCESS_FS_EXECUTE:
|
||||
return "execute"
|
||||
@@ -90,8 +90,8 @@ func (f AccessFS) String() string {
|
||||
return "fs_ioctl_dev"
|
||||
|
||||
default:
|
||||
var c []AccessFS
|
||||
for i := AccessFS(1); i < _LANDLOCK_ACCESS_FS_DELIM; i <<= 1 {
|
||||
var c []LandlockAccessFS
|
||||
for i := LandlockAccessFS(1); i < _LANDLOCK_ACCESS_FS_DELIM; i <<= 1 {
|
||||
if f&i != 0 {
|
||||
c = append(c, i)
|
||||
}
|
||||
@@ -107,18 +107,18 @@ func (f AccessFS) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// AccessNet is bitmask of handled network actions.
|
||||
type AccessNet uint64
|
||||
// LandlockAccessNet is bitmask of handled network actions.
|
||||
type LandlockAccessNet uint64
|
||||
|
||||
const (
|
||||
LANDLOCK_ACCESS_NET_BIND_TCP AccessNet = 1 << iota
|
||||
LANDLOCK_ACCESS_NET_BIND_TCP LandlockAccessNet = 1 << iota
|
||||
LANDLOCK_ACCESS_NET_CONNECT_TCP
|
||||
|
||||
_LANDLOCK_ACCESS_NET_DELIM
|
||||
)
|
||||
|
||||
// String returns a space-separated string of [AccessNet] flags.
|
||||
func (f AccessNet) String() string {
|
||||
// String returns a space-separated string of [LandlockAccessNet] flags.
|
||||
func (f LandlockAccessNet) String() string {
|
||||
switch f {
|
||||
case LANDLOCK_ACCESS_NET_BIND_TCP:
|
||||
return "bind_tcp"
|
||||
@@ -127,8 +127,8 @@ func (f AccessNet) String() string {
|
||||
return "connect_tcp"
|
||||
|
||||
default:
|
||||
var c []AccessNet
|
||||
for i := AccessNet(1); i < _LANDLOCK_ACCESS_NET_DELIM; i <<= 1 {
|
||||
var c []LandlockAccessNet
|
||||
for i := LandlockAccessNet(1); i < _LANDLOCK_ACCESS_NET_DELIM; i <<= 1 {
|
||||
if f&i != 0 {
|
||||
c = append(c, i)
|
||||
}
|
||||
@@ -144,18 +144,18 @@ func (f AccessNet) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Scope is bitmask of scopes restricting a Landlock domain from accessing outside resources.
|
||||
type Scope uint64
|
||||
// LandlockScope is bitmask of scopes restricting a Landlock domain from accessing outside resources.
|
||||
type LandlockScope uint64
|
||||
|
||||
const (
|
||||
LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET Scope = 1 << iota
|
||||
LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET LandlockScope = 1 << iota
|
||||
LANDLOCK_SCOPE_SIGNAL
|
||||
|
||||
_LANDLOCK_SCOPE_DELIM
|
||||
)
|
||||
|
||||
// String returns a space-separated string of [Scope] flags.
|
||||
func (f Scope) String() string {
|
||||
// String returns a space-separated string of [LandlockScope] flags.
|
||||
func (f LandlockScope) String() string {
|
||||
switch f {
|
||||
case LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET:
|
||||
return "abstract_unix_socket"
|
||||
@@ -164,8 +164,8 @@ func (f Scope) String() string {
|
||||
return "signal"
|
||||
|
||||
default:
|
||||
var c []Scope
|
||||
for i := Scope(1); i < _LANDLOCK_SCOPE_DELIM; i <<= 1 {
|
||||
var c []LandlockScope
|
||||
for i := LandlockScope(1); i < _LANDLOCK_SCOPE_DELIM; i <<= 1 {
|
||||
if f&i != 0 {
|
||||
c = append(c, i)
|
||||
}
|
||||
@@ -184,12 +184,12 @@ func (f Scope) String() string {
|
||||
// RulesetAttr is equivalent to struct landlock_ruleset_attr.
|
||||
type RulesetAttr struct {
|
||||
// Bitmask of handled filesystem actions.
|
||||
HandledAccessFS AccessFS
|
||||
HandledAccessFS LandlockAccessFS
|
||||
// Bitmask of handled network actions.
|
||||
HandledAccessNet AccessNet
|
||||
HandledAccessNet LandlockAccessNet
|
||||
// Bitmask of scopes restricting a Landlock domain from accessing outside
|
||||
// resources (e.g. IPCs).
|
||||
Scoped Scope
|
||||
Scoped LandlockScope
|
||||
}
|
||||
|
||||
// String returns a user-facing description of [RulesetAttr].
|
||||
@@ -239,13 +239,13 @@ func (rulesetAttr *RulesetAttr) Create(flags uintptr) (fd int, err error) {
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// GetABI returns the ABI version supported by the kernel.
|
||||
func GetABI() (int, error) {
|
||||
// LandlockGetABI returns the ABI version supported by the kernel.
|
||||
func LandlockGetABI() (int, error) {
|
||||
return (*RulesetAttr)(nil).Create(LANDLOCK_CREATE_RULESET_VERSION)
|
||||
}
|
||||
|
||||
// RestrictSelf applies a loaded ruleset to the calling thread.
|
||||
func RestrictSelf(rulesetFd int, flags uintptr) error {
|
||||
// LandlockRestrictSelf applies a loaded ruleset to the calling thread.
|
||||
func LandlockRestrictSelf(rulesetFd int, flags uintptr) error {
|
||||
r, _, errno := syscall.Syscall(
|
||||
ext.SYS_LANDLOCK_RESTRICT_SELF,
|
||||
uintptr(rulesetFd),
|
||||
65
container/landlock_test.go
Normal file
65
container/landlock_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package container_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"hakurei.app/container"
|
||||
)
|
||||
|
||||
func TestLandlockString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
rulesetAttr *container.RulesetAttr
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, "NULL"},
|
||||
{"zero", new(container.RulesetAttr), "0"},
|
||||
{"some", &container.RulesetAttr{Scoped: container.LANDLOCK_SCOPE_SIGNAL}, "scoped: signal"},
|
||||
{"set", &container.RulesetAttr{
|
||||
HandledAccessFS: container.LANDLOCK_ACCESS_FS_MAKE_SYM | container.LANDLOCK_ACCESS_FS_IOCTL_DEV | container.LANDLOCK_ACCESS_FS_WRITE_FILE,
|
||||
HandledAccessNet: container.LANDLOCK_ACCESS_NET_BIND_TCP,
|
||||
Scoped: container.LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | container.LANDLOCK_SCOPE_SIGNAL,
|
||||
}, "fs: write_file make_sym fs_ioctl_dev, net: bind_tcp, scoped: abstract_unix_socket signal"},
|
||||
{"all", &container.RulesetAttr{
|
||||
HandledAccessFS: container.LANDLOCK_ACCESS_FS_EXECUTE |
|
||||
container.LANDLOCK_ACCESS_FS_WRITE_FILE |
|
||||
container.LANDLOCK_ACCESS_FS_READ_FILE |
|
||||
container.LANDLOCK_ACCESS_FS_READ_DIR |
|
||||
container.LANDLOCK_ACCESS_FS_REMOVE_DIR |
|
||||
container.LANDLOCK_ACCESS_FS_REMOVE_FILE |
|
||||
container.LANDLOCK_ACCESS_FS_MAKE_CHAR |
|
||||
container.LANDLOCK_ACCESS_FS_MAKE_DIR |
|
||||
container.LANDLOCK_ACCESS_FS_MAKE_REG |
|
||||
container.LANDLOCK_ACCESS_FS_MAKE_SOCK |
|
||||
container.LANDLOCK_ACCESS_FS_MAKE_FIFO |
|
||||
container.LANDLOCK_ACCESS_FS_MAKE_BLOCK |
|
||||
container.LANDLOCK_ACCESS_FS_MAKE_SYM |
|
||||
container.LANDLOCK_ACCESS_FS_REFER |
|
||||
container.LANDLOCK_ACCESS_FS_TRUNCATE |
|
||||
container.LANDLOCK_ACCESS_FS_IOCTL_DEV,
|
||||
HandledAccessNet: container.LANDLOCK_ACCESS_NET_BIND_TCP |
|
||||
container.LANDLOCK_ACCESS_NET_CONNECT_TCP,
|
||||
Scoped: container.LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET |
|
||||
container.LANDLOCK_SCOPE_SIGNAL,
|
||||
}, "fs: execute write_file read_file read_dir remove_dir remove_file make_char make_dir make_reg make_sock make_fifo make_block make_sym fs_refer fs_truncate fs_ioctl_dev, net: bind_tcp connect_tcp, scoped: abstract_unix_socket signal"},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tc.rulesetAttr.String(); got != tc.want {
|
||||
t.Errorf("String: %s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandlockAttrSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := 24
|
||||
if got := unsafe.Sizeof(container.RulesetAttr{}); got != uintptr(want) {
|
||||
t.Errorf("Sizeof: %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"hakurei.app/ext"
|
||||
)
|
||||
|
||||
// setNoNewPrivs sets the calling thread's no_new_privs attribute.
|
||||
func setNoNewPrivs() error {
|
||||
// SetNoNewPrivs sets the calling thread's no_new_privs attribute.
|
||||
func SetNoNewPrivs() error {
|
||||
return ext.Prctl(PR_SET_NO_NEW_PRIVS, 1, 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -140,29 +140,21 @@ var (
|
||||
ErrInsecure = errors.New("configuration is insecure")
|
||||
)
|
||||
|
||||
const (
|
||||
// VAllowInsecure allows use of compatibility options considered insecure
|
||||
// under any configuration, to work around ecosystem-wide flaws.
|
||||
VAllowInsecure = 1 << iota
|
||||
)
|
||||
|
||||
// Validate checks [Config] and returns [AppError] if an invalid value is encountered.
|
||||
func (config *Config) Validate(flags int) error {
|
||||
const step = "validate configuration"
|
||||
|
||||
func (config *Config) Validate() error {
|
||||
if config == nil {
|
||||
return &AppError{Step: step, Err: ErrConfigNull,
|
||||
return &AppError{Step: "validate configuration", Err: ErrConfigNull,
|
||||
Msg: "invalid configuration"}
|
||||
}
|
||||
|
||||
// this is checked again in hsu
|
||||
if config.Identity < IdentityStart || config.Identity > IdentityEnd {
|
||||
return &AppError{Step: step, Err: ErrIdentityBounds,
|
||||
return &AppError{Step: "validate configuration", Err: ErrIdentityBounds,
|
||||
Msg: "identity " + strconv.Itoa(config.Identity) + " out of range"}
|
||||
}
|
||||
|
||||
if config.SchedPolicy < 0 || config.SchedPolicy > ext.SCHED_LAST {
|
||||
return &AppError{Step: step, Err: ErrSchedPolicyBounds,
|
||||
return &AppError{Step: "validate configuration", Err: ErrSchedPolicyBounds,
|
||||
Msg: "scheduling policy " +
|
||||
strconv.Itoa(int(config.SchedPolicy)) +
|
||||
" out of range"}
|
||||
@@ -176,51 +168,34 @@ func (config *Config) Validate(flags int) error {
|
||||
}
|
||||
|
||||
if config.Container == nil {
|
||||
return &AppError{Step: step, Err: ErrConfigNull,
|
||||
return &AppError{Step: "validate configuration", Err: ErrConfigNull,
|
||||
Msg: "configuration missing container state"}
|
||||
}
|
||||
if config.Container.Home == nil {
|
||||
return &AppError{Step: step, Err: ErrConfigNull,
|
||||
return &AppError{Step: "validate configuration", Err: ErrConfigNull,
|
||||
Msg: "container configuration missing path to home directory"}
|
||||
}
|
||||
if config.Container.Shell == nil {
|
||||
return &AppError{Step: step, Err: ErrConfigNull,
|
||||
return &AppError{Step: "validate configuration", Err: ErrConfigNull,
|
||||
Msg: "container configuration missing path to shell"}
|
||||
}
|
||||
if config.Container.Path == nil {
|
||||
return &AppError{Step: step, Err: ErrConfigNull,
|
||||
return &AppError{Step: "validate configuration", Err: ErrConfigNull,
|
||||
Msg: "container configuration missing path to initial program"}
|
||||
}
|
||||
|
||||
for key := range config.Container.Env {
|
||||
if strings.IndexByte(key, '=') != -1 || strings.IndexByte(key, 0) != -1 {
|
||||
return &AppError{Step: step, Err: ErrEnviron,
|
||||
return &AppError{Step: "validate configuration", Err: ErrEnviron,
|
||||
Msg: "invalid environment variable " + strconv.Quote(key)}
|
||||
}
|
||||
}
|
||||
|
||||
et := config.Enablements.Unwrap()
|
||||
if !config.DirectPulse && et&EPulse != 0 {
|
||||
return &AppError{Step: step, Err: ErrInsecure,
|
||||
if et := config.Enablements.Unwrap(); !config.DirectPulse && et&EPulse != 0 {
|
||||
return &AppError{Step: "validate configuration", Err: ErrInsecure,
|
||||
Msg: "enablement PulseAudio is insecure and no longer supported"}
|
||||
}
|
||||
|
||||
if flags&VAllowInsecure == 0 {
|
||||
switch {
|
||||
case et&EWayland != 0 && config.DirectWayland:
|
||||
return &AppError{Step: step, Err: ErrInsecure,
|
||||
Msg: "direct_wayland is insecure and no longer supported"}
|
||||
|
||||
case et&EPipeWire != 0 && config.DirectPipeWire:
|
||||
return &AppError{Step: step, Err: ErrInsecure,
|
||||
Msg: "direct_pipewire is insecure and no longer supported"}
|
||||
|
||||
case et&EPulse != 0 && config.DirectPulse:
|
||||
return &AppError{Step: step, Err: ErrInsecure,
|
||||
Msg: "direct_pulse is insecure and no longer supported"}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,109 +14,65 @@ func TestConfigValidate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
config *hst.Config
|
||||
flags int
|
||||
wantErr error
|
||||
}{
|
||||
{"nil", nil, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
{"nil", nil, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
Msg: "invalid configuration"}},
|
||||
|
||||
{"identity lower", &hst.Config{Identity: -1}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrIdentityBounds,
|
||||
{"identity lower", &hst.Config{Identity: -1}, &hst.AppError{Step: "validate configuration", Err: hst.ErrIdentityBounds,
|
||||
Msg: "identity -1 out of range"}},
|
||||
{"identity upper", &hst.Config{Identity: 10000}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrIdentityBounds,
|
||||
{"identity upper", &hst.Config{Identity: 10000}, &hst.AppError{Step: "validate configuration", Err: hst.ErrIdentityBounds,
|
||||
Msg: "identity 10000 out of range"}},
|
||||
|
||||
{"sched lower", &hst.Config{SchedPolicy: -1}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrSchedPolicyBounds,
|
||||
{"sched lower", &hst.Config{SchedPolicy: -1}, &hst.AppError{Step: "validate configuration", Err: hst.ErrSchedPolicyBounds,
|
||||
Msg: "scheduling policy -1 out of range"}},
|
||||
{"sched upper", &hst.Config{SchedPolicy: 0xcafe}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrSchedPolicyBounds,
|
||||
{"sched upper", &hst.Config{SchedPolicy: 0xcafe}, &hst.AppError{Step: "validate configuration", Err: hst.ErrSchedPolicyBounds,
|
||||
Msg: "scheduling policy 51966 out of range"}},
|
||||
|
||||
{"dbus session", &hst.Config{SessionBus: &hst.BusConfig{See: []string{""}}}, 0,
|
||||
{"dbus session", &hst.Config{SessionBus: &hst.BusConfig{See: []string{""}}},
|
||||
&hst.BadInterfaceError{Interface: "", Segment: "session"}},
|
||||
{"dbus system", &hst.Config{SystemBus: &hst.BusConfig{See: []string{""}}}, 0,
|
||||
{"dbus system", &hst.Config{SystemBus: &hst.BusConfig{See: []string{""}}},
|
||||
&hst.BadInterfaceError{Interface: "", Segment: "system"}},
|
||||
|
||||
{"container", &hst.Config{}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
{"container", &hst.Config{}, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
Msg: "configuration missing container state"}},
|
||||
{"home", &hst.Config{Container: &hst.ContainerConfig{}}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
{"home", &hst.Config{Container: &hst.ContainerConfig{}}, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
Msg: "container configuration missing path to home directory"}},
|
||||
{"shell", &hst.Config{Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
}}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
}}, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
Msg: "container configuration missing path to shell"}},
|
||||
{"path", &hst.Config{Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
}}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
}}, &hst.AppError{Step: "validate configuration", Err: hst.ErrConfigNull,
|
||||
Msg: "container configuration missing path to initial program"}},
|
||||
|
||||
{"env equals", &hst.Config{Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
Env: map[string]string{"TERM=": ""},
|
||||
}}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrEnviron,
|
||||
}}, &hst.AppError{Step: "validate configuration", Err: hst.ErrEnviron,
|
||||
Msg: `invalid environment variable "TERM="`}},
|
||||
{"env NUL", &hst.Config{Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
Env: map[string]string{"TERM\x00": ""},
|
||||
}}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrEnviron,
|
||||
}}, &hst.AppError{Step: "validate configuration", Err: hst.ErrEnviron,
|
||||
Msg: `invalid environment variable "TERM\x00"`}},
|
||||
|
||||
{"insecure pulse", &hst.Config{Enablements: new(hst.EPulse), Container: &hst.ContainerConfig{
|
||||
{"insecure pulse", &hst.Config{Enablements: hst.NewEnablements(hst.EPulse), Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
}}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrInsecure,
|
||||
}}, &hst.AppError{Step: "validate configuration", Err: hst.ErrInsecure,
|
||||
Msg: "enablement PulseAudio is insecure and no longer supported"}},
|
||||
|
||||
{"direct wayland", &hst.Config{Enablements: new(hst.EWayland), DirectWayland: true, Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
}}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrInsecure,
|
||||
Msg: "direct_wayland is insecure and no longer supported"}},
|
||||
{"direct wayland allow", &hst.Config{Enablements: new(hst.EWayland), DirectWayland: true, Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
}}, hst.VAllowInsecure, nil},
|
||||
|
||||
{"direct pipewire", &hst.Config{Enablements: new(hst.EPipeWire), DirectPipeWire: true, Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
}}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrInsecure,
|
||||
Msg: "direct_pipewire is insecure and no longer supported"}},
|
||||
{"direct pipewire allow", &hst.Config{Enablements: new(hst.EPipeWire), DirectPipeWire: true, Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
}}, hst.VAllowInsecure, nil},
|
||||
|
||||
{"direct pulse", &hst.Config{Enablements: new(hst.EPulse), DirectPulse: true, Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
}}, 0, &hst.AppError{Step: "validate configuration", Err: hst.ErrInsecure,
|
||||
Msg: "direct_pulse is insecure and no longer supported"}},
|
||||
{"direct pulse allow", &hst.Config{Enablements: new(hst.EPulse), DirectPulse: true, Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
}}, hst.VAllowInsecure, nil},
|
||||
|
||||
{"valid", &hst.Config{Container: &hst.ContainerConfig{
|
||||
Home: fhs.AbsTmp,
|
||||
Shell: fhs.AbsTmp,
|
||||
Path: fhs.AbsTmp,
|
||||
}}, 0, nil},
|
||||
}}, nil},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := tc.config.Validate(tc.flags); !reflect.DeepEqual(err, tc.wantErr) {
|
||||
if err := tc.config.Validate(); !reflect.DeepEqual(err, tc.wantErr) {
|
||||
t.Errorf("Validate: error = %#v, want %#v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Enablements denotes optional host service to export to the target user.
|
||||
type Enablements byte
|
||||
// Enablement represents an optional host service to export to the target user.
|
||||
type Enablement byte
|
||||
|
||||
const (
|
||||
// EWayland exposes a Wayland pathname socket via security-context-v1.
|
||||
EWayland Enablements = 1 << iota
|
||||
EWayland Enablement = 1 << iota
|
||||
// EX11 adds the target user via X11 ChangeHosts and exposes the X11
|
||||
// pathname socket.
|
||||
EX11
|
||||
@@ -28,8 +28,8 @@ const (
|
||||
EM
|
||||
)
|
||||
|
||||
// String returns a string representation of the flags set on [Enablements].
|
||||
func (e Enablements) String() string {
|
||||
// String returns a string representation of the flags set on [Enablement].
|
||||
func (e Enablement) String() string {
|
||||
switch e {
|
||||
case 0:
|
||||
return "(no enablements)"
|
||||
@@ -47,7 +47,7 @@ func (e Enablements) String() string {
|
||||
buf := new(strings.Builder)
|
||||
buf.Grow(32)
|
||||
|
||||
for i := Enablements(1); i < EM; i <<= 1 {
|
||||
for i := Enablement(1); i < EM; i <<= 1 {
|
||||
if e&i != 0 {
|
||||
buf.WriteString(", " + i.String())
|
||||
}
|
||||
@@ -60,6 +60,12 @@ func (e Enablements) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// NewEnablements returns the address of [Enablement] as [Enablements].
|
||||
func NewEnablements(e Enablement) *Enablements { return (*Enablements)(&e) }
|
||||
|
||||
// Enablements is the [json] adapter for [Enablement].
|
||||
type Enablements Enablement
|
||||
|
||||
// enablementsJSON is the [json] representation of [Enablements].
|
||||
type enablementsJSON = struct {
|
||||
Wayland bool `json:"wayland,omitempty"`
|
||||
@@ -69,21 +75,24 @@ type enablementsJSON = struct {
|
||||
Pulse bool `json:"pulse,omitempty"`
|
||||
}
|
||||
|
||||
// Unwrap returns the value pointed to by e.
|
||||
func (e *Enablements) Unwrap() Enablements {
|
||||
// Unwrap returns the underlying [Enablement].
|
||||
func (e *Enablements) Unwrap() Enablement {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return *e
|
||||
return Enablement(*e)
|
||||
}
|
||||
|
||||
func (e Enablements) MarshalJSON() ([]byte, error) {
|
||||
func (e *Enablements) MarshalJSON() ([]byte, error) {
|
||||
if e == nil {
|
||||
return nil, syscall.EINVAL
|
||||
}
|
||||
return json.Marshal(&enablementsJSON{
|
||||
Wayland: e&EWayland != 0,
|
||||
X11: e&EX11 != 0,
|
||||
DBus: e&EDBus != 0,
|
||||
PipeWire: e&EPipeWire != 0,
|
||||
Pulse: e&EPulse != 0,
|
||||
Wayland: Enablement(*e)&EWayland != 0,
|
||||
X11: Enablement(*e)&EX11 != 0,
|
||||
DBus: Enablement(*e)&EDBus != 0,
|
||||
PipeWire: Enablement(*e)&EPipeWire != 0,
|
||||
Pulse: Enablement(*e)&EPulse != 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,21 +106,22 @@ func (e *Enablements) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
*e = 0
|
||||
var ve Enablement
|
||||
if v.Wayland {
|
||||
*e |= EWayland
|
||||
ve |= EWayland
|
||||
}
|
||||
if v.X11 {
|
||||
*e |= EX11
|
||||
ve |= EX11
|
||||
}
|
||||
if v.DBus {
|
||||
*e |= EDBus
|
||||
ve |= EDBus
|
||||
}
|
||||
if v.PipeWire {
|
||||
*e |= EPipeWire
|
||||
ve |= EPipeWire
|
||||
}
|
||||
if v.Pulse {
|
||||
*e |= EPulse
|
||||
ve |= EPulse
|
||||
}
|
||||
*e = Enablements(ve)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestEnablementString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
flags hst.Enablements
|
||||
flags hst.Enablement
|
||||
want string
|
||||
}{
|
||||
{0, "(no enablements)"},
|
||||
@@ -59,13 +59,13 @@ func TestEnablements(t *testing.T) {
|
||||
sData string
|
||||
}{
|
||||
{"nil", nil, "null", `{"value":null,"magic":3236757504}`},
|
||||
{"zero", new(hst.Enablements(0)), `{}`, `{"value":{},"magic":3236757504}`},
|
||||
{"wayland", new(hst.EWayland), `{"wayland":true}`, `{"value":{"wayland":true},"magic":3236757504}`},
|
||||
{"x11", new(hst.EX11), `{"x11":true}`, `{"value":{"x11":true},"magic":3236757504}`},
|
||||
{"dbus", new(hst.EDBus), `{"dbus":true}`, `{"value":{"dbus":true},"magic":3236757504}`},
|
||||
{"pipewire", new(hst.EPipeWire), `{"pipewire":true}`, `{"value":{"pipewire":true},"magic":3236757504}`},
|
||||
{"pulse", new(hst.EPulse), `{"pulse":true}`, `{"value":{"pulse":true},"magic":3236757504}`},
|
||||
{"all", new(hst.EM - 1), `{"wayland":true,"x11":true,"dbus":true,"pipewire":true,"pulse":true}`, `{"value":{"wayland":true,"x11":true,"dbus":true,"pipewire":true,"pulse":true},"magic":3236757504}`},
|
||||
{"zero", hst.NewEnablements(0), `{}`, `{"value":{},"magic":3236757504}`},
|
||||
{"wayland", hst.NewEnablements(hst.EWayland), `{"wayland":true}`, `{"value":{"wayland":true},"magic":3236757504}`},
|
||||
{"x11", hst.NewEnablements(hst.EX11), `{"x11":true}`, `{"value":{"x11":true},"magic":3236757504}`},
|
||||
{"dbus", hst.NewEnablements(hst.EDBus), `{"dbus":true}`, `{"value":{"dbus":true},"magic":3236757504}`},
|
||||
{"pipewire", hst.NewEnablements(hst.EPipeWire), `{"pipewire":true}`, `{"value":{"pipewire":true},"magic":3236757504}`},
|
||||
{"pulse", hst.NewEnablements(hst.EPulse), `{"pulse":true}`, `{"value":{"pulse":true},"magic":3236757504}`},
|
||||
{"all", hst.NewEnablements(hst.EM - 1), `{"wayland":true,"x11":true,"dbus":true,"pipewire":true,"pulse":true}`, `{"value":{"wayland":true,"x11":true,"dbus":true,"pipewire":true,"pulse":true},"magic":3236757504}`},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -137,7 +137,7 @@ func TestEnablements(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("val", func(t *testing.T) {
|
||||
if got := new(hst.EWayland | hst.EPulse).Unwrap(); got != hst.EWayland|hst.EPulse {
|
||||
if got := hst.NewEnablements(hst.EWayland | hst.EPulse).Unwrap(); got != hst.EWayland|hst.EPulse {
|
||||
t.Errorf("Unwrap: %v", got)
|
||||
}
|
||||
})
|
||||
@@ -146,6 +146,9 @@ func TestEnablements(t *testing.T) {
|
||||
t.Run("passthrough", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := (*hst.Enablements)(nil).MarshalJSON(); !errors.Is(err, syscall.EINVAL) {
|
||||
t.Errorf("MarshalJSON: error = %v", err)
|
||||
}
|
||||
if err := (*hst.Enablements)(nil).UnmarshalJSON(nil); !errors.Is(err, syscall.EINVAL) {
|
||||
t.Errorf("UnmarshalJSON: error = %v", err)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func Template() *Config {
|
||||
return &Config{
|
||||
ID: "org.chromium.Chromium",
|
||||
|
||||
Enablements: new(EWayland | EDBus | EPipeWire),
|
||||
Enablements: NewEnablements(EWayland | EDBus | EPipeWire),
|
||||
|
||||
SessionBus: &BusConfig{
|
||||
See: nil,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package landlock_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"hakurei.app/internal/landlock"
|
||||
)
|
||||
|
||||
func TestLandlockString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
rulesetAttr *landlock.RulesetAttr
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, "NULL"},
|
||||
{"zero", new(landlock.RulesetAttr), "0"},
|
||||
{"some", &landlock.RulesetAttr{Scoped: landlock.LANDLOCK_SCOPE_SIGNAL}, "scoped: signal"},
|
||||
{"set", &landlock.RulesetAttr{
|
||||
HandledAccessFS: landlock.LANDLOCK_ACCESS_FS_MAKE_SYM | landlock.LANDLOCK_ACCESS_FS_IOCTL_DEV | landlock.LANDLOCK_ACCESS_FS_WRITE_FILE,
|
||||
HandledAccessNet: landlock.LANDLOCK_ACCESS_NET_BIND_TCP,
|
||||
Scoped: landlock.LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | landlock.LANDLOCK_SCOPE_SIGNAL,
|
||||
}, "fs: write_file make_sym fs_ioctl_dev, net: bind_tcp, scoped: abstract_unix_socket signal"},
|
||||
{"all", &landlock.RulesetAttr{
|
||||
HandledAccessFS: landlock.LANDLOCK_ACCESS_FS_EXECUTE |
|
||||
landlock.LANDLOCK_ACCESS_FS_WRITE_FILE |
|
||||
landlock.LANDLOCK_ACCESS_FS_READ_FILE |
|
||||
landlock.LANDLOCK_ACCESS_FS_READ_DIR |
|
||||
landlock.LANDLOCK_ACCESS_FS_REMOVE_DIR |
|
||||
landlock.LANDLOCK_ACCESS_FS_REMOVE_FILE |
|
||||
landlock.LANDLOCK_ACCESS_FS_MAKE_CHAR |
|
||||
landlock.LANDLOCK_ACCESS_FS_MAKE_DIR |
|
||||
landlock.LANDLOCK_ACCESS_FS_MAKE_REG |
|
||||
landlock.LANDLOCK_ACCESS_FS_MAKE_SOCK |
|
||||
landlock.LANDLOCK_ACCESS_FS_MAKE_FIFO |
|
||||
landlock.LANDLOCK_ACCESS_FS_MAKE_BLOCK |
|
||||
landlock.LANDLOCK_ACCESS_FS_MAKE_SYM |
|
||||
landlock.LANDLOCK_ACCESS_FS_REFER |
|
||||
landlock.LANDLOCK_ACCESS_FS_TRUNCATE |
|
||||
landlock.LANDLOCK_ACCESS_FS_IOCTL_DEV,
|
||||
HandledAccessNet: landlock.LANDLOCK_ACCESS_NET_BIND_TCP |
|
||||
landlock.LANDLOCK_ACCESS_NET_CONNECT_TCP,
|
||||
Scoped: landlock.LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET |
|
||||
landlock.LANDLOCK_SCOPE_SIGNAL,
|
||||
}, "fs: execute write_file read_file read_dir remove_dir remove_file make_char make_dir make_reg make_sock make_fifo make_block make_sym fs_refer fs_truncate fs_ioctl_dev, net: bind_tcp connect_tcp, scoped: abstract_unix_socket signal"},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tc.rulesetAttr.String(); got != tc.want {
|
||||
t.Errorf("String: %s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandlockAttrSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := 24
|
||||
if got := unsafe.Sizeof(landlock.RulesetAttr{}); got != uintptr(want) {
|
||||
t.Errorf("Sizeof: %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
@@ -32,14 +32,7 @@ type outcome struct {
|
||||
syscallDispatcher
|
||||
}
|
||||
|
||||
// finalise prepares an outcome for main.
|
||||
func (k *outcome) finalise(
|
||||
ctx context.Context,
|
||||
msg message.Msg,
|
||||
id *hst.ID,
|
||||
config *hst.Config,
|
||||
flags int,
|
||||
) error {
|
||||
func (k *outcome) finalise(ctx context.Context, msg message.Msg, id *hst.ID, config *hst.Config) error {
|
||||
if ctx == nil || id == nil {
|
||||
// unreachable
|
||||
panic("invalid call to finalise")
|
||||
@@ -50,7 +43,7 @@ func (k *outcome) finalise(
|
||||
}
|
||||
k.ctx = ctx
|
||||
|
||||
if err := config.Validate(flags); err != nil {
|
||||
if err := config.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ type outcomeStateSys struct {
|
||||
// Copied from [hst.Config]. Safe for read by outcomeOp.toSystem.
|
||||
appId string
|
||||
// Copied from [hst.Config]. Safe for read by outcomeOp.toSystem.
|
||||
et hst.Enablements
|
||||
et hst.Enablement
|
||||
|
||||
// Copied from [hst.Config]. Safe for read by spWaylandOp.toSystem only.
|
||||
directWayland bool
|
||||
|
||||
@@ -297,12 +297,12 @@ func (k *outcome) main(msg message.Msg, identifierFd int) {
|
||||
// accumulate enablements of remaining instances
|
||||
var (
|
||||
// alive enablement bits
|
||||
rt hst.Enablements
|
||||
rt hst.Enablement
|
||||
// alive instance count
|
||||
n int
|
||||
)
|
||||
for eh := range entries {
|
||||
var et hst.Enablements
|
||||
var et hst.Enablement
|
||||
if et, err = eh.Load(nil); err != nil {
|
||||
perror(err, "read state header of instance "+eh.ID.String())
|
||||
} else {
|
||||
|
||||
@@ -18,13 +18,7 @@ import (
|
||||
func IsPollDescriptor(fd uintptr) bool
|
||||
|
||||
// Main runs an app according to [hst.Config] and terminates. Main does not return.
|
||||
func Main(
|
||||
ctx context.Context,
|
||||
msg message.Msg,
|
||||
config *hst.Config,
|
||||
flags int,
|
||||
fd int,
|
||||
) {
|
||||
func Main(ctx context.Context, msg message.Msg, config *hst.Config, fd int) {
|
||||
// avoids runtime internals or standard streams
|
||||
if fd >= 0 {
|
||||
if IsPollDescriptor(uintptr(fd)) || fd < 3 {
|
||||
@@ -40,7 +34,7 @@ func Main(
|
||||
k := outcome{syscallDispatcher: direct{msg}}
|
||||
|
||||
finaliseTime := time.Now()
|
||||
if err := k.finalise(ctx, msg, &id, config, flags); err != nil {
|
||||
if err := k.finalise(ctx, msg, &id, config); err != nil {
|
||||
printMessageError(msg.GetLogger().Fatalln, "cannot seal app:", err)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ func TestOutcomeRun(t *testing.T) {
|
||||
},
|
||||
Filter: true,
|
||||
},
|
||||
Enablements: new(hst.EWayland | hst.EDBus | hst.EPipeWire | hst.EPulse),
|
||||
Enablements: hst.NewEnablements(hst.EWayland | hst.EDBus | hst.EPipeWire | hst.EPulse),
|
||||
|
||||
Container: &hst.ContainerConfig{
|
||||
Filesystem: []hst.FilesystemConfigJSON{
|
||||
@@ -427,7 +427,7 @@ func TestOutcomeRun(t *testing.T) {
|
||||
DirectPipeWire: true,
|
||||
|
||||
ID: "org.chromium.Chromium",
|
||||
Enablements: new(hst.EWayland | hst.EDBus | hst.EPipeWire | hst.EPulse),
|
||||
Enablements: hst.NewEnablements(hst.EWayland | hst.EDBus | hst.EPipeWire | hst.EPulse),
|
||||
Container: &hst.ContainerConfig{
|
||||
Env: nil,
|
||||
Filesystem: []hst.FilesystemConfigJSON{
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestSpPulseOp(t *testing.T) {
|
||||
newConfig := func() *hst.Config {
|
||||
config := hst.Template()
|
||||
config.DirectPulse = true
|
||||
config.Enablements = new(hst.EPulse)
|
||||
config.Enablements = hst.NewEnablements(hst.EPulse)
|
||||
return config
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"hakurei.app/container"
|
||||
"hakurei.app/fhs"
|
||||
"hakurei.app/internal/info"
|
||||
"hakurei.app/internal/landlock"
|
||||
"hakurei.app/internal/pkg"
|
||||
"hakurei.app/internal/stub"
|
||||
"hakurei.app/message"
|
||||
@@ -294,7 +293,7 @@ func checkWithCache(t *testing.T, testCases []cacheTestCase) {
|
||||
flags := tc.flags
|
||||
|
||||
if info.CanDegrade {
|
||||
if _, err := landlock.GetABI(); err != nil {
|
||||
if _, err := container.LandlockGetABI(); err != nil {
|
||||
if !errors.Is(err, syscall.ENOSYS) {
|
||||
t.Fatalf("LandlockGetABI: error = %v", err)
|
||||
}
|
||||
|
||||
@@ -141,8 +141,8 @@ rm \
|
||||
)
|
||||
|
||||
const (
|
||||
version = "1.26.2"
|
||||
checksum = "v-6BE89_1g3xYf-9oIYpJKFXlo3xKHYJj2_VGkaUq8ZVkIVQmLwrto-xGG03OISH"
|
||||
version = "1.26.1"
|
||||
checksum = "DdC5Ea-aCYPUHNObQh_09uWU0vn4e-8Ben850Vq-5OoamDRrXhuYI4YQ_BOFgaT0"
|
||||
)
|
||||
return t.newGo(
|
||||
version,
|
||||
|
||||
@@ -4,8 +4,8 @@ import "hakurei.app/internal/pkg"
|
||||
|
||||
func (t Toolchain) newLibcap() (pkg.Artifact, string) {
|
||||
const (
|
||||
version = "2.78"
|
||||
checksum = "wFdUkBhFMD9InPnrBZyegWrlPSAg_9JiTBC-eSFyWWlmbzL2qjh2mKxr9Kx2a8ut"
|
||||
version = "2.77"
|
||||
checksum = "2GOTFU4cl2QoS7Dv5wh0c9-hxsQwIzMB9Y_gfAo5xKHqcM13fiHt1RbPkfemzjmB"
|
||||
)
|
||||
return t.NewPackage("libcap", version, pkg.NewHTTPGetTar(
|
||||
nil, "https://git.kernel.org/pub/scm/libs/libcap/libcap.git/"+
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
// llvmAttr holds the attributes that will be applied to a new [pkg.Artifact]
|
||||
// containing a LLVM variant.
|
||||
type llvmAttr struct {
|
||||
// Enabled projects and runtimes.
|
||||
pr int
|
||||
// Passed through to PackageAttr.Flag.
|
||||
flags int
|
||||
|
||||
// Concatenated with default environment for PackageAttr.Env.
|
||||
env []string
|
||||
@@ -24,6 +24,8 @@ type llvmAttr struct {
|
||||
append []string
|
||||
// Passed through to PackageAttr.NonStage0.
|
||||
nonStage0 []pkg.Artifact
|
||||
// Passed through to PackageAttr.Paths.
|
||||
paths []pkg.ExecPath
|
||||
// Concatenated with default fixup for CMakeHelper.Script.
|
||||
script string
|
||||
|
||||
@@ -73,18 +75,19 @@ func llvmFlagName(flag int) string {
|
||||
|
||||
// newLLVMVariant returns a [pkg.Artifact] containing a LLVM variant.
|
||||
func (t Toolchain) newLLVMVariant(variant string, attr *llvmAttr) pkg.Artifact {
|
||||
|
||||
if attr == nil {
|
||||
panic("LLVM attr must be non-nil")
|
||||
}
|
||||
|
||||
var projects, runtimes []string
|
||||
for i := 1; i < llvmProjectAll; i <<= 1 {
|
||||
if attr.pr&i != 0 {
|
||||
if attr.flags&i != 0 {
|
||||
projects = append(projects, llvmFlagName(i))
|
||||
}
|
||||
}
|
||||
for i := (llvmProjectAll + 1) << 1; i < llvmRuntimeAll; i <<= 1 {
|
||||
if attr.pr&i != 0 {
|
||||
if attr.flags&i != 0 {
|
||||
runtimes = append(runtimes, llvmFlagName(i))
|
||||
}
|
||||
}
|
||||
@@ -123,7 +126,7 @@ func (t Toolchain) newLLVMVariant(variant string, attr *llvmAttr) pkg.Artifact {
|
||||
}...)
|
||||
}
|
||||
|
||||
if attr.pr&llvmProjectClang != 0 {
|
||||
if attr.flags&llvmProjectClang != 0 {
|
||||
cache = append(cache, []KV{
|
||||
{"CLANG_DEFAULT_LINKER", "lld"},
|
||||
{"CLANG_DEFAULT_CXX_STDLIB", "libc++"},
|
||||
@@ -131,30 +134,30 @@ func (t Toolchain) newLLVMVariant(variant string, attr *llvmAttr) pkg.Artifact {
|
||||
{"CLANG_DEFAULT_UNWINDLIB", "libunwind"},
|
||||
}...)
|
||||
}
|
||||
if attr.pr&llvmProjectLld != 0 {
|
||||
if attr.flags&llvmProjectLld != 0 {
|
||||
script += `
|
||||
ln -s ld.lld /work/system/bin/ld
|
||||
`
|
||||
}
|
||||
if attr.pr&llvmRuntimeCompilerRT != 0 {
|
||||
if attr.flags&llvmRuntimeCompilerRT != 0 {
|
||||
if attr.append == nil {
|
||||
cache = append(cache, []KV{
|
||||
{"COMPILER_RT_USE_LLVM_UNWINDER", "ON"},
|
||||
}...)
|
||||
}
|
||||
}
|
||||
if attr.pr&llvmRuntimeLibunwind != 0 {
|
||||
if attr.flags&llvmRuntimeLibunwind != 0 {
|
||||
cache = append(cache, []KV{
|
||||
{"LIBUNWIND_USE_COMPILER_RT", "ON"},
|
||||
}...)
|
||||
}
|
||||
if attr.pr&llvmRuntimeLibcxx != 0 {
|
||||
if attr.flags&llvmRuntimeLibcxx != 0 {
|
||||
cache = append(cache, []KV{
|
||||
{"LIBCXX_HAS_MUSL_LIBC", "ON"},
|
||||
{"LIBCXX_USE_COMPILER_RT", "ON"},
|
||||
}...)
|
||||
}
|
||||
if attr.pr&llvmRuntimeLibcxxABI != 0 {
|
||||
if attr.flags&llvmRuntimeLibcxxABI != 0 {
|
||||
cache = append(cache, []KV{
|
||||
{"LIBCXXABI_USE_COMPILER_RT", "ON"},
|
||||
{"LIBCXXABI_USE_LLVM_UNWINDER", "ON"},
|
||||
@@ -167,22 +170,7 @@ ln -s ld.lld /work/system/bin/ld
|
||||
mustDecode(llvmChecksum),
|
||||
pkg.TarGzip,
|
||||
), &PackageAttr{
|
||||
Patches: slices.Concat(attr.patches, []KV{
|
||||
{"increase-stack-size-unconditional", `diff --git a/llvm/lib/Support/Threading.cpp b/llvm/lib/Support/Threading.cpp
|
||||
index 9da357a7ebb9..b2931510c1ae 100644
|
||||
--- a/llvm/lib/Support/Threading.cpp
|
||||
+++ b/llvm/lib/Support/Threading.cpp
|
||||
@@ -80,7 +80,7 @@ unsigned llvm::ThreadPoolStrategy::compute_thread_count() const {
|
||||
// keyword.
|
||||
#include "llvm/Support/thread.h"
|
||||
|
||||
-#if defined(__APPLE__)
|
||||
+#if defined(__APPLE__) || 1
|
||||
// Darwin's default stack size for threads except the main one is only 512KB,
|
||||
// which is not enough for some/many normal LLVM compilations. This implements
|
||||
// the same interface as std::thread but requests the same stack size as the
|
||||
`},
|
||||
}),
|
||||
Patches: attr.patches,
|
||||
NonStage0: attr.nonStage0,
|
||||
|
||||
Env: slices.Concat([]string{
|
||||
@@ -190,7 +178,8 @@ index 9da357a7ebb9..b2931510c1ae 100644
|
||||
"ROSA_LLVM_RUNTIMES=" + strings.Join(runtimes, ";"),
|
||||
}, attr.env),
|
||||
|
||||
Flag: TExclusive,
|
||||
Paths: attr.paths,
|
||||
Flag: TExclusive,
|
||||
}, &CMakeHelper{
|
||||
Variant: variant,
|
||||
|
||||
@@ -212,19 +201,17 @@ index 9da357a7ebb9..b2931510c1ae 100644
|
||||
|
||||
// newLLVM returns LLVM toolchain across multiple [pkg.Artifact].
|
||||
func (t Toolchain) newLLVM() (musl, compilerRT, runtimes, clang pkg.Artifact) {
|
||||
target := "'AArch64;RISCV;X86'"
|
||||
if t.isStage0() {
|
||||
switch runtime.GOARCH {
|
||||
case "386", "amd64":
|
||||
target = "X86"
|
||||
case "arm64":
|
||||
target = "AArch64"
|
||||
case "riscv64":
|
||||
target = "RISCV"
|
||||
var target string
|
||||
switch runtime.GOARCH {
|
||||
case "386", "amd64":
|
||||
target = "X86"
|
||||
case "arm64":
|
||||
target = "AArch64"
|
||||
case "riscv64":
|
||||
target = "RISCV"
|
||||
|
||||
default:
|
||||
panic("unsupported target " + runtime.GOARCH)
|
||||
}
|
||||
default:
|
||||
panic("unsupported target " + runtime.GOARCH)
|
||||
}
|
||||
|
||||
minimalDeps := []KV{
|
||||
@@ -246,7 +233,7 @@ func (t Toolchain) newLLVM() (musl, compilerRT, runtimes, clang pkg.Artifact) {
|
||||
{"CMAKE_CXX_COMPILER_TARGET", ""},
|
||||
|
||||
{"COMPILER_RT_BUILD_BUILTINS", "ON"},
|
||||
{"COMPILER_RT_DEFAULT_TARGET_ONLY", "OFF"},
|
||||
{"COMPILER_RT_DEFAULT_TARGET_ONLY", "ON"},
|
||||
{"COMPILER_RT_SANITIZERS_TO_BUILD", "asan"},
|
||||
{"LLVM_ENABLE_PER_TARGET_RUNTIME_DIR", "ON"},
|
||||
|
||||
@@ -290,7 +277,7 @@ ln -s \
|
||||
env: stage0ExclConcat(t, []string{},
|
||||
"LDFLAGS="+earlyLDFLAGS(false),
|
||||
),
|
||||
pr: llvmRuntimeLibunwind | llvmRuntimeLibcxx | llvmRuntimeLibcxxABI,
|
||||
flags: llvmRuntimeLibunwind | llvmRuntimeLibcxx | llvmRuntimeLibcxxABI,
|
||||
cmake: slices.Concat([]KV{
|
||||
// libc++ not yet available
|
||||
{"CMAKE_CXX_COMPILER_WORKS", "ON"},
|
||||
@@ -306,7 +293,7 @@ ln -s \
|
||||
})
|
||||
|
||||
clang = t.newLLVMVariant("clang", &llvmAttr{
|
||||
pr: llvmProjectClang | llvmProjectLld,
|
||||
flags: llvmProjectClang | llvmProjectLld,
|
||||
env: stage0ExclConcat(t, []string{},
|
||||
"CFLAGS="+earlyCFLAGS,
|
||||
"CXXFLAGS="+earlyCXXFLAGS(),
|
||||
@@ -329,7 +316,7 @@ ln -s clang++ /work/system/bin/c++
|
||||
ninja check-all
|
||||
`,
|
||||
|
||||
patches: []KV{
|
||||
patches: slices.Concat([]KV{
|
||||
{"add-rosa-vendor", `diff --git a/llvm/include/llvm/TargetParser/Triple.h b/llvm/include/llvm/TargetParser/Triple.h
|
||||
index 9c83abeeb3b1..5acfe5836a23 100644
|
||||
--- a/llvm/include/llvm/TargetParser/Triple.h
|
||||
@@ -501,7 +488,7 @@ index 64324a3f8b01..15ce70b68217 100644
|
||||
"/System/Library/Frameworks"};
|
||||
|
||||
`},
|
||||
},
|
||||
}, clangPatches),
|
||||
})
|
||||
|
||||
return
|
||||
|
||||
4
internal/rosa/llvm_amd64.go
Normal file
4
internal/rosa/llvm_amd64.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package rosa
|
||||
|
||||
// clangPatches are patches applied to the LLVM source tree for building clang.
|
||||
var clangPatches []KV
|
||||
12
internal/rosa/llvm_arm64.go
Normal file
12
internal/rosa/llvm_arm64.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package rosa
|
||||
|
||||
// clangPatches are patches applied to the LLVM source tree for building clang.
|
||||
var clangPatches []KV
|
||||
|
||||
// one version behind, latest fails 5 tests with 2 flaky on arm64
|
||||
const (
|
||||
llvmVersionMajor = "21"
|
||||
llvmVersion = llvmVersionMajor + ".1.8"
|
||||
|
||||
llvmChecksum = "8SUpqDkcgwOPsqHVtmf9kXfFeVmjVxl4LMn-qSE1AI_Xoeju-9HaoPNGtidyxyka"
|
||||
)
|
||||
@@ -1,9 +1,11 @@
|
||||
//go:build !arm64
|
||||
|
||||
package rosa
|
||||
|
||||
// latest version of LLVM, conditional to temporarily avoid broken new releases
|
||||
const (
|
||||
llvmVersionMajor = "22"
|
||||
llvmVersion = llvmVersionMajor + ".1.3"
|
||||
llvmVersion = llvmVersionMajor + ".1.2"
|
||||
|
||||
llvmChecksum = "CUwnpzua_y28HZ9oI0NmcKL2wClsSjFpgY9do5-7cCZJHI5KNF64vfwGvY0TYyR3"
|
||||
llvmChecksum = "FwsmurWDVyYYQlOowowFjekwIGSB5__aKTpW_VGP3eWoZGXvBny-bOn1DuQ1U5xE"
|
||||
)
|
||||
|
||||
4
internal/rosa/llvm_riscv64.go
Normal file
4
internal/rosa/llvm_riscv64.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package rosa
|
||||
|
||||
// clangPatches are patches applied to the LLVM source tree for building clang.
|
||||
var clangPatches []KV
|
||||
@@ -4,8 +4,8 @@ import "hakurei.app/internal/pkg"
|
||||
|
||||
func (t Toolchain) newOpenSSL() (pkg.Artifact, string) {
|
||||
const (
|
||||
version = "3.6.2"
|
||||
checksum = "jH004dXTiE01Hp0kyShkWXwrSHEksZi4i_3v47D9H9Uz9LQ1aMwF7mrl2Tb4t_XA"
|
||||
version = "3.6.1"
|
||||
checksum = "boMAj2SIVIFXHswZva3qHJuFEpc32rxCCu07wjMPsVe9nn_976BGMmW_5P1zthgg"
|
||||
)
|
||||
return t.NewPackage("openssl", version, pkg.NewHTTPGetTar(
|
||||
nil, "https://github.com/openssl/openssl/releases/download/"+
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
func (t Toolchain) newPython() (pkg.Artifact, string) {
|
||||
const (
|
||||
version = "3.14.4"
|
||||
checksum = "X0VRAAGOlCVldh4J9tRAE-YrJtDvqfQTJaqxKPXNX6YTPlwpR9GwA5WRIZDO-63s"
|
||||
version = "3.14.3"
|
||||
checksum = "ajEC32WPmn9Jvll0n4gGvlTvhMPUHb2H_j5_h9jf_esHmkZBRfAumDcKY7nTTsCH"
|
||||
)
|
||||
return t.NewPackage("python", version, pkg.NewHTTPGetTar(
|
||||
nil, "https://www.python.org/ftp/python/"+version+
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
func (t Toolchain) newTamaGo() (pkg.Artifact, string) {
|
||||
const (
|
||||
version = "1.26.2"
|
||||
checksum = "5xlhWq2NGhYCjt0y73QkydJ386lxg6-HkiO84ne6ByQSJBDat7-HSVzNA6jy7Laz"
|
||||
version = "1.26.1"
|
||||
checksum = "fimZnklQcYWGsTQU8KepLn-yCYaTfNdMI9DCg6NJVQv-3gOJnUEO9mqRCMAHnEXZ"
|
||||
)
|
||||
return t.New("tamago-go"+version, 0, t.AppendPresets(nil,
|
||||
Bash,
|
||||
|
||||
@@ -24,7 +24,7 @@ func entryEncode(w io.Writer, s *hst.State) error {
|
||||
}
|
||||
|
||||
// entryDecodeHeader calls entryReadHeader, returning [hst.AppError] for a non-nil error.
|
||||
func entryDecodeHeader(r io.Reader) (hst.Enablements, error) {
|
||||
func entryDecodeHeader(r io.Reader) (hst.Enablement, error) {
|
||||
if et, err := entryReadHeader(r); err != nil {
|
||||
return 0, &hst.AppError{Step: "decode state header", Err: err}
|
||||
} else {
|
||||
@@ -32,23 +32,20 @@ func entryDecodeHeader(r io.Reader) (hst.Enablements, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// entryDecode decodes [hst.State] from [io.Reader] and stores the result in the
|
||||
// value pointed to by p. entryDecode validates the embedded [hst.Config] value.
|
||||
// entryDecode decodes [hst.State] from [io.Reader] and stores the result in the value pointed to by p.
|
||||
// entryDecode validates the embedded [hst.Config] value.
|
||||
//
|
||||
// A non-nil error returned by entryDecode is of type [hst.AppError].
|
||||
func entryDecode(r io.Reader, p *hst.State) (hst.Enablements, error) {
|
||||
func entryDecode(r io.Reader, p *hst.State) (hst.Enablement, error) {
|
||||
if et, err := entryDecodeHeader(r); err != nil {
|
||||
return et, err
|
||||
} else if err = gob.NewDecoder(r).Decode(&p); err != nil {
|
||||
return et, &hst.AppError{Step: "decode state body", Err: err}
|
||||
} else if err = p.Config.Validate(hst.VAllowInsecure); err != nil {
|
||||
} else if err = p.Config.Validate(); err != nil {
|
||||
return et, err
|
||||
} else if p.Enablements.Unwrap() != et {
|
||||
return et, &hst.AppError{Step: "validate state enablement", Err: os.ErrInvalid,
|
||||
Msg: fmt.Sprintf(
|
||||
"state entry %s has unexpected enablement byte %#x, %#x",
|
||||
p.ID.String(), byte(p.Enablements.Unwrap()), byte(et),
|
||||
)}
|
||||
Msg: fmt.Sprintf("state entry %s has unexpected enablement byte %#x, %#x", p.ID.String(), byte(p.Enablements.Unwrap()), byte(et))}
|
||||
} else {
|
||||
return et, nil
|
||||
}
|
||||
|
||||
@@ -14,25 +14,22 @@ import (
|
||||
const (
|
||||
// entryHeaderMagic are magic bytes at the beginning of the state entry file.
|
||||
entryHeaderMagic = "\x00\xff\xca\xfe"
|
||||
// entryHeaderRevision follows entryHeaderMagic and is incremented for
|
||||
// revisions of the format.
|
||||
// entryHeaderRevision follows entryHeaderMagic and is incremented for revisions of the format.
|
||||
entryHeaderRevision = "\x00\x00"
|
||||
// entryHeaderSize is the fixed size of the header in bytes, including the
|
||||
// enablement byte and its complement.
|
||||
// entryHeaderSize is the fixed size of the header in bytes, including the enablement byte and its complement.
|
||||
entryHeaderSize = len(entryHeaderMagic+entryHeaderRevision) + 2
|
||||
)
|
||||
|
||||
// entryHeaderEncode encodes a state entry header for a [hst.Enablements] byte.
|
||||
func entryHeaderEncode(et hst.Enablements) *[entryHeaderSize]byte {
|
||||
// entryHeaderEncode encodes a state entry header for a [hst.Enablement] byte.
|
||||
func entryHeaderEncode(et hst.Enablement) *[entryHeaderSize]byte {
|
||||
data := [entryHeaderSize]byte([]byte(
|
||||
entryHeaderMagic + entryHeaderRevision + string([]hst.Enablements{et, ^et}),
|
||||
entryHeaderMagic + entryHeaderRevision + string([]hst.Enablement{et, ^et}),
|
||||
))
|
||||
return &data
|
||||
}
|
||||
|
||||
// entryHeaderDecode validates a state entry header and returns the
|
||||
// [hst.Enablements] byte.
|
||||
func entryHeaderDecode(data *[entryHeaderSize]byte) (hst.Enablements, error) {
|
||||
// entryHeaderDecode validates a state entry header and returns the [hst.Enablement] byte.
|
||||
func entryHeaderDecode(data *[entryHeaderSize]byte) (hst.Enablement, error) {
|
||||
if magic := data[:len(entryHeaderMagic)]; string(magic) != entryHeaderMagic {
|
||||
return 0, errors.New("invalid header " + hex.EncodeToString(magic))
|
||||
}
|
||||
@@ -44,7 +41,7 @@ func entryHeaderDecode(data *[entryHeaderSize]byte) (hst.Enablements, error) {
|
||||
if et != ^data[len(entryHeaderMagic+entryHeaderRevision)+1] {
|
||||
return 0, errors.New("header enablement value is inconsistent")
|
||||
}
|
||||
return hst.Enablements(et), nil
|
||||
return hst.Enablement(et), nil
|
||||
}
|
||||
|
||||
// EntrySizeError is returned for a file too small to hold a state entry header.
|
||||
@@ -71,8 +68,8 @@ func entryCheckFile(fi os.FileInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// entryReadHeader reads [hst.Enablements] from an [io.Reader].
|
||||
func entryReadHeader(r io.Reader) (hst.Enablements, error) {
|
||||
// entryReadHeader reads [hst.Enablement] from an [io.Reader].
|
||||
func entryReadHeader(r io.Reader) (hst.Enablement, error) {
|
||||
var data [entryHeaderSize]byte
|
||||
if n, err := r.Read(data[:]); err != nil {
|
||||
return 0, err
|
||||
@@ -82,8 +79,8 @@ func entryReadHeader(r io.Reader) (hst.Enablements, error) {
|
||||
return entryHeaderDecode(&data)
|
||||
}
|
||||
|
||||
// entryWriteHeader writes [hst.Enablements] header to an [io.Writer].
|
||||
func entryWriteHeader(w io.Writer, et hst.Enablements) error {
|
||||
// entryWriteHeader writes [hst.Enablement] header to an [io.Writer].
|
||||
func entryWriteHeader(w io.Writer, et hst.Enablement) error {
|
||||
_, err := w.Write(entryHeaderEncode(et)[:])
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestEntryHeader(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
data [entryHeaderSize]byte
|
||||
et hst.Enablements
|
||||
et hst.Enablement
|
||||
err error
|
||||
}{
|
||||
{"complement mismatch", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00,
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
)
|
||||
|
||||
// EntryHandle is a handle on a state entry retrieved from a [Handle].
|
||||
//
|
||||
// Must only be used while its parent [Handle.Lock] is held.
|
||||
type EntryHandle struct {
|
||||
// Error returned while decoding pathname.
|
||||
@@ -28,7 +27,6 @@ type EntryHandle struct {
|
||||
}
|
||||
|
||||
// open opens the underlying state entry file.
|
||||
//
|
||||
// A non-nil error returned by open is of type [hst.AppError].
|
||||
func (eh *EntryHandle) open(flag int, perm os.FileMode) (*os.File, error) {
|
||||
if eh.DecodeErr != nil {
|
||||
@@ -43,7 +41,6 @@ func (eh *EntryHandle) open(flag int, perm os.FileMode) (*os.File, error) {
|
||||
}
|
||||
|
||||
// Destroy removes the underlying state entry.
|
||||
//
|
||||
// A non-nil error returned by Destroy is of type [hst.AppError].
|
||||
func (eh *EntryHandle) Destroy() error {
|
||||
// destroy does not go through open
|
||||
@@ -58,10 +55,8 @@ func (eh *EntryHandle) Destroy() error {
|
||||
}
|
||||
|
||||
// save encodes [hst.State] and writes it to the underlying file.
|
||||
//
|
||||
// An error is returned if a file already exists with the same identifier.
|
||||
// save does not validate the embedded [hst.Config].
|
||||
//
|
||||
// A non-nil error returned by save is of type [hst.AppError].
|
||||
func (eh *EntryHandle) save(state *hst.State) error {
|
||||
f, err := eh.open(os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
|
||||
@@ -76,19 +71,17 @@ func (eh *EntryHandle) save(state *hst.State) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load loads and validates the state entry header, and returns the
|
||||
// [hst.Enablements] byte. For a non-nil v, the full state payload is decoded
|
||||
// and stored in the value pointed to by v.
|
||||
//
|
||||
// Load validates the embedded [hst.Config] value. A non-nil error returned by
|
||||
// Load is of type [hst.AppError].
|
||||
func (eh *EntryHandle) Load(v *hst.State) (hst.Enablements, error) {
|
||||
// Load loads and validates the state entry header, and returns the [hst.Enablement] byte.
|
||||
// for a non-nil v, the full state payload is decoded and stored in the value pointed to by v.
|
||||
// Load validates the embedded [hst.Config] value.
|
||||
// A non-nil error returned by Load is of type [hst.AppError].
|
||||
func (eh *EntryHandle) Load(v *hst.State) (hst.Enablement, error) {
|
||||
f, err := eh.open(os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var et hst.Enablements
|
||||
var et hst.Enablement
|
||||
if v != nil {
|
||||
et, err = entryDecode(f, v)
|
||||
if err == nil && v.ID != eh.ID {
|
||||
@@ -106,7 +99,6 @@ func (eh *EntryHandle) Load(v *hst.State) (hst.Enablements, error) {
|
||||
}
|
||||
|
||||
// Handle is a handle on a [Store] segment.
|
||||
//
|
||||
// Initialised by [Store.Handle].
|
||||
type Handle struct {
|
||||
// Identity of instances tracked by this segment.
|
||||
@@ -121,9 +113,8 @@ type Handle struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Lock attempts to acquire a lock on [Handle]. If successful, Lock returns a
|
||||
// non-nil unlock function.
|
||||
//
|
||||
// Lock attempts to acquire a lock on [Handle].
|
||||
// If successful, Lock returns a non-nil unlock function.
|
||||
// A non-nil error returned by Lock is of type [hst.AppError].
|
||||
func (h *Handle) Lock() (unlock func(), err error) {
|
||||
if unlock, err = h.fileMu.Lock(); err != nil {
|
||||
@@ -132,24 +123,20 @@ func (h *Handle) Lock() (unlock func(), err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save attempts to save [hst.State] as a segment entry, and returns its
|
||||
// [EntryHandle]. Must be called while holding [Handle.Lock].
|
||||
//
|
||||
// Save attempts to save [hst.State] as a segment entry, and returns its [EntryHandle].
|
||||
// Must be called while holding [Handle.Lock].
|
||||
// An error is returned if an entry already exists with the same identifier.
|
||||
// Save does not validate the embedded [hst.Config].
|
||||
//
|
||||
// A non-nil error returned by Save is of type [hst.AppError].
|
||||
func (h *Handle) Save(state *hst.State) (*EntryHandle, error) {
|
||||
eh := EntryHandle{nil, h.Path.Append(state.ID.String()), state.ID}
|
||||
return &eh, eh.save(state)
|
||||
}
|
||||
|
||||
// Entries returns an iterator over all [EntryHandle] held in this segment. Must
|
||||
// be called while holding [Handle.Lock].
|
||||
//
|
||||
// A non-nil error attached to a [EntryHandle] indicates a malformed identifier
|
||||
// and is of type [hst.AppError]. A non-nil error returned by Entries is of type
|
||||
// [hst.AppError].
|
||||
// Entries returns an iterator over all [EntryHandle] held in this segment.
|
||||
// Must be called while holding [Handle.Lock].
|
||||
// A non-nil error attached to a [EntryHandle] indicates a malformed identifier and is of type [hst.AppError].
|
||||
// A non-nil error returned by Entries is of type [hst.AppError].
|
||||
func (h *Handle) Entries() (iter.Seq[*EntryHandle], int, error) {
|
||||
// for error reporting
|
||||
const step = "read store segment entries"
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// Made available here for direct validation of state entry files.
|
||||
//
|
||||
//go:linkname entryDecode hakurei.app/internal/store.entryDecode
|
||||
func entryDecode(r io.Reader, p *hst.State) (hst.Enablements, error)
|
||||
func entryDecode(r io.Reader, p *hst.State) (hst.Enablement, error)
|
||||
|
||||
// Made available here for direct access to known segment handles.
|
||||
//
|
||||
|
||||
@@ -17,21 +17,20 @@ func (sys *I) UpdatePerm(path *check.Absolute, perms ...acl.Perm) *I {
|
||||
return sys
|
||||
}
|
||||
|
||||
// UpdatePermType maintains [acl.Perms] on a file until its [hst.Enablements] is
|
||||
// no longer satisfied.
|
||||
func (sys *I) UpdatePermType(et hst.Enablements, path *check.Absolute, perms ...acl.Perm) *I {
|
||||
// UpdatePermType maintains [acl.Perms] on a file until its [Enablement] is no longer satisfied.
|
||||
func (sys *I) UpdatePermType(et hst.Enablement, path *check.Absolute, perms ...acl.Perm) *I {
|
||||
sys.ops = append(sys.ops, &aclUpdateOp{et, path.String(), perms})
|
||||
return sys
|
||||
}
|
||||
|
||||
// aclUpdateOp implements [I.UpdatePermType].
|
||||
type aclUpdateOp struct {
|
||||
et hst.Enablements
|
||||
et hst.Enablement
|
||||
path string
|
||||
perms acl.Perms
|
||||
}
|
||||
|
||||
func (a *aclUpdateOp) Type() hst.Enablements { return a.et }
|
||||
func (a *aclUpdateOp) Type() hst.Enablement { return a.et }
|
||||
|
||||
func (a *aclUpdateOp) apply(sys *I) error {
|
||||
sys.msg.Verbose("applying ACL", a)
|
||||
|
||||
@@ -31,9 +31,7 @@ func (sys *I) MustProxyDBus(
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyDBus finalises configuration ahead of time and starts xdg-dbus-proxy via
|
||||
// [dbus] and terminates it on revert.
|
||||
//
|
||||
// ProxyDBus finalises configuration ahead of time and starts xdg-dbus-proxy via [dbus] and terminates it on revert.
|
||||
// This [Op] is always [Process] scoped.
|
||||
func (sys *I) ProxyDBus(
|
||||
session, system *hst.BusConfig,
|
||||
@@ -86,7 +84,7 @@ type dbusProxyOp struct {
|
||||
system bool
|
||||
}
|
||||
|
||||
func (d *dbusProxyOp) Type() hst.Enablements { return Process }
|
||||
func (d *dbusProxyOp) Type() hst.Enablement { return Process }
|
||||
|
||||
func (d *dbusProxyOp) apply(sys *I) error {
|
||||
sys.msg.Verbosef("session bus proxy on %q for upstream %q", d.final.Session[1], d.final.Session[0])
|
||||
|
||||
@@ -21,16 +21,12 @@ type osFile interface {
|
||||
fs.File
|
||||
}
|
||||
|
||||
// syscallDispatcher provides methods that make state-dependent system calls as
|
||||
// part of their behaviour.
|
||||
//
|
||||
// syscallDispatcher provides methods that make state-dependent system calls as part of their behaviour.
|
||||
// syscallDispatcher is embedded in [I], so all methods must be unexported.
|
||||
type syscallDispatcher interface {
|
||||
// new starts a goroutine with a new instance of syscallDispatcher.
|
||||
//
|
||||
// A syscallDispatcher must never be used in any goroutine other than the
|
||||
// one owning it, just synchronising access is not enough, as this is for
|
||||
// test instrumentation.
|
||||
// A syscallDispatcher must never be used in any goroutine other than the one owning it,
|
||||
// just synchronising access is not enough, as this is for test instrumentation.
|
||||
new(f func(k syscallDispatcher))
|
||||
|
||||
// stat provides os.Stat.
|
||||
|
||||
@@ -27,7 +27,7 @@ func call(name string, args stub.ExpectArgs, ret any, err error) stub.Call {
|
||||
type opBehaviourTestCase struct {
|
||||
name string
|
||||
uid int
|
||||
ec hst.Enablements
|
||||
ec hst.Enablement
|
||||
op Op
|
||||
|
||||
apply []stub.Call
|
||||
@@ -158,7 +158,7 @@ type opMetaTestCase struct {
|
||||
name string
|
||||
op Op
|
||||
|
||||
wantType hst.Enablements
|
||||
wantType hst.Enablement
|
||||
wantPath string
|
||||
wantString string
|
||||
}
|
||||
|
||||
@@ -12,19 +12,19 @@ func (sys *I) Link(oldname, newname *check.Absolute) *I {
|
||||
return sys.LinkFileType(Process, oldname, newname)
|
||||
}
|
||||
|
||||
// LinkFileType maintains a hardlink until its [hst.Enablements] is no longer satisfied.
|
||||
func (sys *I) LinkFileType(et hst.Enablements, oldname, newname *check.Absolute) *I {
|
||||
// LinkFileType maintains a hardlink until its [Enablement] is no longer satisfied.
|
||||
func (sys *I) LinkFileType(et hst.Enablement, oldname, newname *check.Absolute) *I {
|
||||
sys.ops = append(sys.ops, &hardlinkOp{et, newname.String(), oldname.String()})
|
||||
return sys
|
||||
}
|
||||
|
||||
// hardlinkOp implements [I.LinkFileType].
|
||||
type hardlinkOp struct {
|
||||
et hst.Enablements
|
||||
et hst.Enablement
|
||||
dst, src string
|
||||
}
|
||||
|
||||
func (l *hardlinkOp) Type() hst.Enablements { return l.et }
|
||||
func (l *hardlinkOp) Type() hst.Enablement { return l.et }
|
||||
|
||||
func (l *hardlinkOp) apply(sys *I) error {
|
||||
sys.msg.Verbose("linking", l)
|
||||
|
||||
@@ -15,22 +15,21 @@ func (sys *I) Ensure(name *check.Absolute, perm os.FileMode) *I {
|
||||
return sys
|
||||
}
|
||||
|
||||
// Ephemeral ensures the existence of a directory until its [hst.Enablements] is
|
||||
// no longer satisfied.
|
||||
func (sys *I) Ephemeral(et hst.Enablements, name *check.Absolute, perm os.FileMode) *I {
|
||||
// Ephemeral ensures the existence of a directory until its [Enablement] is no longer satisfied.
|
||||
func (sys *I) Ephemeral(et hst.Enablement, name *check.Absolute, perm os.FileMode) *I {
|
||||
sys.ops = append(sys.ops, &mkdirOp{et, name.String(), perm, true})
|
||||
return sys
|
||||
}
|
||||
|
||||
// mkdirOp implements [I.Ensure] and [I.Ephemeral].
|
||||
type mkdirOp struct {
|
||||
et hst.Enablements
|
||||
et hst.Enablement
|
||||
path string
|
||||
perm os.FileMode
|
||||
ephemeral bool
|
||||
}
|
||||
|
||||
func (m *mkdirOp) Type() hst.Enablements { return m.et }
|
||||
func (m *mkdirOp) Type() hst.Enablement { return m.et }
|
||||
|
||||
func (m *mkdirOp) apply(sys *I) error {
|
||||
sys.msg.Verbose("ensuring directory", m)
|
||||
|
||||
@@ -12,9 +12,8 @@ import (
|
||||
)
|
||||
|
||||
// PipeWire maintains a pipewire socket with SecurityContext attached via [pipewire].
|
||||
//
|
||||
// The socket stops accepting connections once the pipe referred to by sync is
|
||||
// closed. The socket is pathname only and is destroyed on revert.
|
||||
// The socket stops accepting connections once the pipe referred to by sync is closed.
|
||||
// The socket is pathname only and is destroyed on revert.
|
||||
func (sys *I) PipeWire(dst *check.Absolute, appID, instanceID string) *I {
|
||||
sys.ops = append(sys.ops, &pipewireOp{nil, dst, appID, instanceID})
|
||||
return sys
|
||||
@@ -28,7 +27,7 @@ type pipewireOp struct {
|
||||
appID, instanceID string
|
||||
}
|
||||
|
||||
func (p *pipewireOp) Type() hst.Enablements { return Process }
|
||||
func (p *pipewireOp) Type() hst.Enablement { return Process }
|
||||
|
||||
func (p *pipewireOp) apply(sys *I) (err error) {
|
||||
var ctx *pipewire.Context
|
||||
|
||||
@@ -20,21 +20,21 @@ const (
|
||||
)
|
||||
|
||||
// Criteria specifies types of Op to revert.
|
||||
type Criteria hst.Enablements
|
||||
type Criteria hst.Enablement
|
||||
|
||||
func (ec *Criteria) hasType(t hst.Enablements) bool {
|
||||
func (ec *Criteria) hasType(t hst.Enablement) bool {
|
||||
// nil criteria: revert everything except User
|
||||
if ec == nil {
|
||||
return t != User
|
||||
}
|
||||
|
||||
return hst.Enablements(*ec)&t != 0
|
||||
return hst.Enablement(*ec)&t != 0
|
||||
}
|
||||
|
||||
// Op is a reversible system operation.
|
||||
type Op interface {
|
||||
// Type returns [Op]'s enablement type, for matching a revert criteria.
|
||||
Type() hst.Enablements
|
||||
Type() hst.Enablement
|
||||
|
||||
apply(sys *I) error
|
||||
revert(sys *I, ec *Criteria) error
|
||||
@@ -44,8 +44,8 @@ type Op interface {
|
||||
String() string
|
||||
}
|
||||
|
||||
// TypeString extends [hst.Enablements] to support [User] and [Process].
|
||||
func TypeString(e hst.Enablements) string {
|
||||
// TypeString extends [hst.Enablement.String] to support [User] and [Process].
|
||||
func TypeString(e hst.Enablement) string {
|
||||
switch e {
|
||||
case User:
|
||||
return "user"
|
||||
@@ -110,9 +110,7 @@ func (sys *I) Equal(target *I) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Commit applies all [Op] held by [I] and reverts all successful [Op] on first
|
||||
// error encountered.
|
||||
//
|
||||
// Commit applies all [Op] held by [I] and reverts all successful [Op] on first error encountered.
|
||||
// Commit must not be called more than once.
|
||||
func (sys *I) Commit() error {
|
||||
if sys.committed {
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestCriteria(t *testing.T) {
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
ec, t hst.Enablements
|
||||
ec, t hst.Enablement
|
||||
want bool
|
||||
}{
|
||||
{"nil", 0xff, hst.EWayland, true},
|
||||
@@ -47,7 +47,7 @@ func TestTypeString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
e hst.Enablements
|
||||
e hst.Enablement
|
||||
want string
|
||||
}{
|
||||
{hst.EWayland, hst.EWayland.String()},
|
||||
@@ -190,7 +190,7 @@ func TestCommitRevert(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
f func(sys *I)
|
||||
ec hst.Enablements
|
||||
ec hst.Enablement
|
||||
|
||||
commit []stub.Call
|
||||
wantErrCommit error
|
||||
|
||||
@@ -11,9 +11,8 @@ import (
|
||||
)
|
||||
|
||||
// Wayland maintains a wayland socket with security-context-v1 attached via [wayland].
|
||||
//
|
||||
// The socket stops accepting connections once the pipe referred to by sync is
|
||||
// closed. The socket is pathname only and is destroyed on revert.
|
||||
// The socket stops accepting connections once the pipe referred to by sync is closed.
|
||||
// The socket is pathname only and is destroyed on revert.
|
||||
func (sys *I) Wayland(dst, src *check.Absolute, appID, instanceID string) *I {
|
||||
sys.ops = append(sys.ops, &waylandOp{nil,
|
||||
dst, src, appID, instanceID})
|
||||
@@ -27,7 +26,7 @@ type waylandOp struct {
|
||||
appID, instanceID string
|
||||
}
|
||||
|
||||
func (w *waylandOp) Type() hst.Enablements { return Process }
|
||||
func (w *waylandOp) Type() hst.Enablement { return Process }
|
||||
|
||||
func (w *waylandOp) apply(sys *I) (err error) {
|
||||
if w.ctx, err = sys.waylandNew(w.src, w.dst, w.appID, w.instanceID); err != nil {
|
||||
|
||||
@@ -5,8 +5,7 @@ import (
|
||||
"hakurei.app/internal/xcb"
|
||||
)
|
||||
|
||||
// ChangeHosts inserts the target user into X11 hosts and deletes it once its
|
||||
// [hst.Enablements] is no longer satisfied.
|
||||
// ChangeHosts inserts the target user into X11 hosts and deletes it once its [Enablement] is no longer satisfied.
|
||||
func (sys *I) ChangeHosts(username string) *I {
|
||||
sys.ops = append(sys.ops, xhostOp(username))
|
||||
return sys
|
||||
@@ -15,7 +14,7 @@ func (sys *I) ChangeHosts(username string) *I {
|
||||
// xhostOp implements [I.ChangeHosts].
|
||||
type xhostOp string
|
||||
|
||||
func (x xhostOp) Type() hst.Enablements { return hst.EX11 }
|
||||
func (x xhostOp) Type() hst.Enablement { return hst.EX11 }
|
||||
|
||||
func (x xhostOp) apply(sys *I) error {
|
||||
sys.msg.Verbosef("inserting entry %s to X11", x)
|
||||
|
||||
@@ -265,7 +265,7 @@ in
|
||||
'';
|
||||
in
|
||||
pkgs.writeShellScriptBin app.name ''
|
||||
exec hakurei${if app.verbose then " -v" else ""}${if app.insecureWayland then " --insecure" else ""} run ${checkedConfig "hakurei-app-${app.name}.json" conf} $@
|
||||
exec hakurei${if app.verbose then " -v" else ""} run ${checkedConfig "hakurei-app-${app.name}.json" conf} $@
|
||||
''
|
||||
)
|
||||
]
|
||||
|
||||
41
options.md
41
options.md
@@ -35,7 +35,7 @@ package
|
||||
|
||||
|
||||
*Default:*
|
||||
` <derivation hakurei-static-x86_64-unknown-linux-musl-0.4.0> `
|
||||
` <derivation hakurei-static-x86_64-unknown-linux-musl-0.3.6> `
|
||||
|
||||
|
||||
|
||||
@@ -542,43 +542,6 @@ null or string
|
||||
|
||||
|
||||
|
||||
## environment\.hakurei\.apps\.\<name>\.schedPolicy
|
||||
|
||||
|
||||
|
||||
Scheduling policy to set for the container\.
|
||||
The zero value retains the current scheduling policy\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or one of “fifo”, “rr”, “batch”, “idle”, “deadline”, “ext”
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` null `
|
||||
|
||||
|
||||
|
||||
## environment\.hakurei\.apps\.\<name>\.schedPriority
|
||||
|
||||
|
||||
|
||||
Scheduling priority to set for the container\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or integer between 1 and 99 (both inclusive)
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` null `
|
||||
|
||||
|
||||
|
||||
## environment\.hakurei\.apps\.\<name>\.script
|
||||
|
||||
|
||||
@@ -842,7 +805,7 @@ package
|
||||
|
||||
|
||||
*Default:*
|
||||
` <derivation hakurei-hsu-0.4.0> `
|
||||
` <derivation hakurei-hsu-0.3.6> `
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
buildGo126Module rec {
|
||||
pname = "hakurei";
|
||||
version = "0.4.0";
|
||||
version = "0.3.7";
|
||||
|
||||
srcFiltered = builtins.path {
|
||||
name = "${pname}-src";
|
||||
|
||||
Reference in New Issue
Block a user