Prepare Sandwich Hime v1 release candidate source
This commit is contained in:
@@ -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"><unsafe> & "quoted"</h1>`,
|
||||
`<textarea id="note"></textarea><script id="attacker">window.evidenceFailed=true</script></textarea>`,
|
||||
`<a id="destination" href="/safe?q=a&b=c">Open</a>`,
|
||||
`<table id="table"><tbody><tr><td id="cell"><unsafe> & "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]"
|
||||
}
|
||||
Reference in New Issue
Block a user