Prepare Sandwich Hime v1 release candidate source

This commit is contained in:
2026-08-23 16:25:02 -04:00
parent c11552b87a
commit efd5b997ed
64 changed files with 5276 additions and 156 deletions
+467
View File
@@ -0,0 +1,467 @@
// SPDX-License-Identifier: AGPL-3.0-only
//go:build himesan_browser_evidence
package devserver
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"gamertan.com/sandwich-hime/internal/compiler"
)
const realBrowserTimeout = 20 * time.Second
func TestRealBrowserDevelopmentClient(t *testing.T) {
chrome := os.Getenv("HIMESAN_CHROME")
if chrome == "" {
t.Fatal("HIMESAN_CHROME must name the reviewed Chrome or Chromium executable")
}
if info, err := os.Stat(chrome); err != nil || info.IsDir() || info.Mode()&0o111 == 0 {
t.Fatalf("HIMESAN_CHROME is not an executable regular file: %q", chrome)
}
t.Run("diagnostic overlay and CSP", func(t *testing.T) {
harness := newRealBrowserHarness(t)
document := runRealBrowser(t, chrome, harness.url("/"), "browser evidence diagnostic", func() {
waitForRealBrowserSubscriber(t, harness.hub)
harness.hub.publish(Event{
Type: "diagnostic", Phase: "generate", Message: "browser evidence diagnostic",
Diagnostics: []Diagnostic{{Path: "views/home.sando", Line: 7, Column: 3, Code: "HIM1300", Message: "deliberate evidence fixture"}},
})
})
for _, want := range []string{"id=\"__himesan_overlay\"", "browser evidence diagnostic", "views/home.sando:7:3 [HIM1300]"} {
if !strings.Contains(document, want) {
t.Fatalf("browser DOM lacks %q:\n%s", want, document)
}
}
})
t.Run("reload", func(t *testing.T) {
harness := newRealBrowserHarness(t)
document := runRealBrowser(t, chrome, harness.url("/"), "version two", func() {
waitForRealBrowserSubscriber(t, harness.hub)
harness.version.Store(2)
harness.hub.publish(Event{Type: "reload", Phase: "serve", Message: "healthy application activated"})
})
if !strings.Contains(document, `<main id="page-version">version two</main>`) {
t.Fatalf("browser did not reload the selected document:\n%s", document)
}
})
t.Run("fragment and API exclusions", func(t *testing.T) {
harness := newRealBrowserHarness(t)
for _, path := range []string{"/fragment", "/api"} {
document := runRealBrowser(t, chrome, harness.url(path), "", nil)
if strings.Contains(document, "data-himesan-reload") || strings.Contains(document, "__himesan_overlay") {
t.Fatalf("development client leaked into %s:\n%s", path, document)
}
}
})
t.Run("generated document parsing", func(t *testing.T) {
root := writeGeneratedBrowserApplication(t)
cfg := DefaultConfig()
cfg.ProxyAddress = "127.0.0.1:0"
cfg.HealthPath = "/healthz"
supervisor, err := New(Options{
RootDir: root,
Config: cfg,
Generate: func(ctx context.Context) error {
_, generateErr := compiler.Generate(ctx, []string{root})
return generateErr
},
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 30 * time.Second,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
StartupTimeout: integrationCandidateStartupTimeout,
ShutdownTimeout: 2 * time.Second,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
runResult := make(chan error, 1)
go func() { runResult <- supervisor.Run(ctx) }()
proxyAddress := waitForProxyAddress(t, supervisor)
waitForBody(t, "http://"+proxyAddress+"/", "generated-browser-document")
document := runRealBrowser(t, chrome, "http://"+proxyAddress+"/", "generated-browser-document", nil)
for _, want := range []string{
`<main id="generated-browser-document"`,
`<h1 id="title">&lt;unsafe&gt; &amp; "quoted"</h1>`,
`<textarea id="note">&lt;/textarea&gt;&lt;script id="attacker"&gt;window.evidenceFailed=true&lt;/script&gt;</textarea>`,
`<a id="destination" href="/safe?q=a&amp;b=c">Open</a>`,
`<table id="table"><tbody><tr><td id="cell">&lt;unsafe&gt; &amp; "quoted"</td></tr></tbody></table>`,
} {
if !strings.Contains(document, want) {
t.Fatalf("generated browser DOM lacks %q:\n%s", want, document)
}
}
if strings.Contains(document, `<script id="attacker">`) {
t.Fatalf("hostile RCDATA became executable structure:\n%s", document)
}
cancel()
select {
case err := <-runResult:
if err != nil {
t.Fatalf("stop generated-document supervisor: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("generated-document supervisor did not stop")
}
})
}
func writeGeneratedBrowserApplication(t *testing.T) string {
t.Helper()
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("resolve test-owned browser fixture: %v", err)
}
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate browser evidence source")
}
repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", ".."))
files := map[string]string{
"go.mod": fmt.Sprintf("module example.test/himesan-browser-evidence\n\ngo 1.25\n\nrequire gamertan.com/sandwich-hime/sando v0.0.0\nreplace gamertan.com/sandwich-hime/sando => %s/sando\n", filepath.ToSlash(repositoryRoot)),
"page.sando": `<?sando go
package main
func Page(title string, note string, destination string)
?>
<!doctype html>
<html><head><title><?= title ?></title></head><body>
<main id="generated-browser-document" data-note="<?= note ?>">
<h1 id="title"><?= title ?></h1>
<textarea id="note"><?= note ?></textarea>
<a id="destination" href="<?= destination ?>">Open</a>
<table id="table"><tbody><tr><td id="cell"><?= title ?></td></tr></tbody></table>
</main>
</body></html>
`,
"main.go": `package main
import (
"net/http"
"os"
"gamertan.com/sandwich-hime/sando"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
mux.HandleFunc("/", func(w http.ResponseWriter, request *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := sando.Render(request.Context(), w, Page(
` + "`<unsafe> & \"quoted\"`" + `,
` + "`</textarea><script id=\"attacker\">window.evidenceFailed=true</script>`" + `,
"/safe?q=a&b=c",
)); err != nil {
panic(err)
}
})
if err := http.ListenAndServe(os.Getenv("HIMESAN_LISTEN_ADDR"), mux); err != nil {
panic(err)
}
}
`,
}
for name, contents := range files {
if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
if _, err := compiler.Generate(context.Background(), []string{root}); err != nil {
t.Fatalf("generate browser evidence fixture: %v", err)
}
return root
}
type realBrowserHarness struct {
hub *eventHub
version atomic.Int32
upstream *httptest.Server
proxy *httptest.Server
}
func newRealBrowserHarness(t *testing.T) *realBrowserHarness {
t.Helper()
harness := &realBrowserHarness{hub: newEventHub()}
harness.version.Store(1)
harness.upstream = httptest.NewServer(http.HandlerFunc(harness.serveApplication))
development := newDevelopmentProxy(harness.hub)
upstreamURL, err := url.Parse(harness.upstream.URL)
if err != nil {
t.Fatal(err)
}
if err := development.setTarget(upstreamURL.Host); err != nil {
t.Fatal(err)
}
harness.proxy = httptest.NewServer(development)
proxyURL, err := url.Parse(harness.proxy.URL)
if err != nil {
t.Fatal(err)
}
if err := development.setAuthority(proxyURL.Host); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
harness.hub.close()
harness.proxy.Close()
harness.upstream.Close()
})
return harness
}
func (h *realBrowserHarness) url(path string) string {
return h.proxy.URL + path
}
func (h *realBrowserHarness) serveApplication(w http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/":
version := "one"
if h.version.Load() == 2 {
version = "two"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'none'; script-src-elem 'none'; connect-src 'none'")
fmt.Fprintf(w, `<!doctype html><html><body><main id="page-version">version %s</main></body></html>`, version)
case "/fragment":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<p id="fragment">fragment only</p>`)
case "/api":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"kind":"api","ok":true}`)
default:
http.NotFound(w, request)
}
}
func runRealBrowser(t *testing.T, chrome, target, want string, afterStart func()) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), realBrowserTimeout)
defer cancel()
toBrowserRead, toBrowserWrite, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
fromBrowserRead, fromBrowserWrite, err := os.Pipe()
if err != nil {
_ = toBrowserRead.Close()
_ = toBrowserWrite.Close()
t.Fatal(err)
}
defer toBrowserWrite.Close()
defer fromBrowserRead.Close()
stderrPath := filepath.Join(t.TempDir(), "chrome.stderr")
stderr, err := os.OpenFile(stderrPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
t.Fatal(err)
}
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
_ = stderr.Close()
t.Fatal(err)
}
command := exec.CommandContext(ctx, chrome,
"--headless=new",
"--disable-background-networking",
"--disable-component-update",
"--disable-default-apps",
"--disable-sync",
"--metrics-recording-only",
"--no-first-run",
"--no-default-browser-check",
"--user-data-dir="+t.TempDir(),
"--remote-debugging-pipe",
"about:blank",
)
command.ExtraFiles = []*os.File{toBrowserRead, fromBrowserWrite}
command.Stdout = devNull
command.Stderr = stderr
command.WaitDelay = 2 * time.Second
if err := command.Start(); err != nil {
_ = toBrowserRead.Close()
_ = fromBrowserWrite.Close()
_ = stderr.Close()
_ = devNull.Close()
t.Fatalf("start real browser: %v", err)
}
_ = toBrowserRead.Close()
_ = fromBrowserWrite.Close()
client := &devToolsPipe{reader: bufio.NewReader(fromBrowserRead), writer: toBrowserWrite}
targetResult := client.call(t, "", "Target.createTarget", map[string]any{"url": target})
var created struct {
TargetID string `json:"targetId"`
}
if err := json.Unmarshal(targetResult, &created); err != nil || created.TargetID == "" {
t.Fatalf("decode Chrome target: %v", err)
}
attachResult := client.call(t, "", "Target.attachToTarget", map[string]any{"targetId": created.TargetID, "flatten": true})
var attached struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(attachResult, &attached); err != nil || attached.SessionID == "" {
t.Fatalf("decode Chrome session: %v", err)
}
client.call(t, attached.SessionID, "Runtime.enable", map[string]any{})
waitForBrowserDOM(t, client, attached.SessionID, "")
if afterStart != nil {
afterStart()
}
document := waitForBrowserDOM(t, client, attached.SessionID, want)
_ = client.callIgnoringClose("", "Browser.close", map[string]any{})
waitResult := make(chan error, 1)
go func() { waitResult <- command.Wait() }()
select {
case err := <-waitResult:
if err != nil && !errors.Is(err, exec.ErrWaitDelay) {
_ = stderr.Close()
contents, _ := os.ReadFile(stderrPath)
t.Fatalf("real browser failed: %v; stderr=%s", err, boundedBrowserOutput(string(contents)))
}
case <-time.After(5 * time.Second):
_ = command.Process.Kill()
t.Fatal("real browser did not exit after Browser.close")
}
_ = stderr.Close()
_ = devNull.Close()
return document
}
type devToolsPipe struct {
reader *bufio.Reader
writer io.Writer
nextID int
}
type devToolsEnvelope struct {
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func (c *devToolsPipe) call(t *testing.T, session, method string, parameters map[string]any) json.RawMessage {
t.Helper()
c.nextID++
request := map[string]any{"id": c.nextID, "method": method, "params": parameters}
if session != "" {
request["sessionId"] = session
}
contents, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
contents = append(contents, 0)
if _, err := c.writer.Write(contents); err != nil {
t.Fatalf("write Chrome DevTools request: %v", err)
}
for {
message, err := c.reader.ReadBytes(0)
if err != nil {
t.Fatalf("read Chrome DevTools response: %v", err)
}
if len(message) > 4<<20 {
t.Fatal("Chrome DevTools response exceeds 4 MiB")
}
var response devToolsEnvelope
if err := json.Unmarshal(bytes.TrimSuffix(message, []byte{0}), &response); err != nil {
t.Fatalf("decode Chrome DevTools response: %v", err)
}
if response.ID != c.nextID {
continue
}
if response.Error != nil {
t.Fatalf("Chrome DevTools %s failed (%d): %s", method, response.Error.Code, response.Error.Message)
}
return response.Result
}
}
func (c *devToolsPipe) callIgnoringClose(session, method string, parameters map[string]any) error {
c.nextID++
request := map[string]any{"id": c.nextID, "method": method, "params": parameters}
if session != "" {
request["sessionId"] = session
}
contents, err := json.Marshal(request)
if err != nil {
return err
}
_, err = c.writer.Write(append(contents, 0))
return err
}
func waitForBrowserDOM(t *testing.T, client *devToolsPipe, session, want string) string {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
var last string
for time.Now().Before(deadline) {
result := client.call(t, session, "Runtime.evaluate", map[string]any{
"expression": "document.documentElement && document.documentElement.outerHTML",
"returnByValue": true,
})
var evaluated struct {
Result struct {
Type string `json:"type"`
Value string `json:"value"`
} `json:"result"`
}
if err := json.Unmarshal(result, &evaluated); err == nil && evaluated.Result.Type == "string" {
last = evaluated.Result.Value
if last != "" && (want == "" || strings.Contains(last, want)) {
return last
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("browser DOM did not contain %q; last DOM:\n%s", want, last)
return ""
}
func waitForRealBrowserSubscriber(t *testing.T, hub *eventHub) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
hub.mu.Lock()
count := len(hub.subscribers)
hub.mu.Unlock()
if count != 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("real browser did not establish the same-origin event stream")
}
func boundedBrowserOutput(value string) string {
const limit = 4096
if len(value) <= limit {
return value
}
return value[:limit] + "\n[Chrome output truncated]"
}
+77
View File
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
func TestV1ConfigSchemaMatchesConfigTypeAndDefaults(t *testing.T) {
t.Parallel()
contents, err := os.ReadFile(filepath.Join("..", "..", "contracts", "himesan-config-v1.schema.json"))
if err != nil {
t.Fatal(err)
}
var schema struct {
AdditionalProperties bool `json:"additionalProperties"`
Required []string `json:"required"`
Properties map[string]json.RawMessage `json:"properties"`
}
if err := json.Unmarshal(contents, &schema); err != nil {
t.Fatal(err)
}
if schema.AdditionalProperties {
t.Fatal("v1 config schema must reject unknown fields")
}
if !reflect.DeepEqual(schema.Required, []string{"version"}) {
t.Fatalf("required config fields = %v, want [version]", schema.Required)
}
typeOfConfig := reflect.TypeOf(Config{})
fields := make([]string, 0, typeOfConfig.NumField())
for index := 0; index < typeOfConfig.NumField(); index++ {
name := strings.Split(typeOfConfig.Field(index).Tag.Get("json"), ",")[0]
fields = append(fields, name)
}
sort.Strings(fields)
properties := make([]string, 0, len(schema.Properties))
for name := range schema.Properties {
properties = append(properties, name)
}
sort.Strings(properties)
if !reflect.DeepEqual(fields, properties) {
t.Fatalf("Config JSON fields %v do not match schema properties %v", fields, properties)
}
defaults := DefaultConfig()
wantDefaults := map[string]any{
"version": float64(defaults.Version),
"sourceRoots": []any{"."},
"goPackage": defaults.GoPackage,
"appArgs": []any{},
"listenAddressEnv": defaults.ListenAddressEnv,
"healthPath": defaults.HealthPath,
"proxyAddress": defaults.ProxyAddress,
"additionalWatchRoots": []any{},
}
for name, want := range wantDefaults {
var property map[string]any
if err := json.Unmarshal(schema.Properties[name], &property); err != nil {
t.Fatal(err)
}
if name == "version" {
if !reflect.DeepEqual(property["const"], want) {
t.Fatalf("schema %s const = %#v, want %#v", name, property["const"], want)
}
continue
}
if !reflect.DeepEqual(property["default"], want) {
t.Fatalf("schema %s default = %#v, want %#v", name, property["default"], want)
}
}
}
+4 -1
View File
@@ -81,7 +81,7 @@ func (c *candidateProcess) cleanupProcessTree() error {
processTree := c.processTree
c.processTree = 0
c.mu.Unlock()
return cleanupProcess(c.command, processTree)
return acceptableStopError(cleanupProcess(c.command, processTree))
}
func (c *candidateProcess) stop(ctx context.Context) error {
@@ -119,6 +119,9 @@ func acceptableStopError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, os.ErrProcessDone) {
return nil
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return nil
+7
View File
@@ -87,6 +87,13 @@ func TestTaskkillArguments(t *testing.T) {
}
}
func TestAlreadyFinishedIsAnAcceptableStopResult(t *testing.T) {
t.Parallel()
if err := acceptableStopError(os.ErrProcessDone); err != nil {
t.Fatalf("already-finished process was treated as a stop failure: %v", err)
}
}
func TestManagedProcessHelper(t *testing.T) {
switch os.Getenv("HIMESAN_PROCESS_HELPER") {
case "":
+23 -9
View File
@@ -18,6 +18,8 @@ import (
"time"
)
const integrationCandidateStartupTimeout = 3 * time.Second
func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
if testing.Short() {
t.Skip("integration test builds temporary Go applications")
@@ -46,11 +48,14 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
t.Logf("supervisor event: type=%s phase=%s message=%s", event.Type, event.Phase, event.Message)
events <- event
},
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 25 * time.Millisecond,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
StartupTimeout: 750 * time.Millisecond,
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 25 * time.Millisecond,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
// Keep the production default unchanged. Native release runners may
// compile another Go line concurrently, so this integration test gives
// the temporary child enough time to be scheduled under honest load.
StartupTimeout: integrationCandidateStartupTimeout,
ShutdownTimeout: 2 * time.Second,
})
if err != nil {
@@ -128,7 +133,7 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
PollInterval: 30 * time.Second,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
StartupTimeout: time.Second,
StartupTimeout: integrationCandidateStartupTimeout,
ShutdownTimeout: 2 * time.Second,
})
if err != nil {
@@ -140,7 +145,11 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
t.Cleanup(cancel)
proxyAddress := waitForProxyAddress(t, supervisor)
waitForBody(t, "http://"+proxyAddress+"/", "short lived")
// Observe the supervisor's ordered activation event instead of racing an
// HTTP request against the deliberately short-lived child. Other tests
// prove proxy serving; this test proves that an activated child which exits
// clears its selected target under scheduler and compiler load.
waitForEvent(t, events, "reload", "serve")
waitForPhase(t, events, "run")
if target := supervisor.proxy.target.Load(); target != nil {
t.Fatalf("proxy retained exited upstream %v", target)
@@ -290,17 +299,22 @@ func main() {
}
func waitForPhase(t *testing.T, events <-chan Event, phase string) {
t.Helper()
waitForEvent(t, events, "diagnostic", phase)
}
func waitForEvent(t *testing.T, events <-chan Event, eventType, phase string) {
t.Helper()
timer := time.NewTimer(10 * time.Second)
defer timer.Stop()
for {
select {
case event := <-events:
if event.Type == "diagnostic" && event.Phase == phase {
if event.Type == eventType && event.Phase == phase {
return
}
case <-timer.C:
t.Fatalf("did not receive %s diagnostic", phase)
t.Fatalf("did not receive %s/%s event", eventType, phase)
}
}
}