feat: publish the Sandwich Hime source preview
Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"go/scanner"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/version"
|
||||
)
|
||||
|
||||
type backendImports struct {
|
||||
Context string
|
||||
IO string
|
||||
Sando string
|
||||
All []sourceImport
|
||||
}
|
||||
|
||||
// Compile parses, context-checks, and formats one .sando source entirely in
|
||||
// memory. It never reads project metadata, writes a file, or executes Go code.
|
||||
func Compile(path string, source []byte) (CompiledFile, []Diagnostic) {
|
||||
return compileWithMapping(path, source, filepath.ToSlash(filepath.Base(path)))
|
||||
}
|
||||
|
||||
func compileWithMapping(path string, source []byte, mapping string) (CompiledFile, []Diagnostic) {
|
||||
cleanPath := filepath.Clean(path)
|
||||
file, diagnostics := parseSource(cleanPath, source)
|
||||
if file == nil {
|
||||
sortDiagnostics(diagnostics)
|
||||
return CompiledFile{}, diagnostics
|
||||
}
|
||||
file.Mapping = mapping
|
||||
diagnostics = append(diagnostics, analyzeContexts(file)...)
|
||||
diagnostics = append(diagnostics, auditTrustCalls(file)...)
|
||||
if hasErrors(diagnostics) {
|
||||
sortDiagnostics(diagnostics)
|
||||
return CompiledFile{}, diagnostics
|
||||
}
|
||||
|
||||
code, backendDiagnostics := generateGo(file)
|
||||
diagnostics = append(diagnostics, backendDiagnostics...)
|
||||
if hasErrors(diagnostics) {
|
||||
sortDiagnostics(diagnostics)
|
||||
return CompiledFile{}, diagnostics
|
||||
}
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(source))
|
||||
compiled := CompiledFile{
|
||||
SourcePath: cleanPath,
|
||||
OutputPath: cleanPath + ".go",
|
||||
Package: file.Package,
|
||||
Component: file.Name,
|
||||
Digest: digest,
|
||||
Code: code,
|
||||
source: file,
|
||||
}
|
||||
sortDiagnostics(diagnostics)
|
||||
return compiled, diagnostics
|
||||
}
|
||||
|
||||
func generateGo(file *sourceFile) ([]byte, []Diagnostic) {
|
||||
imports, diagnostics := prepareImports(file)
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, diagnostics
|
||||
}
|
||||
usedIdentifiers := sourceIdentifiers(file.Source)
|
||||
contextName := uniqueIdentifier("__himesan_render_context", usedIdentifiers)
|
||||
writerName := uniqueIdentifier("__himesan_writer", usedIdentifiers)
|
||||
errorName := uniqueIdentifier("__himesan_error", usedIdentifiers)
|
||||
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(file.Source))
|
||||
var output strings.Builder
|
||||
output.WriteString(generatedPrefix)
|
||||
output.WriteByte('\n')
|
||||
fmt.Fprintf(&output, "// himesan:compiler %s\n", version.Compiler)
|
||||
fmt.Fprintf(&output, "// himesan:runtime-abi %s\n", version.RuntimeABI)
|
||||
fmt.Fprintf(&output, "// himesan:source-sha256 %s\n\n", digest)
|
||||
fmt.Fprintf(&output, "package %s\n\n", file.Package)
|
||||
output.WriteString("import (\n")
|
||||
for _, imported := range imports.All {
|
||||
if imported.Alias != "" {
|
||||
fmt.Fprintf(&output, "\t%s %q\n", imported.Alias, imported.Path)
|
||||
} else {
|
||||
fmt.Fprintf(&output, "\t%q\n", imported.Path)
|
||||
}
|
||||
}
|
||||
output.WriteString(")\n\n")
|
||||
fmt.Fprintf(&output, "var _ = %s.ABI\n\n", imports.Sando)
|
||||
fmt.Fprintf(&output, "func %s%s%s %s.Component {\n", file.Name, file.TypeParams, file.Params, imports.Sando)
|
||||
fmt.Fprintf(&output, "\treturn %s.ComponentFunc(func(%s %s.Context, %s %s.Writer) error {\n", imports.Sando, contextName, imports.Context, writerName, imports.IO)
|
||||
fmt.Fprintf(&output, "\t\t_ = %s\n", contextName)
|
||||
|
||||
directiveName := sanitizeDirectivePath(file.Mapping)
|
||||
for _, node := range file.Nodes {
|
||||
if node.Kind == nodeComment {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&output, "//line %s:%d:%d\n", directiveName, node.Pos.Line, node.Pos.Column)
|
||||
switch node.Kind {
|
||||
case nodeText:
|
||||
if node.Text == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&output, "if %s := %s.WriteString(%s, %s); %s != nil { return %s }\n", errorName, imports.Sando, writerName, strconv.Quote(node.Text), errorName, errorName)
|
||||
case nodeStatement:
|
||||
output.WriteString(node.Text)
|
||||
output.WriteByte('\n')
|
||||
case nodeExpression:
|
||||
helper := "WriteText"
|
||||
switch node.Context {
|
||||
case ContextAttr:
|
||||
helper = "WriteAttr"
|
||||
case ContextRCDATA:
|
||||
helper = "WriteRCDATA"
|
||||
case ContextURL:
|
||||
helper = "WriteURL"
|
||||
case ContextJS:
|
||||
helper = "WriteJS"
|
||||
case ContextCSS:
|
||||
helper = "WriteCSS"
|
||||
}
|
||||
fmt.Fprintf(&output, "if %s := %s.%s(%s, (%s)); %s != nil { return %s }\n", errorName, imports.Sando, helper, writerName, node.Text, errorName, errorName)
|
||||
case nodeComponent:
|
||||
fmt.Fprintf(&output, "if %s := %s.Render(%s, %s, (%s)); %s != nil { return %s }\n", errorName, imports.Sando, contextName, writerName, node.Text, errorName, errorName)
|
||||
}
|
||||
}
|
||||
output.WriteString("return nil\n")
|
||||
output.WriteString("})\n")
|
||||
output.WriteString("}\n")
|
||||
|
||||
formatted, err := format.Source([]byte(output.String()))
|
||||
if err != nil {
|
||||
position := sourcePosition{Line: 1, Column: 1}
|
||||
message := err.Error()
|
||||
if list, ok := err.(scanner.ErrorList); ok && len(list) != 0 {
|
||||
position.Line = list[0].Pos.Line
|
||||
position.Column = list[0].Pos.Column
|
||||
message = list[0].Msg
|
||||
}
|
||||
return nil, []Diagnostic{diagnostic(file.Path, position, "HIM1401", "generated Go is invalid: "+message)}
|
||||
}
|
||||
return formatted, diagnostics
|
||||
}
|
||||
|
||||
func sanitizeDirectivePath(path string) string {
|
||||
path = filepath.ToSlash(path)
|
||||
var sanitized strings.Builder
|
||||
for index := 0; index < len(path); index++ {
|
||||
b := path[index]
|
||||
if b < 0x20 || b == 0x7f || b == '%' {
|
||||
fmt.Fprintf(&sanitized, "%%%02X", b)
|
||||
continue
|
||||
}
|
||||
sanitized.WriteByte(b)
|
||||
}
|
||||
if sanitized.Len() == 0 {
|
||||
return "source.sando"
|
||||
}
|
||||
return sanitized.String()
|
||||
}
|
||||
|
||||
func prepareImports(file *sourceFile) (backendImports, []Diagnostic) {
|
||||
imports := append([]sourceImport(nil), file.Imports...)
|
||||
identifiers := sourceIdentifiers(file.Source)
|
||||
for _, imported := range imports {
|
||||
if imported.Alias != "" && imported.Alias != "_" {
|
||||
identifiers[imported.Alias] = true
|
||||
} else if imported.Alias == "" {
|
||||
identifiers[defaultImportName(imported.Path)] = true
|
||||
}
|
||||
}
|
||||
|
||||
var diagnostics []Diagnostic
|
||||
ensure := func(path, base string) string {
|
||||
for _, imported := range imports {
|
||||
if imported.Path != path {
|
||||
continue
|
||||
}
|
||||
if imported.Alias == "_" {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, sourcePosition{Line: 1, Column: 1}, "HIM1410", fmt.Sprintf("internal dependency %q cannot be imported for side effects", path)))
|
||||
return ""
|
||||
}
|
||||
if imported.Alias != "" {
|
||||
return imported.Alias
|
||||
}
|
||||
return defaultImportName(path)
|
||||
}
|
||||
alias := uniqueIdentifier(base, identifiers)
|
||||
imports = append(imports, sourceImport{Alias: alias, Path: path})
|
||||
return alias
|
||||
}
|
||||
|
||||
contextAlias := ensure("context", "__himesan_context")
|
||||
ioAlias := ensure("io", "__himesan_io")
|
||||
sandoAlias := ensure(runtimeImportPath, "__himesan_sando")
|
||||
sort.SliceStable(imports, func(i, j int) bool {
|
||||
if imports[i].Path != imports[j].Path {
|
||||
return imports[i].Path < imports[j].Path
|
||||
}
|
||||
return imports[i].Alias < imports[j].Alias
|
||||
})
|
||||
return backendImports{Context: contextAlias, IO: ioAlias, Sando: sandoAlias, All: imports}, diagnostics
|
||||
}
|
||||
|
||||
func sourceIdentifiers(source []byte) map[string]bool {
|
||||
identifiers := make(map[string]bool)
|
||||
var lexical scanner.Scanner
|
||||
fileSet := token.NewFileSet()
|
||||
file := fileSet.AddFile("source.sando", -1, len(source))
|
||||
lexical.Init(file, source, nil, scanner.ScanComments)
|
||||
for {
|
||||
_, tok, literal := lexical.Scan()
|
||||
if tok == token.EOF {
|
||||
break
|
||||
}
|
||||
if tok == token.IDENT {
|
||||
identifiers[literal] = true
|
||||
}
|
||||
}
|
||||
return identifiers
|
||||
}
|
||||
|
||||
func uniqueIdentifier(base string, used map[string]bool) string {
|
||||
name := base
|
||||
for suffix := 2; used[name]; suffix++ {
|
||||
name = fmt.Sprintf("%s_%d", base, suffix)
|
||||
}
|
||||
used[name] = true
|
||||
return name
|
||||
}
|
||||
|
||||
func defaultImportName(path string) string {
|
||||
base := filepath.Base(path)
|
||||
if index := strings.IndexByte(base, '.'); index >= 0 {
|
||||
base = base[:index]
|
||||
}
|
||||
base = strings.ReplaceAll(base, "-", "_")
|
||||
return base
|
||||
}
|
||||
|
||||
func auditTrustCalls(file *sourceFile) []Diagnostic {
|
||||
trusted := map[string]bool{
|
||||
"TrustHTML": true,
|
||||
"TrustURL": true,
|
||||
"TrustJS": true,
|
||||
"TrustCSS": true,
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
trustedTypeSeen := make(map[string]bool)
|
||||
var headerScanner scanner.Scanner
|
||||
headerFileSet := token.NewFileSet()
|
||||
headerFile := headerFileSet.AddFile(filepath.Base(file.Path), -1, file.HeaderEnd)
|
||||
headerScanner.Init(headerFile, file.Source[:file.HeaderEnd], nil, scanner.ScanComments)
|
||||
for {
|
||||
_, tok, literal := headerScanner.Scan()
|
||||
if tok == token.EOF {
|
||||
break
|
||||
}
|
||||
if tok == token.IDENT && (literal == "TrustedHTML" || literal == "TrustedURL" || literal == "TrustedJS" || literal == "TrustedCSS") && !trustedTypeSeen[literal] {
|
||||
trustedTypeSeen[literal] = true
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Path: file.Path,
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
Code: "HIM1903",
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("component signature names %s; audit every value supplied through this trust boundary", literal),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, node := range file.Nodes {
|
||||
if node.Kind != nodeExpression && node.Kind != nodeComponent && node.Kind != nodeStatement {
|
||||
continue
|
||||
}
|
||||
if node.Kind == nodeExpression && (node.Context == ContextJS || node.Context == ContextCSS) {
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Path: file.Path,
|
||||
Line: node.Pos.Line,
|
||||
Column: node.Pos.Column,
|
||||
Code: "HIM1902",
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("dynamic %s output requires an explicitly trusted runtime value; audit its provenance", node.Context),
|
||||
})
|
||||
}
|
||||
var lexical scanner.Scanner
|
||||
set := token.NewFileSet()
|
||||
goFile := set.AddFile(filepath.Base(file.Path), -1, len(node.Text))
|
||||
lexical.Init(goFile, []byte(node.Text), nil, scanner.ScanComments)
|
||||
for {
|
||||
_, tok, literal := lexical.Scan()
|
||||
if tok == token.EOF {
|
||||
break
|
||||
}
|
||||
if tok == token.IDENT && trusted[literal] {
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Path: file.Path,
|
||||
Line: node.Pos.Line,
|
||||
Column: node.Pos.Column,
|
||||
Code: "HIM1901",
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("conspicuous trusted-value constructor %s is used; audit its provenance", literal),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
return bytes.Equal(a, b)
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const profileSource = `<?sando go
|
||||
package views
|
||||
|
||||
func Profile(name string, admin bool)
|
||||
?>
|
||||
<section class="profile" data-name="<?= name ?>">
|
||||
<h1><?= name ?></h1>
|
||||
<? if admin { ?>
|
||||
<strong>Admin</strong>
|
||||
<? } ?>
|
||||
</section>
|
||||
`
|
||||
|
||||
func TestCompileDeterministicContextAnnotatedBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
first, diagnostics := Compile("views/profile.sando", []byte(profileSource))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
second, secondDiagnostics := Compile("views/profile.sando", []byte(profileSource))
|
||||
assertNoErrorDiagnostics(t, secondDiagnostics)
|
||||
if !bytes.Equal(first.Code, second.Code) {
|
||||
t.Fatal("repeated compilation was not deterministic")
|
||||
}
|
||||
generated := string(first.Code)
|
||||
for _, expected := range []string{
|
||||
generatedPrefix,
|
||||
"// himesan:compiler ",
|
||||
"// himesan:runtime-abi sando.v1",
|
||||
"// himesan:source-sha256 ",
|
||||
"func Profile(name string, admin bool)",
|
||||
".WriteAttr(",
|
||||
".WriteText(",
|
||||
".WriteString(",
|
||||
"ComponentFunc(func(",
|
||||
"//line profile.sando:",
|
||||
} {
|
||||
if !strings.Contains(generated, expected) {
|
||||
t.Fatalf("generated code does not contain %q:\n%s", expected, generated)
|
||||
}
|
||||
}
|
||||
if strings.Contains(generated, "AGPL") || strings.Contains(generated, "SPDX-License-Identifier") {
|
||||
t.Fatal("generated application code inherited the compiler license header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommittedGoldenOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
|
||||
sourcePath := filepath.Join(root, "examples", "eql-shaped", "views", "badge.sando")
|
||||
wantPath := sourcePath + ".go"
|
||||
compiled, diagnostics := compileWithMapping(sourcePath, mustRead(t, sourcePath), "views/badge.sando")
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if want := mustRead(t, wantPath); !bytes.Equal(compiled.Code, want) {
|
||||
t.Fatalf("committed golden output is stale; run himesan generate\n--- got ---\n%s\n--- want ---\n%s", compiled.Code, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderAllowsBOMWhitespaceAndGoLexicalDelimiters(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := "\xef\xbb\xbf \r\n\t<?sando go\npackage views\nfunc Lexical(v struct { Tag string `json:\"?>\"` })\n?>\n<p><?= \"?>\" ?></p>"
|
||||
compiled, diagnostics := Compile("lexical.sando", []byte(source))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if !strings.Contains(string(compiled.Code), `WriteText(`) || !strings.Contains(string(compiled.Code), `"?>"`) {
|
||||
t.Fatalf("lexically protected delimiter was not preserved:\n%s", compiled.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderTrailingLineCommentDoesNotConsumeSyntheticBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := `<?sando go
|
||||
package p
|
||||
func Commented() // ?> remains inside the Go line comment
|
||||
?>
|
||||
<p>ok</p>`
|
||||
if _, diagnostics := Compile("commented.sando", []byte(source)); hasErrors(diagnostics) {
|
||||
t.Fatalf("trailing header comment failed: %v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderRequiresMarkerWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, diagnostics := Compile("bad.sando", []byte("<?sandogo\npackage p\nfunc Bad()\n?>"))
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1105")
|
||||
}
|
||||
|
||||
func TestGoReservedFunctionNamesAreRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
packageName string
|
||||
function string
|
||||
}{
|
||||
{packageName: "views", function: "init"},
|
||||
{packageName: "main", function: "main"},
|
||||
} {
|
||||
source := "<?sando go\npackage " + test.packageName + "\nfunc " + test.function + "()\n?>"
|
||||
_, diagnostics := Compile(test.function+".sando", []byte(source))
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRLFAndGeneratedSyntaxDiagnostics(t *testing.T) {
|
||||
t.Parallel()
|
||||
crlf := strings.ReplaceAll(simpleSource("CRLF", "snow 雪"), "\n", "\r\n")
|
||||
if _, diagnostics := Compile("crlf.sando", []byte(crlf)); hasErrors(diagnostics) {
|
||||
t.Fatalf("CRLF source failed: %v", diagnostics)
|
||||
}
|
||||
invalid := `<?sando go
|
||||
package p
|
||||
func Invalid(value bool)
|
||||
?>
|
||||
<? if value { ?>ok<? definitely-not-go ?><? } ?>`
|
||||
_, diagnostics := Compile("mapped.sando", []byte(invalid))
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1401")
|
||||
for _, item := range diagnostics {
|
||||
if item.Code == "HIM1401" && (item.Line < 5 || item.Column < 1) {
|
||||
t.Fatalf("generated syntax diagnostic was not mapped to source: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineDirectivePathCannotInjectGeneratedGo(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := "bad\ngo-build-injected.sando"
|
||||
compiled, diagnostics := Compile(path, []byte(simpleSource("SafePath", "safe")))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
generated := string(compiled.Code)
|
||||
if strings.Contains(generated, "//line go-build-injected") || !strings.Contains(generated, "%0A") {
|
||||
t.Fatalf("unsafe source path was not encoded in //line directive:\n%s", generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
code string
|
||||
}{
|
||||
{name: "dynamic tag", body: `<<?= "div" ?>>ok</div>`, code: "HIM1303"},
|
||||
{name: "unquoted attribute", body: `<p title=<?= "x" ?>></p>`, code: "HIM1328"},
|
||||
{name: "event attribute", body: `<p onclick="fixed"></p>`, code: "HIM1340"},
|
||||
{name: "component in attribute", body: `<p title="<?~ Child() ?>"></p>`, code: "HIM1302"},
|
||||
{name: "component in textarea", body: `<textarea><?~ Child() ?></textarea>`, code: "HIM1302"},
|
||||
{name: "unbalanced", body: `<div><span></div>`, code: "HIM1352"},
|
||||
{name: "unfinished", body: `<div>`, code: "HIM1311"},
|
||||
{name: "self closing nonvoid", body: `<div/>`, code: "HIM1354"},
|
||||
{name: "foreign SVG", body: `<svg></svg>`, code: "HIM1355"},
|
||||
{name: "ambiguous noscript", body: `<noscript>fallback</noscript>`, code: "HIM1356"},
|
||||
{name: "script escaped state", body: "<script><!--<script></script>\n<?= css ?>\n<!--\n</script>\n-->", code: "HIM1357"},
|
||||
{name: "script escaped state split by template comment", body: `<script><!<?# emits nothing ?>--alert(1)</script>`, code: "HIM1357"},
|
||||
{name: "dynamic iframe raw text", body: `<iframe><?= css ?></iframe>`, code: "HIM1303"},
|
||||
{name: "dynamic style attribute", body: `<p style="<?= css ?>"></p>`, code: "HIM1343"},
|
||||
{name: "dynamic srcdoc attribute", body: `<iframe srcdoc="<?= css ?>"></iframe>`, code: "HIM1345"},
|
||||
{name: "dynamic srcset attribute", body: `<img srcset="<?= css ?>">`, code: "HIM1345"},
|
||||
{name: "meta refresh", body: `<meta content="0;url=javascript:alert(1)" http-equiv="refresh">`, code: "HIM1346"},
|
||||
{name: "dynamic meta http equiv", body: `<meta http-equiv="<?= css ?>" content="safe">`, code: "HIM1346"},
|
||||
{name: "duplicate attribute", body: `<meta http-equiv="refresh" http-equiv="safe">`, code: "HIM1347"},
|
||||
{name: "dangerous static URL", body: `<a href="javascript:alert(1)">x</a>`, code: "HIM1344"},
|
||||
{name: "ambiguous URL pieces", body: `<a href="<?= scheme ?>:payload">x</a>`, code: "HIM1341"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
source := "<?sando go\npackage p\nfunc Example(css, scheme string)\n?>\n" + test.body
|
||||
_, diagnostics := Compile(test.name+".sando", []byte(source))
|
||||
assertDiagnosticCode(t, diagnostics, test.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLCommentSyntaxInOtherRawTextStatesRemainsSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := `<?sando go
|
||||
package p
|
||||
func RawText()
|
||||
?>
|
||||
<style><!-- .old-browser { display: none } --></style>
|
||||
<textarea><!-- literal text --></textarea>
|
||||
<iframe><!-- literal text --></iframe>`
|
||||
_, diagnostics := Compile("raw-text.sando", []byte(source))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
}
|
||||
|
||||
func TestTextareaExpressionUsesTextEscaping(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := `<?sando go
|
||||
package p
|
||||
func Field(value string)
|
||||
?>
|
||||
<textarea><?= value ?></textarea>`
|
||||
compiled, diagnostics := Compile("field.sando", []byte(source))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if !strings.Contains(string(compiled.Code), ".WriteRCDATA(") {
|
||||
t.Fatalf("textarea expression did not use dedicated RCDATA escaping:\n%s", compiled.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedURLAndRawTextContexts(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := `<?sando go
|
||||
package p
|
||||
import "gamertan.com/sandwich-hime/sando"
|
||||
func Safe(id string, js sando.TrustedJS, css sando.TrustedCSS)
|
||||
?>
|
||||
<a href="/item/<?= id ?>">relative</a>
|
||||
<a href="<?= sando.TrustURL("https://example.test/") ?>">trusted</a>
|
||||
<script><?= js ?></script>
|
||||
<style><?= css ?></style>`
|
||||
compiled, diagnostics := Compile("safe.sando", []byte(source))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1901")
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1902")
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1903")
|
||||
generated := string(compiled.Code)
|
||||
for _, helper := range []string{".WriteURL(", ".WriteJS(", ".WriteCSS("} {
|
||||
if !strings.Contains(generated, helper) {
|
||||
t.Fatalf("generated code does not contain %s:\n%s", helper, generated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustWarningsDoNotFailGenerateOrCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "trusted.sando")
|
||||
source := `<?sando go
|
||||
package p
|
||||
import "gamertan.com/sandwich-hime/sando"
|
||||
func Trusted()
|
||||
?>
|
||||
<?= sando.TrustHTML("<b>reviewed</b>") ?>`
|
||||
mustWrite(t, path, source)
|
||||
generated, err := Generate(context.Background(), []string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("warning unexpectedly failed generation: %v", err)
|
||||
}
|
||||
assertDiagnosticCode(t, generated.Diagnostics, "HIM1901")
|
||||
checked, err := Check(context.Background(), []string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("warning unexpectedly failed check: %v", err)
|
||||
}
|
||||
assertDiagnosticCode(t, checked.Diagnostics, "HIM1901")
|
||||
}
|
||||
|
||||
func TestGenerateCheckAndUnchangedTimestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "hello.sando")
|
||||
mustWrite(t, path, `<?sando go
|
||||
package demo
|
||||
func Hello(name string)
|
||||
?>
|
||||
<p>Hello <?= name ?></p>`)
|
||||
first, err := Generate(context.Background(), []string{directory})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v (%v)", err, first.Diagnostics)
|
||||
}
|
||||
if first.Changed != 1 || first.Discovered != 1 {
|
||||
t.Fatalf("unexpected first result: %+v", first)
|
||||
}
|
||||
outputPath := path + ".go"
|
||||
before, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Generate(context.Background(), []string{directory})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Unchanged != 1 || !before.ModTime().Equal(after.ModTime()) {
|
||||
t.Fatalf("unchanged generation changed output metadata: before=%v after=%v result=%+v", before.ModTime(), after.ModTime(), second)
|
||||
}
|
||||
checked, err := Check(context.Background(), []string{directory})
|
||||
if err != nil || checked.Unchanged != 1 {
|
||||
t.Fatalf("fresh check failed: result=%+v err=%v", checked, err)
|
||||
}
|
||||
mustWrite(t, path, strings.ReplaceAll(string(mustRead(t, path)), "Hello", "Welcome"))
|
||||
stale, err := Check(context.Background(), []string{directory})
|
||||
if err == nil || stale.Stale != 1 {
|
||||
t.Fatalf("stale check did not fail: result=%+v err=%v", stale, err)
|
||||
}
|
||||
assertDiagnosticCode(t, stale.Diagnostics, "HIM2204")
|
||||
}
|
||||
|
||||
func TestCompileFailurePreservesEveryLastGoodOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
firstPath := filepath.Join(directory, "first.sando")
|
||||
secondPath := filepath.Join(directory, "second.sando")
|
||||
mustWrite(t, firstPath, simpleSource("First", "first"))
|
||||
mustWrite(t, secondPath, simpleSource("Second", "second"))
|
||||
if _, err := Generate(context.Background(), []string{directory}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstLastGood := mustRead(t, firstPath+".go")
|
||||
secondLastGood := mustRead(t, secondPath+".go")
|
||||
mustWrite(t, firstPath, simpleSource("First", "changed"))
|
||||
mustWrite(t, secondPath, "<?sando go\npackage demo\nfunc Second(\n?>")
|
||||
if result, err := Generate(context.Background(), []string{directory}); err == nil {
|
||||
t.Fatalf("invalid batch unexpectedly generated: %+v", result)
|
||||
}
|
||||
if !bytes.Equal(firstLastGood, mustRead(t, firstPath+".go")) || !bytes.Equal(secondLastGood, mustRead(t, secondPath+".go")) {
|
||||
t.Fatal("a last-good output changed after batch compilation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefusesUnownedOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "page.sando")
|
||||
mustWrite(t, path, simpleSource("Page", "page"))
|
||||
mustWrite(t, path+".go", "package demo\n")
|
||||
result, err := Generate(context.Background(), []string{path})
|
||||
if err == nil {
|
||||
t.Fatalf("Generate overwrote an unowned output: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM2104")
|
||||
if got := string(mustRead(t, path+".go")); got != "package demo\n" {
|
||||
t.Fatalf("unowned output changed to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryBoundariesAndExplicitNestedFile(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation commonly requires additional Windows privileges")
|
||||
}
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
mustWrite(t, filepath.Join(directory, "root.sando"), simpleSource("Root", "root"))
|
||||
mustWrite(t, filepath.Join(directory, ".git", "ignored.sando"), simpleSource("Git", "git"))
|
||||
mustWrite(t, filepath.Join(directory, "vendor", "ignored.sando"), simpleSource("Vendor", "vendor"))
|
||||
mustWrite(t, filepath.Join(directory, "other-repository", ".git"), "gitdir: elsewhere\n")
|
||||
mustWrite(t, filepath.Join(directory, "other-repository", "ignored.sando"), simpleSource("OtherRepository", "other"))
|
||||
nested := filepath.Join(directory, "nested")
|
||||
mustWrite(t, filepath.Join(nested, "go.mod"), "module nested.test\n")
|
||||
nestedSource := filepath.Join(nested, "nested.sando")
|
||||
mustWrite(t, nestedSource, simpleSource("Nested", "nested"))
|
||||
discovered, diagnostics := discover(context.Background(), []string{directory})
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if len(discovered) != 1 || filepath.Base(discovered[0]) != "root.sando" {
|
||||
t.Fatalf("unexpected discovery result: %v", discovered)
|
||||
}
|
||||
explicit, diagnostics := discover(context.Background(), []string{nestedSource})
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if len(explicit) != 1 || explicit[0] != nestedSource {
|
||||
t.Fatalf("explicit nested source was not accepted: %v", explicit)
|
||||
}
|
||||
symlink := filepath.Join(directory, "linked")
|
||||
if err := os.Symlink(nested, symlink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, diagnostics = discover(context.Background(), []string{filepath.Join(symlink, "nested.sando")})
|
||||
assertDiagnosticCode(t, diagnostics, "HIM2003")
|
||||
}
|
||||
|
||||
func TestCheckRejectsSymlinkOutput(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation commonly requires additional Windows privileges")
|
||||
}
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "page.sando")
|
||||
mustWrite(t, path, simpleSource("Page", "page"))
|
||||
target := filepath.Join(directory, "handwritten.go")
|
||||
mustWrite(t, target, "package demo\n")
|
||||
if err := os.Symlink(target, path+".go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := Check(context.Background(), []string{path})
|
||||
if err == nil {
|
||||
t.Fatalf("symlink output unexpectedly passed check: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM2205")
|
||||
}
|
||||
|
||||
func TestGenerateReportsReadOnlyDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX directory mode test")
|
||||
}
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "page.sando")
|
||||
mustWrite(t, path, simpleSource("Page", "page"))
|
||||
if err := os.Chmod(directory, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(directory, 0o755) })
|
||||
result, err := Generate(context.Background(), []string{path})
|
||||
if err == nil {
|
||||
t.Fatalf("read-only directory unexpectedly generated: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM2110")
|
||||
}
|
||||
|
||||
func TestNestedNonRegularGoModIsBoundary(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation commonly requires additional Windows privileges")
|
||||
}
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
target := filepath.Join(directory, "actual.mod")
|
||||
mustWrite(t, target, "module nested.test\n")
|
||||
nested := filepath.Join(directory, "nested")
|
||||
mustWrite(t, filepath.Join(nested, "hidden.sando"), simpleSource("Hidden", "hidden"))
|
||||
if err := os.Symlink(target, filepath.Join(nested, "go.mod")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
discovered, diagnostics := discover(context.Background(), []string{directory})
|
||||
if len(discovered) != 0 {
|
||||
t.Fatalf("traversed nested module with symlink go.mod: %v", discovered)
|
||||
}
|
||||
assertDiagnosticCode(t, diagnostics, "HIM2008")
|
||||
}
|
||||
|
||||
func TestModuleRelativeLineMappings(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
mustWrite(t, filepath.Join(directory, "go.mod"), "module example.test/app\n")
|
||||
path := filepath.Join(directory, "views", "card.sando")
|
||||
mustWrite(t, path, simpleSource("Card", "card"))
|
||||
if _, err := Generate(context.Background(), []string{directory}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generated := string(mustRead(t, path+".go"))
|
||||
if !strings.Contains(generated, "//line views/card.sando:") {
|
||||
t.Fatalf("line mapping was not module-relative:\n%s", generated)
|
||||
}
|
||||
if strings.Contains(generated, filepath.ToSlash(directory)) {
|
||||
t.Fatal("generated output contains an absolute build-machine path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticComponentCycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
mustWrite(t, filepath.Join(directory, "a.sando"), `<?sando go
|
||||
package demo
|
||||
func A()
|
||||
?>
|
||||
<?~ B() ?>`)
|
||||
mustWrite(t, filepath.Join(directory, "b.sando"), `<?sando go
|
||||
package demo
|
||||
func B()
|
||||
?>
|
||||
<?~ A() ?>`)
|
||||
result, err := Generate(context.Background(), []string{directory})
|
||||
if err == nil {
|
||||
t.Fatalf("component cycle unexpectedly generated: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM1501")
|
||||
if _, statErr := os.Stat(filepath.Join(directory, "a.sando.go")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("cycle wrote output: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateComponentNamesFailBeforeWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
mustWrite(t, filepath.Join(directory, "one.sando"), simpleSource("Duplicate", "one"))
|
||||
mustWrite(t, filepath.Join(directory, "two.sando"), simpleSource("Duplicate", "two"))
|
||||
result, err := Generate(context.Background(), []string{directory})
|
||||
if err == nil {
|
||||
t.Fatalf("duplicate component names unexpectedly generated: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM1500")
|
||||
}
|
||||
|
||||
func simpleSource(component, text string) string {
|
||||
return "<?sando go\npackage demo\nfunc " + component + "()\n?>\n<p>" + text + "</p>\n"
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
resolved, err := filepath.EvalSymlinks(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func assertNoErrorDiagnostics(t *testing.T, diagnostics []Diagnostic) {
|
||||
t.Helper()
|
||||
if hasErrors(diagnostics) {
|
||||
t.Fatalf("unexpected diagnostics: %v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDiagnosticCode(t *testing.T, diagnostics []Diagnostic, code string) {
|
||||
t.Helper()
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Code == code {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("diagnostic %s not found in %v", code, diagnostics)
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type htmlState uint8
|
||||
|
||||
const (
|
||||
htmlData htmlState = iota
|
||||
htmlAfterLT
|
||||
htmlDeclarationStart
|
||||
htmlDeclarationDash
|
||||
htmlDeclaration
|
||||
htmlComment
|
||||
htmlTagName
|
||||
htmlBeforeAttribute
|
||||
htmlAttributeName
|
||||
htmlAfterAttributeName
|
||||
htmlBeforeAttributeValue
|
||||
htmlAttributeDoubleQuoted
|
||||
htmlAttributeSingleQuoted
|
||||
htmlSelfClosing
|
||||
htmlEndTagName
|
||||
htmlAfterEndTagName
|
||||
htmlRawText
|
||||
htmlRawAfterLT
|
||||
htmlRawEndTagName
|
||||
htmlRawAfterEndTagName
|
||||
)
|
||||
|
||||
type contextAnalyzer struct {
|
||||
file *sourceFile
|
||||
|
||||
state htmlState
|
||||
currentTag string
|
||||
currentAttr string
|
||||
stack []string
|
||||
selfClosing bool
|
||||
commentDash int
|
||||
rawTag string
|
||||
rawEndName string
|
||||
scriptTail string
|
||||
attributes map[string]string
|
||||
seenAttrs map[string]bool
|
||||
|
||||
attrLiteral strings.Builder
|
||||
attrDynamicNodes []int
|
||||
attrFirstDynamic int
|
||||
attrDynamicPrefix string
|
||||
}
|
||||
|
||||
var voidElements = map[string]bool{
|
||||
"area": true, "base": true, "br": true, "col": true, "embed": true,
|
||||
"hr": true, "img": true, "input": true, "link": true, "meta": true,
|
||||
"param": true, "source": true, "track": true, "wbr": true,
|
||||
}
|
||||
|
||||
var urlAttributes = map[string]bool{
|
||||
"action": true, "background": true, "cite": true, "classid": true,
|
||||
"code": true, "codebase": true, "data": true, "dynsrc": true,
|
||||
"formaction": true, "href": true, "icon": true, "itemid": true,
|
||||
"longdesc": true, "lowsrc": true, "manifest": true, "poster": true,
|
||||
"profile": true, "src": true, "usemap": true, "xlink:href": true,
|
||||
"xmlns": true,
|
||||
}
|
||||
|
||||
var unsupportedDynamicAttributes = map[string]string{
|
||||
"archive": "URL-list",
|
||||
"imagesrcset": "responsive-image URL-list",
|
||||
"itemtype": "URL-list",
|
||||
"ping": "URL-list",
|
||||
"srcdoc": "nested HTML",
|
||||
"srcset": "responsive-image URL-list",
|
||||
}
|
||||
|
||||
func analyzeContexts(file *sourceFile) []Diagnostic {
|
||||
analyzer := &contextAnalyzer{file: file, state: htmlData, attrFirstDynamic: -1}
|
||||
var diagnostics []Diagnostic
|
||||
for nodeIndex := range file.Nodes {
|
||||
node := &file.Nodes[nodeIndex]
|
||||
switch node.Kind {
|
||||
case nodeText:
|
||||
if d := analyzer.consumeText(node.Text, node.Pos); d != nil {
|
||||
diagnostics = append(diagnostics, *d)
|
||||
return diagnostics
|
||||
}
|
||||
case nodeComment:
|
||||
// Hime-san comments emit no bytes and cannot change HTML state.
|
||||
case nodeStatement:
|
||||
if analyzer.state != htmlData {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1301", "Go statements are only allowed at HTML content boundaries"))
|
||||
return diagnostics
|
||||
}
|
||||
node.Context = ContextNone
|
||||
case nodeComponent:
|
||||
if analyzer.state != htmlData {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1302", "component rendering (<?~) is only allowed at HTML content boundaries"))
|
||||
return diagnostics
|
||||
}
|
||||
node.Context = ContextHTMLText
|
||||
case nodeExpression:
|
||||
if analyzer.state == htmlBeforeAttributeValue {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1328", "attribute values must be quoted"))
|
||||
return diagnostics
|
||||
}
|
||||
if analyzer.inAttribute() && unsupportedDynamicAttributes[analyzer.currentAttr] != "" {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1345", fmt.Sprintf("dynamic %s attributes require an unsupported %s context in v1", analyzer.currentAttr, unsupportedDynamicAttributes[analyzer.currentAttr])))
|
||||
return diagnostics
|
||||
}
|
||||
if analyzer.inAttribute() && analyzer.currentTag == "meta" && analyzer.currentAttr == "http-equiv" {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1346", "dynamic meta http-equiv values are not supported in v1"))
|
||||
return diagnostics
|
||||
}
|
||||
if analyzer.inAttribute() && analyzer.currentAttr == "style" {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1343", "dynamic style attributes are not supported in v1; use a static style attribute or a class"))
|
||||
return diagnostics
|
||||
}
|
||||
context, ok := analyzer.dynamicContext()
|
||||
if !ok {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1303", "dynamic output is not allowed while constructing markup; use HTML text or a quoted attribute value"))
|
||||
return diagnostics
|
||||
}
|
||||
node.Context = context
|
||||
if analyzer.inAttribute() {
|
||||
if analyzer.attrFirstDynamic < 0 {
|
||||
analyzer.attrFirstDynamic = nodeIndex
|
||||
analyzer.attrDynamicPrefix = analyzer.attrLiteral.String()
|
||||
}
|
||||
analyzer.attrDynamicNodes = append(analyzer.attrDynamicNodes, nodeIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if analyzer.state != htmlData {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1310", "template ends in an incomplete or ambiguous HTML parser context"))
|
||||
}
|
||||
if len(analyzer.stack) != 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1311", fmt.Sprintf("component must finish in its starting HTML context; unclosed <%s>", analyzer.stack[len(analyzer.stack)-1])))
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) consumeText(text string, start sourcePosition) *Diagnostic {
|
||||
positionTable := newPositionTable(a.file.Source)
|
||||
for index := 0; index < len(text); index++ {
|
||||
b := text[index]
|
||||
position := positionTable.at(start.Offset + index)
|
||||
if a.rawTag == "script" {
|
||||
a.scriptTail += string(b)
|
||||
if len(a.scriptTail) > len("<!--") {
|
||||
a.scriptTail = a.scriptTail[len(a.scriptTail)-len("<!--"):]
|
||||
}
|
||||
if a.scriptTail == "<!--" {
|
||||
return a.problem(position, "HIM1357", "HTML comment syntax inside <script> is not supported because it enters ambiguous escaped script parser states; remove the <!-- sequence")
|
||||
}
|
||||
}
|
||||
reprocess := true
|
||||
for reprocess {
|
||||
reprocess = false
|
||||
switch a.state {
|
||||
case htmlData:
|
||||
if b == '<' {
|
||||
a.state = htmlAfterLT
|
||||
}
|
||||
case htmlAfterLT:
|
||||
switch {
|
||||
case b == '!':
|
||||
a.state = htmlDeclarationStart
|
||||
case b == '/':
|
||||
a.currentTag = ""
|
||||
a.state = htmlEndTagName
|
||||
case isTagNameStart(b):
|
||||
a.currentTag = strings.ToLower(string(b))
|
||||
a.attributes = make(map[string]string)
|
||||
a.seenAttrs = make(map[string]bool)
|
||||
a.selfClosing = false
|
||||
a.state = htmlTagName
|
||||
default:
|
||||
return a.problem(position, "HIM1320", "malformed HTML after '<'; dynamic tag construction is not supported")
|
||||
}
|
||||
case htmlDeclarationStart:
|
||||
if b == '-' {
|
||||
a.state = htmlDeclarationDash
|
||||
} else if isASCIILetter(b) {
|
||||
a.state = htmlDeclaration
|
||||
} else {
|
||||
return a.problem(position, "HIM1321", "unsupported HTML declaration")
|
||||
}
|
||||
case htmlDeclarationDash:
|
||||
if b != '-' {
|
||||
return a.problem(position, "HIM1322", "malformed HTML comment opening")
|
||||
}
|
||||
a.commentDash = 0
|
||||
a.state = htmlComment
|
||||
case htmlDeclaration:
|
||||
if b == '>' {
|
||||
a.state = htmlData
|
||||
} else if b == '<' {
|
||||
return a.problem(position, "HIM1323", "malformed HTML declaration")
|
||||
}
|
||||
case htmlComment:
|
||||
if b == '-' {
|
||||
a.commentDash++
|
||||
} else if b == '>' && a.commentDash >= 2 {
|
||||
a.commentDash = 0
|
||||
a.state = htmlData
|
||||
} else {
|
||||
a.commentDash = 0
|
||||
}
|
||||
case htmlTagName:
|
||||
switch {
|
||||
case isTagNameChar(b):
|
||||
a.currentTag += strings.ToLower(string(b))
|
||||
case isHTMLSpace(b):
|
||||
a.state = htmlBeforeAttribute
|
||||
case b == '>':
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case b == '/':
|
||||
a.selfClosing = true
|
||||
a.state = htmlSelfClosing
|
||||
default:
|
||||
return a.problem(position, "HIM1324", "unsupported character in HTML tag name")
|
||||
}
|
||||
case htmlBeforeAttribute:
|
||||
switch {
|
||||
case isHTMLSpace(b):
|
||||
case isAttributeNameStart(b):
|
||||
a.beginAttribute(b)
|
||||
a.state = htmlAttributeName
|
||||
case b == '>':
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case b == '/':
|
||||
a.selfClosing = true
|
||||
a.state = htmlSelfClosing
|
||||
default:
|
||||
return a.problem(position, "HIM1325", "dynamic or malformed attribute names are not supported")
|
||||
}
|
||||
case htmlAttributeName:
|
||||
switch {
|
||||
case isAttributeNameChar(b):
|
||||
a.currentAttr += strings.ToLower(string(b))
|
||||
case isHTMLSpace(b):
|
||||
if d := a.validateAttributeName(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.state = htmlAfterAttributeName
|
||||
case b == '=':
|
||||
if d := a.validateAttributeName(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.state = htmlBeforeAttributeValue
|
||||
case b == '>':
|
||||
if d := a.validateAttributeName(position); d != nil {
|
||||
return d
|
||||
}
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case b == '/':
|
||||
if d := a.validateAttributeName(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.selfClosing = true
|
||||
a.state = htmlSelfClosing
|
||||
default:
|
||||
return a.problem(position, "HIM1326", "unsupported character in attribute name")
|
||||
}
|
||||
case htmlAfterAttributeName:
|
||||
switch {
|
||||
case isHTMLSpace(b):
|
||||
case b == '=':
|
||||
a.state = htmlBeforeAttributeValue
|
||||
case isAttributeNameStart(b):
|
||||
a.beginAttribute(b)
|
||||
a.state = htmlAttributeName
|
||||
case b == '>':
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case b == '/':
|
||||
a.selfClosing = true
|
||||
a.state = htmlSelfClosing
|
||||
default:
|
||||
return a.problem(position, "HIM1327", "expected '=' or another attribute")
|
||||
}
|
||||
case htmlBeforeAttributeValue:
|
||||
switch {
|
||||
case isHTMLSpace(b):
|
||||
case b == '"':
|
||||
a.resetAttributeValue()
|
||||
a.state = htmlAttributeDoubleQuoted
|
||||
case b == '\'':
|
||||
a.resetAttributeValue()
|
||||
a.state = htmlAttributeSingleQuoted
|
||||
default:
|
||||
return a.problem(position, "HIM1328", "attribute values must be quoted")
|
||||
}
|
||||
case htmlAttributeDoubleQuoted:
|
||||
if b == '"' {
|
||||
if d := a.finishAttributeValue(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.state = htmlBeforeAttribute
|
||||
} else if b == '<' {
|
||||
return a.problem(position, "HIM1329", "'<' is not supported inside attribute values")
|
||||
} else {
|
||||
a.attrLiteral.WriteByte(b)
|
||||
}
|
||||
case htmlAttributeSingleQuoted:
|
||||
if b == '\'' {
|
||||
if d := a.finishAttributeValue(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.state = htmlBeforeAttribute
|
||||
} else if b == '<' {
|
||||
return a.problem(position, "HIM1329", "'<' is not supported inside attribute values")
|
||||
} else {
|
||||
a.attrLiteral.WriteByte(b)
|
||||
}
|
||||
case htmlSelfClosing:
|
||||
if isHTMLSpace(b) {
|
||||
continue
|
||||
}
|
||||
if b != '>' {
|
||||
return a.problem(position, "HIM1330", "expected '>' after '/' in a tag")
|
||||
}
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case htmlEndTagName:
|
||||
switch {
|
||||
case isTagNameChar(b):
|
||||
a.currentTag += strings.ToLower(string(b))
|
||||
case isHTMLSpace(b) && a.currentTag != "":
|
||||
a.state = htmlAfterEndTagName
|
||||
case b == '>' && a.currentTag != "":
|
||||
if d := a.finishCloseTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
default:
|
||||
return a.problem(position, "HIM1331", "malformed closing tag")
|
||||
}
|
||||
case htmlAfterEndTagName:
|
||||
if isHTMLSpace(b) {
|
||||
continue
|
||||
}
|
||||
if b != '>' {
|
||||
return a.problem(position, "HIM1332", "unexpected content in closing tag")
|
||||
}
|
||||
if d := a.finishCloseTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case htmlRawText:
|
||||
if b == '<' {
|
||||
a.state = htmlRawAfterLT
|
||||
}
|
||||
case htmlRawAfterLT:
|
||||
if b == '/' {
|
||||
a.rawEndName = ""
|
||||
a.state = htmlRawEndTagName
|
||||
} else if b != '<' {
|
||||
a.state = htmlRawText
|
||||
}
|
||||
case htmlRawEndTagName:
|
||||
if isTagNameChar(b) {
|
||||
a.rawEndName += strings.ToLower(string(b))
|
||||
continue
|
||||
}
|
||||
if a.rawEndName != a.rawTag {
|
||||
a.state = htmlRawText
|
||||
if b == '<' {
|
||||
a.state = htmlRawAfterLT
|
||||
}
|
||||
continue
|
||||
}
|
||||
if b == '>' {
|
||||
if d := a.finishRawClose(position); d != nil {
|
||||
return d
|
||||
}
|
||||
} else if isHTMLSpace(b) {
|
||||
a.state = htmlRawAfterEndTagName
|
||||
} else {
|
||||
a.state = htmlRawText
|
||||
}
|
||||
case htmlRawAfterEndTagName:
|
||||
if isHTMLSpace(b) {
|
||||
continue
|
||||
}
|
||||
if b != '>' {
|
||||
return a.problem(position, "HIM1333", "malformed script/style closing tag")
|
||||
}
|
||||
if d := a.finishRawClose(position); d != nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) beginAttribute(first byte) {
|
||||
a.currentAttr = strings.ToLower(string(first))
|
||||
a.resetAttributeValue()
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) resetAttributeValue() {
|
||||
a.attrLiteral.Reset()
|
||||
a.attrDynamicNodes = a.attrDynamicNodes[:0]
|
||||
a.attrFirstDynamic = -1
|
||||
a.attrDynamicPrefix = ""
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) validateAttributeName(position sourcePosition) *Diagnostic {
|
||||
if a.seenAttrs[a.currentAttr] {
|
||||
return a.problem(position, "HIM1347", fmt.Sprintf("duplicate attribute %q creates ambiguous browser parsing", a.currentAttr))
|
||||
}
|
||||
a.seenAttrs[a.currentAttr] = true
|
||||
if strings.HasPrefix(a.currentAttr, "on") {
|
||||
return a.problem(position, "HIM1340", fmt.Sprintf("event-handler attribute %q is not supported", a.currentAttr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) finishAttributeValue(position sourcePosition) *Diagnostic {
|
||||
if len(a.attrDynamicNodes) == 0 {
|
||||
a.attributes[a.currentAttr] = html.UnescapeString(a.attrLiteral.String())
|
||||
}
|
||||
if !urlAttributes[a.currentAttr] {
|
||||
return nil
|
||||
}
|
||||
if len(a.attrDynamicNodes) == 0 {
|
||||
if !safeStaticURL(a.attrLiteral.String()) {
|
||||
return a.problem(position, "HIM1344", "static URL uses a dangerous or ambiguous scheme; use a safe URL or an explicit full TrustURL value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
prefix := a.attrDynamicPrefix
|
||||
allLiteral := a.attrLiteral.String()
|
||||
suffix := strings.TrimPrefix(allLiteral, prefix)
|
||||
if prefix == "" && (len(a.attrDynamicNodes) != 1 || suffix != "") {
|
||||
return a.problem(position, "HIM1341", "a dynamic URL without a static safe prefix must occupy the entire quoted attribute")
|
||||
}
|
||||
if prefix != "" && !safeURLPrefix(prefix) {
|
||||
return a.problem(position, "HIM1342", "mixed static/dynamic URL attributes require a relative or explicit safe-scheme static prefix")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeStaticURL(value string) bool {
|
||||
decoded := strings.TrimSpace(html.UnescapeString(value))
|
||||
if decoded == "" {
|
||||
return true
|
||||
}
|
||||
for _, r := range decoded {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(decoded)
|
||||
if strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, "./") || strings.HasPrefix(lower, "../") || strings.HasPrefix(lower, "#") || strings.HasPrefix(lower, "?") {
|
||||
return true
|
||||
}
|
||||
colon := strings.IndexByte(lower, ':')
|
||||
boundary := len(lower)
|
||||
for _, separator := range []byte{'/', '?', '#'} {
|
||||
if index := strings.IndexByte(lower, separator); index >= 0 && index < boundary {
|
||||
boundary = index
|
||||
}
|
||||
}
|
||||
if colon < 0 || colon > boundary {
|
||||
return true
|
||||
}
|
||||
scheme := lower[:colon+1]
|
||||
return scheme == "http:" || scheme == "https:" || scheme == "mailto:" || scheme == "tel:"
|
||||
}
|
||||
|
||||
func safeURLPrefix(prefix string) bool {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(prefix))
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "./") || strings.HasPrefix(trimmed, "../") || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "?") {
|
||||
return true
|
||||
}
|
||||
for _, scheme := range []string{"http:", "https:", "mailto:", "tel:"} {
|
||||
if strings.HasPrefix(trimmed, scheme) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) finishOpenTag(position sourcePosition) *Diagnostic {
|
||||
if a.currentTag == "" {
|
||||
return a.problem(position, "HIM1350", "empty HTML tag name")
|
||||
}
|
||||
if a.currentTag == "svg" || a.currentTag == "math" {
|
||||
return a.problem(position, "HIM1355", fmt.Sprintf("foreign-content element <%s> is not supported by the v1 HTML context analyzer", a.currentTag))
|
||||
}
|
||||
if a.currentTag == "plaintext" || a.currentTag == "noscript" {
|
||||
return a.problem(position, "HIM1356", fmt.Sprintf("HTML element <%s> has environment-dependent or non-terminating parsing and is not supported in v1", a.currentTag))
|
||||
}
|
||||
if a.currentTag == "meta" && strings.EqualFold(strings.TrimSpace(a.attributes["http-equiv"]), "refresh") {
|
||||
return a.problem(position, "HIM1346", "meta refresh is an unsupported navigation context in v1")
|
||||
}
|
||||
if a.selfClosing && !voidElements[a.currentTag] {
|
||||
return a.problem(position, "HIM1354", fmt.Sprintf("self-closing syntax is not valid for non-void HTML element <%s>; use an explicit closing tag", a.currentTag))
|
||||
}
|
||||
if !a.selfClosing && !voidElements[a.currentTag] {
|
||||
a.stack = append(a.stack, a.currentTag)
|
||||
}
|
||||
if !a.selfClosing && (a.currentTag == "script" || a.currentTag == "style" || a.currentTag == "textarea" || a.currentTag == "title" || a.currentTag == "iframe" || a.currentTag == "noembed" || a.currentTag == "noframes" || a.currentTag == "xmp") {
|
||||
a.rawTag = a.currentTag
|
||||
a.scriptTail = ""
|
||||
a.state = htmlRawText
|
||||
} else {
|
||||
a.state = htmlData
|
||||
}
|
||||
a.currentAttr = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) finishCloseTag(position sourcePosition) *Diagnostic {
|
||||
if voidElements[a.currentTag] {
|
||||
return a.problem(position, "HIM1351", fmt.Sprintf("void element <%s> cannot have a closing tag", a.currentTag))
|
||||
}
|
||||
if len(a.stack) == 0 || a.stack[len(a.stack)-1] != a.currentTag {
|
||||
expected := "no closing tag"
|
||||
if len(a.stack) != 0 {
|
||||
expected = fmt.Sprintf("</%s>", a.stack[len(a.stack)-1])
|
||||
}
|
||||
return a.problem(position, "HIM1352", fmt.Sprintf("unbalanced closing tag </%s>; expected %s", a.currentTag, expected))
|
||||
}
|
||||
a.stack = a.stack[:len(a.stack)-1]
|
||||
a.state = htmlData
|
||||
a.currentTag = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) finishRawClose(position sourcePosition) *Diagnostic {
|
||||
if len(a.stack) == 0 || a.stack[len(a.stack)-1] != a.rawTag {
|
||||
return a.problem(position, "HIM1353", fmt.Sprintf("unbalanced </%s>", a.rawTag))
|
||||
}
|
||||
a.stack = a.stack[:len(a.stack)-1]
|
||||
a.state = htmlData
|
||||
a.currentTag = ""
|
||||
a.rawTag = ""
|
||||
a.rawEndName = ""
|
||||
a.scriptTail = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) dynamicContext() (Context, bool) {
|
||||
switch a.state {
|
||||
case htmlData:
|
||||
return ContextHTMLText, true
|
||||
case htmlAttributeDoubleQuoted, htmlAttributeSingleQuoted:
|
||||
if urlAttributes[a.currentAttr] {
|
||||
return ContextURL, true
|
||||
}
|
||||
return ContextAttr, true
|
||||
case htmlRawText:
|
||||
if a.rawTag == "script" {
|
||||
return ContextJS, true
|
||||
}
|
||||
if a.rawTag == "style" {
|
||||
return ContextCSS, true
|
||||
}
|
||||
if a.rawTag == "textarea" || a.rawTag == "title" {
|
||||
return ContextRCDATA, true
|
||||
}
|
||||
}
|
||||
return ContextNone, false
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) inAttribute() bool {
|
||||
return a.state == htmlAttributeDoubleQuoted || a.state == htmlAttributeSingleQuoted
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) problem(position sourcePosition, code, message string) *Diagnostic {
|
||||
d := diagnostic(a.file.Path, position, code, message)
|
||||
return &d
|
||||
}
|
||||
|
||||
func isASCIILetter(b byte) bool {
|
||||
return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
|
||||
}
|
||||
|
||||
func isTagNameStart(b byte) bool {
|
||||
return isASCIILetter(b)
|
||||
}
|
||||
|
||||
func isTagNameChar(b byte) bool {
|
||||
return isASCIILetter(b) || b >= '0' && b <= '9' || b == ':' || b == '-'
|
||||
}
|
||||
|
||||
func isAttributeNameStart(b byte) bool {
|
||||
return isASCIILetter(b) || b == '_' || b == ':'
|
||||
}
|
||||
|
||||
func isAttributeNameChar(b byte) bool {
|
||||
return isAttributeNameStart(b) || b >= '0' && b <= '9' || b == '-' || b == '.'
|
||||
}
|
||||
|
||||
func isHTMLSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\r' || b == '\n' || b == '\f'
|
||||
}
|
||||
|
||||
func endPosition(source []byte) sourcePosition {
|
||||
return newPositionTable(source).at(len(source))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Package compiler implements the Hime-san .sando compiler.
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Severity describes the impact of a diagnostic.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityError Severity = "error"
|
||||
SeverityWarning Severity = "warning"
|
||||
)
|
||||
|
||||
// Diagnostic is a stable, machine-readable compiler message. Line and Column
|
||||
// are one-based. A diagnostic without a source position uses line and column 1.
|
||||
type Diagnostic struct {
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line"`
|
||||
Column int `json:"column"`
|
||||
Code string `json:"code"`
|
||||
Severity Severity `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (d Diagnostic) Error() string {
|
||||
line, column := d.Line, d.Column
|
||||
if line < 1 {
|
||||
line = 1
|
||||
}
|
||||
if column < 1 {
|
||||
column = 1
|
||||
}
|
||||
return fmt.Sprintf("%s:%d:%d: %s: %s", d.Path, line, column, d.Code, d.Message)
|
||||
}
|
||||
|
||||
// DiagnosticsError reports one or more error diagnostics.
|
||||
type DiagnosticsError struct {
|
||||
Diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
func (e *DiagnosticsError) Error() string {
|
||||
if e == nil || len(e.Diagnostics) == 0 {
|
||||
return "himesan: compilation failed"
|
||||
}
|
||||
if len(e.Diagnostics) == 1 {
|
||||
return e.Diagnostics[0].Error()
|
||||
}
|
||||
return fmt.Sprintf("%s (and %d more diagnostics)", e.Diagnostics[0].Error(), len(e.Diagnostics)-1)
|
||||
}
|
||||
|
||||
func errorFromDiagnostics(ds []Diagnostic) error {
|
||||
if !hasErrors(ds) {
|
||||
return nil
|
||||
}
|
||||
copyOfDiagnostics := append([]Diagnostic(nil), ds...)
|
||||
sortDiagnostics(copyOfDiagnostics)
|
||||
return &DiagnosticsError{Diagnostics: copyOfDiagnostics}
|
||||
}
|
||||
|
||||
func hasErrors(ds []Diagnostic) bool {
|
||||
for _, d := range ds {
|
||||
if d.Severity == SeverityError || d.Severity == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sortDiagnostics(ds []Diagnostic) {
|
||||
sort.SliceStable(ds, func(i, j int) bool {
|
||||
a, b := ds[i], ds[j]
|
||||
if a.Path != b.Path {
|
||||
return a.Path < b.Path
|
||||
}
|
||||
if a.Line != b.Line {
|
||||
return a.Line < b.Line
|
||||
}
|
||||
if a.Column != b.Column {
|
||||
return a.Column < b.Column
|
||||
}
|
||||
if a.Code != b.Code {
|
||||
return a.Code < b.Code
|
||||
}
|
||||
return a.Message < b.Message
|
||||
})
|
||||
}
|
||||
|
||||
func diagnostic(path string, pos sourcePosition, code, message string) Diagnostic {
|
||||
return Diagnostic{
|
||||
Path: path,
|
||||
Line: pos.Line,
|
||||
Column: pos.Column,
|
||||
Code: code,
|
||||
Severity: SeverityError,
|
||||
Message: strings.TrimSpace(message),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var excludedDirectories = map[string]bool{
|
||||
".git": true,
|
||||
".hg": true,
|
||||
".svn": true,
|
||||
"vendor": true,
|
||||
}
|
||||
|
||||
func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
if len(paths) == 0 {
|
||||
paths = []string{"."}
|
||||
}
|
||||
filesByAbsolutePath := make(map[string]string)
|
||||
var diagnostics []Diagnostic
|
||||
|
||||
for _, requested := range paths {
|
||||
if err := ctx.Err(); err != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(requested, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
||||
break
|
||||
}
|
||||
clean := filepath.Clean(requested)
|
||||
if symlinkParent, symlinkErr := firstSymlinkComponent(clean); symlinkErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2002", "cannot inspect path ancestry: "+symlinkErr.Error()))
|
||||
continue
|
||||
} else if symlinkParent != "" {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2003", fmt.Sprintf("symlink paths are not followed (through %s)", symlinkParent)))
|
||||
continue
|
||||
}
|
||||
info, err := os.Lstat(clean)
|
||||
if err != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2002", "cannot inspect path: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2003", "symlink paths are not followed"))
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if filepath.Ext(clean) != ".sando" {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2004", "explicit source file must use the .sando extension"))
|
||||
continue
|
||||
}
|
||||
absolute, absoluteErr := filepath.Abs(clean)
|
||||
if absoluteErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2005", "cannot resolve source path: "+absoluteErr.Error()))
|
||||
continue
|
||||
}
|
||||
filesByAbsolutePath[absolute] = clean
|
||||
continue
|
||||
}
|
||||
|
||||
root := clean
|
||||
rootInfo := info
|
||||
walkErr := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if walkErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2006", "cannot inspect path during discovery: "+walkErr.Error()))
|
||||
if entry != nil && entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path != root && entry.IsDir() && excludedDirectories[entry.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path != root && entry.IsDir() {
|
||||
entryInfo, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2007", "cannot inspect directory: "+statErr.Error()))
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !sameFilesystem(rootInfo, entryInfo) {
|
||||
diagnostics = append(diagnostics, Diagnostic{Path: path, Line: 1, Column: 1, Code: "HIM2901", Severity: SeverityWarning, Message: "skipped mounted filesystem boundary"})
|
||||
return filepath.SkipDir
|
||||
}
|
||||
for _, marker := range []string{".git", ".hg", ".svn"} {
|
||||
markerPath := filepath.Join(path, marker)
|
||||
if _, markerErr := os.Lstat(markerPath); markerErr == nil {
|
||||
return filepath.SkipDir
|
||||
} else if !os.IsNotExist(markerErr) {
|
||||
diagnostics = append(diagnostics, diagnostic(markerPath, sourcePosition{Line: 1, Column: 1}, "HIM2011", "cannot inspect nested VCS boundary: "+markerErr.Error()))
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
modulePath := filepath.Join(path, "go.mod")
|
||||
if moduleInfo, moduleErr := os.Lstat(modulePath); moduleErr == nil {
|
||||
if !moduleInfo.Mode().IsRegular() {
|
||||
diagnostics = append(diagnostics, diagnostic(modulePath, sourcePosition{Line: 1, Column: 1}, "HIM2008", "nested go.mod boundary is not a regular file; directory was skipped"))
|
||||
}
|
||||
return filepath.SkipDir
|
||||
} else if !os.IsNotExist(moduleErr) {
|
||||
diagnostics = append(diagnostics, diagnostic(modulePath, sourcePosition{Line: 1, Column: 1}, "HIM2008", "cannot inspect nested module boundary: "+moduleErr.Error()))
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sando" {
|
||||
return nil
|
||||
}
|
||||
entryInfo, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2009", "cannot inspect source: "+statErr.Error()))
|
||||
return nil
|
||||
}
|
||||
if !entryInfo.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
absolute, absoluteErr := filepath.Abs(path)
|
||||
if absoluteErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2005", "cannot resolve source path: "+absoluteErr.Error()))
|
||||
return nil
|
||||
}
|
||||
filesByAbsolutePath[absolute] = path
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil && ctx.Err() != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(root, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+ctx.Err().Error()))
|
||||
}
|
||||
}
|
||||
|
||||
absolutePaths := make([]string, 0, len(filesByAbsolutePath))
|
||||
for absolute := range filesByAbsolutePath {
|
||||
absolutePaths = append(absolutePaths, absolute)
|
||||
}
|
||||
sort.Strings(absolutePaths)
|
||||
discovered := make([]string, 0, len(absolutePaths))
|
||||
for _, absolute := range absolutePaths {
|
||||
discovered = append(discovered, filesByAbsolutePath[absolute])
|
||||
}
|
||||
sort.SliceStable(discovered, func(i, j int) bool {
|
||||
left, _ := filepath.Abs(discovered[i])
|
||||
right, _ := filepath.Abs(discovered[j])
|
||||
return filepath.ToSlash(left) < filepath.ToSlash(right)
|
||||
})
|
||||
sortDiagnostics(diagnostics)
|
||||
return discovered, diagnostics
|
||||
}
|
||||
|
||||
func firstSymlinkComponent(path string) (string, error) {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
volume := filepath.VolumeName(absolute)
|
||||
remainder := strings.TrimPrefix(absolute, volume)
|
||||
remainder = strings.TrimPrefix(remainder, string(filepath.Separator))
|
||||
current := volume + string(filepath.Separator)
|
||||
for _, component := range strings.Split(remainder, string(filepath.Separator)) {
|
||||
if component == "" {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, component)
|
||||
info, lstatErr := os.Lstat(current)
|
||||
if lstatErr != nil {
|
||||
return "", lstatErr
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return current, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func sameFilesystem(root, candidate fs.FileInfo) bool {
|
||||
rootDevice, rootOK := deviceNumber(root.Sys())
|
||||
candidateDevice, candidateOK := deviceNumber(candidate.Sys())
|
||||
return !rootOK || !candidateOK || rootDevice == candidateDevice
|
||||
}
|
||||
|
||||
func deviceNumber(system any) (uint64, bool) {
|
||||
if system == nil {
|
||||
return 0, false
|
||||
}
|
||||
value := reflect.Indirect(reflect.ValueOf(system))
|
||||
if !value.IsValid() || value.Kind() != reflect.Struct {
|
||||
return 0, false
|
||||
}
|
||||
field := value.FieldByName("Dev")
|
||||
if !field.IsValid() {
|
||||
return 0, false
|
||||
}
|
||||
switch field.Kind() {
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return field.Uint(), true
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
device := field.Int()
|
||||
if device < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint64(device), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratedOutputCompilesInTemporaryModule(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping temporary-module compilation in short mode")
|
||||
}
|
||||
t.Parallel()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
|
||||
runtimeRoot := filepath.Join(repositoryRoot, "sando")
|
||||
directory := resolvedTempDir(t)
|
||||
goMod := "module example.test/generated\n\ngo 1.25\n\nrequire gamertan.com/sandwich-hime/sando v0.0.0\n\nreplace gamertan.com/sandwich-hime/sando => " + filepath.ToSlash(runtimeRoot) + "\n"
|
||||
mustWrite(t, filepath.Join(directory, "go.mod"), goMod)
|
||||
mustWrite(t, filepath.Join(directory, "view.go"), `package generated
|
||||
|
||||
import "gamertan.com/sandwich-hime/sando"
|
||||
|
||||
type View struct {
|
||||
Name string
|
||||
URL string
|
||||
JS sando.TrustedJS
|
||||
HTML sando.TrustedHTML
|
||||
}
|
||||
`)
|
||||
mustWrite(t, filepath.Join(directory, "render_test.go"), `package generated
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/sandwich-hime/sando"
|
||||
)
|
||||
|
||||
func TestRCDATACannotBeBypassedByTrustedHTML(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
view := View{Name: "title", URL: "/", JS: sando.TrustJS(""), HTML: sando.TrustHTML("</textarea><script>bad()</script>")}
|
||||
if err := sando.Render(context.Background(), &output, Page(view)); err != nil { t.Fatal(err) }
|
||||
if strings.Contains(output.String(), "</textarea><script>") { t.Fatalf("RCDATA boundary escaped: %s", output.String()) }
|
||||
}
|
||||
`)
|
||||
templatePath := filepath.Join(directory, "page.sando")
|
||||
mustWrite(t, templatePath, `<?sando go
|
||||
package generated
|
||||
func Page(view View)
|
||||
?>
|
||||
<!doctype html>
|
||||
<html><body>
|
||||
<a href="<?= view.URL ?>"><?= view.Name ?></a>
|
||||
<script><?= view.JS ?></script>
|
||||
<textarea><?= view.HTML ?></textarea>
|
||||
</body></html>`)
|
||||
result, err := Generate(context.Background(), []string{templatePath})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v (%v)", err, result.Diagnostics)
|
||||
}
|
||||
command := exec.Command("go", "test", "./...")
|
||||
command.Dir = directory
|
||||
command.Env = append(os.Environ(), "GOWORK=off")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("generated temporary module did not compile: %v\n%s\n--- generated ---\n%s", err, output, mustRead(t, templatePath+".go"))
|
||||
}
|
||||
if strings.Contains(string(mustRead(t, templatePath+".go")), repositoryRoot) {
|
||||
t.Fatal("generated output leaked the compiler checkout path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import "testing"
|
||||
|
||||
func FuzzCompileNeverPanics(f *testing.F) {
|
||||
for _, seed := range []string{
|
||||
profileSource,
|
||||
"",
|
||||
"<?sando go\npackage p\nfunc F()\n?>",
|
||||
"<?sando go\npackage p\nfunc F(v string)\n?>\n<a href=\"<?= v ?>\">x</a>",
|
||||
"<?sando go\npackage p\nfunc F()\n?>\n<script><?= `?>` ?></script>",
|
||||
"<?sando go\npackage p\nfunc F(v string)\n?>\n<script><!--<script></script>\n<?= v ?>\n<!--\n</script>\n-->",
|
||||
"\xef\xbb\xbf\r\n<?sando go\r\npackage p\r\nfunc F()\r\n?>\r\n<p>x</p>",
|
||||
} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, source string) {
|
||||
_, _ = Compile("fuzz.sando", []byte(source))
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzGoDelimiterNeverPanics(f *testing.F) {
|
||||
for _, seed := range []string{`?>`, `"?>" ?>`, "`?>` ?>", `/* ?> */ ?>`, "// ?>\n?>", `'?' ?>`} {
|
||||
f.Add(seed, uint8(0))
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, source string, start uint8) {
|
||||
offset := int(start)
|
||||
if offset > len(source) {
|
||||
offset = len(source)
|
||||
}
|
||||
result := findGoDelimiter([]byte(source), offset)
|
||||
if result < -1 || result > len(source) {
|
||||
t.Fatalf("invalid delimiter offset %d for %d bytes", result, len(source))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/version"
|
||||
)
|
||||
|
||||
// CompilerVersion mirrors the release-injected compiler version for callers
|
||||
// that need to display provenance alongside generated output.
|
||||
var CompilerVersion = version.Compiler
|
||||
|
||||
// RuntimeABI is the generated-code/runtime compatibility boundary for v1.
|
||||
const RuntimeABI = version.RuntimeABI
|
||||
|
||||
const (
|
||||
runtimeImportPath = "gamertan.com/sandwich-hime/sando"
|
||||
generatedPrefix = "// Code generated by himesan; DO NOT EDIT."
|
||||
)
|
||||
|
||||
// Context identifies the escaping context assigned to a renderer operation.
|
||||
type Context string
|
||||
|
||||
const (
|
||||
ContextHTMLText Context = "html-text"
|
||||
ContextRCDATA Context = "rcdata"
|
||||
ContextAttr Context = "quoted-attribute"
|
||||
ContextURL Context = "url-attribute"
|
||||
ContextJS Context = "script"
|
||||
ContextCSS Context = "style"
|
||||
ContextNone Context = "none"
|
||||
)
|
||||
|
||||
type nodeKind uint8
|
||||
|
||||
const (
|
||||
nodeText nodeKind = iota
|
||||
nodeStatement
|
||||
nodeExpression
|
||||
nodeComponent
|
||||
nodeComment
|
||||
)
|
||||
|
||||
// rendererNode is the context-annotated renderer IR. Context is assigned by
|
||||
// analyzeContexts before any backend is allowed to consume the IR.
|
||||
type rendererNode struct {
|
||||
Kind nodeKind
|
||||
Text string
|
||||
Context Context
|
||||
Pos sourcePosition
|
||||
}
|
||||
|
||||
type sourcePosition struct {
|
||||
Offset int
|
||||
Line int
|
||||
Column int
|
||||
}
|
||||
|
||||
type sourceFile struct {
|
||||
Path string
|
||||
Mapping string
|
||||
Package string
|
||||
Name string
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
Nodes []rendererNode
|
||||
Source []byte
|
||||
HeaderEnd int
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
type sourceImport struct {
|
||||
Alias string
|
||||
Path string
|
||||
}
|
||||
|
||||
// CompiledFile is a fully validated generated output held in memory.
|
||||
type CompiledFile struct {
|
||||
SourcePath string
|
||||
OutputPath string
|
||||
Package string
|
||||
Component string
|
||||
Digest string
|
||||
Code []byte
|
||||
source *sourceFile
|
||||
}
|
||||
|
||||
// FileResult describes one source/output pair processed by Generate or Check.
|
||||
type FileResult struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
OutputPath string `json:"output_path"`
|
||||
Changed bool `json:"changed"`
|
||||
Stale bool `json:"stale"`
|
||||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
// Result is returned even when an operation reports diagnostics.
|
||||
type Result struct {
|
||||
Files []FileResult `json:"files"`
|
||||
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
|
||||
Discovered int `json:"discovered"`
|
||||
Changed int `json:"changed"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
Stale int `json:"stale"`
|
||||
Missing int `json:"missing"`
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Generate compiles all discovered .sando files in memory, then atomically
|
||||
// replaces only changed, Hime-san-owned .sando.go outputs. Any parse, context,
|
||||
// format, cycle, or ownership error prevents every output write.
|
||||
func Generate(ctx context.Context, paths []string) (Result, error) {
|
||||
compiled, result := compileOperation(ctx, paths)
|
||||
if hasErrors(result.Diagnostics) {
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
|
||||
// Validate every destination before performing the first mutation.
|
||||
for _, file := range compiled {
|
||||
info, err := os.Lstat(file.OutputPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2101", "cannot inspect generated output: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2102", "refusing to replace a non-regular or symlink output"))
|
||||
continue
|
||||
}
|
||||
existing, readErr := os.ReadFile(file.OutputPath)
|
||||
if readErr != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2103", "cannot read generated output: "+readErr.Error()))
|
||||
continue
|
||||
}
|
||||
if !bytes.HasPrefix(existing, []byte(generatedPrefix+"\n")) {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2104", "refusing to overwrite a file not owned by Hime-san"))
|
||||
}
|
||||
}
|
||||
sortDiagnostics(result.Diagnostics)
|
||||
if hasErrors(result.Diagnostics) {
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
|
||||
for index, file := range compiled {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
||||
break
|
||||
}
|
||||
existing, readErr := os.ReadFile(file.OutputPath)
|
||||
if readErr == nil && bytesEqual(existing, file.Code) {
|
||||
result.Files[index].Changed = false
|
||||
result.Unchanged++
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if info, statErr := os.Stat(file.OutputPath); statErr == nil {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
if writeErr := atomicWrite(file.OutputPath, file.Code, mode); writeErr != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2110", "atomic output replacement failed: "+writeErr.Error()))
|
||||
break
|
||||
}
|
||||
result.Files[index].Changed = true
|
||||
result.Changed++
|
||||
}
|
||||
sortDiagnostics(result.Diagnostics)
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
|
||||
// Check validates sources and reports missing or stale generated output without
|
||||
// writing to the filesystem. Warnings, including trusted-value audit findings,
|
||||
// do not cause Check to fail by themselves.
|
||||
func Check(ctx context.Context, paths []string) (Result, error) {
|
||||
compiled, result := compileOperation(ctx, paths)
|
||||
if hasErrors(result.Diagnostics) {
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
for index, file := range compiled {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
||||
break
|
||||
}
|
||||
info, lstatErr := os.Lstat(file.OutputPath)
|
||||
if lstatErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2205", "generated output is a symlink or non-regular file"))
|
||||
continue
|
||||
}
|
||||
if lstatErr != nil && !os.IsNotExist(lstatErr) {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2202", "cannot inspect generated output: "+lstatErr.Error()))
|
||||
continue
|
||||
}
|
||||
existing, err := os.ReadFile(file.OutputPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
result.Files[index].Missing = true
|
||||
result.Missing++
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2201", "generated output is missing; run himesan generate"))
|
||||
} else {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2202", "cannot read generated output: "+err.Error()))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if bytesEqual(existing, file.Code) {
|
||||
result.Unchanged++
|
||||
continue
|
||||
}
|
||||
result.Files[index].Stale = true
|
||||
result.Stale++
|
||||
if !bytes.HasPrefix(existing, []byte(generatedPrefix+"\n")) {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2203", "expected output exists but is not owned by Hime-san"))
|
||||
} else {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2204", "generated output is stale; run himesan generate"))
|
||||
}
|
||||
}
|
||||
sortDiagnostics(result.Diagnostics)
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
|
||||
func compileOperation(ctx context.Context, paths []string) ([]CompiledFile, Result) {
|
||||
discovered, discoveryDiagnostics := discover(ctx, paths)
|
||||
result := Result{Discovered: len(discovered), Diagnostics: discoveryDiagnostics}
|
||||
compiled := make([]CompiledFile, 0, len(discovered))
|
||||
for _, sourcePath := range discovered {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
||||
break
|
||||
}
|
||||
info, lstatErr := os.Lstat(sourcePath)
|
||||
if lstatErr != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2010", "cannot inspect source before compilation: "+lstatErr.Error()))
|
||||
continue
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2012", "source changed into a symlink or non-regular file during discovery"))
|
||||
continue
|
||||
}
|
||||
source, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2010", "cannot read source: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
output, diagnostics := compileWithMapping(sourcePath, source, moduleRelativeSourcePath(sourcePath))
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostics...)
|
||||
if output.Code != nil {
|
||||
compiled = append(compiled, output)
|
||||
result.Files = append(result.Files, FileResult{SourcePath: output.SourcePath, OutputPath: output.OutputPath})
|
||||
}
|
||||
}
|
||||
result.Diagnostics = append(result.Diagnostics, detectComponentCycles(compiled)...)
|
||||
sort.SliceStable(compiled, func(i, j int) bool { return compiled[i].SourcePath < compiled[j].SourcePath })
|
||||
sort.SliceStable(result.Files, func(i, j int) bool { return result.Files[i].SourcePath < result.Files[j].SourcePath })
|
||||
sortDiagnostics(result.Diagnostics)
|
||||
return compiled, result
|
||||
}
|
||||
|
||||
func moduleRelativeSourcePath(sourcePath string) string {
|
||||
absolute, err := filepath.Abs(sourcePath)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(filepath.Base(sourcePath))
|
||||
}
|
||||
directory := filepath.Dir(absolute)
|
||||
for {
|
||||
modulePath := filepath.Join(directory, "go.mod")
|
||||
if info, statErr := os.Lstat(modulePath); statErr == nil && info.Mode().IsRegular() {
|
||||
if relative, relativeErr := filepath.Rel(directory, absolute); relativeErr == nil {
|
||||
return filepath.ToSlash(relative)
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory {
|
||||
break
|
||||
}
|
||||
directory = parent
|
||||
}
|
||||
return filepath.ToSlash(filepath.Base(sourcePath))
|
||||
}
|
||||
|
||||
func atomicWrite(path string, content []byte, mode os.FileMode) (returnErr error) {
|
||||
directory := filepath.Dir(path)
|
||||
temporary, err := os.CreateTemp(directory, ".himesan-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
closed := false
|
||||
defer func() {
|
||||
var closeErr error
|
||||
if !closed {
|
||||
closeErr = temporary.Close()
|
||||
}
|
||||
removeErr := os.Remove(temporaryPath)
|
||||
if returnErr == nil && closeErr != nil {
|
||||
returnErr = closeErr
|
||||
}
|
||||
if returnErr == nil && removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
returnErr = removeErr
|
||||
}
|
||||
}()
|
||||
if _, err := temporary.Write(content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temporary.Chmod(mode.Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
closed = true
|
||||
if err := replaceFile(temporaryPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
if directoryHandle, err := os.Open(directory); err == nil {
|
||||
_ = directoryHandle.Sync()
|
||||
_ = directoryHandle.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type componentKey struct {
|
||||
directory string
|
||||
packageID string
|
||||
name string
|
||||
}
|
||||
|
||||
type componentEdge struct {
|
||||
target componentKey
|
||||
position sourcePosition
|
||||
}
|
||||
|
||||
func detectComponentCycles(files []CompiledFile) []Diagnostic {
|
||||
byKey := make(map[componentKey]CompiledFile, len(files))
|
||||
var diagnostics []Diagnostic
|
||||
for _, file := range files {
|
||||
key := componentKey{directory: filepath.Clean(filepath.Dir(file.SourcePath)), packageID: file.Package, name: file.Component}
|
||||
if previous, exists := byKey[key]; exists {
|
||||
diagnostics = append(diagnostics,
|
||||
diagnostic(previous.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM1500", fmt.Sprintf("component %s is also declared by %s", file.Component, file.SourcePath)),
|
||||
diagnostic(file.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM1500", fmt.Sprintf("component %s is also declared by %s", file.Component, previous.SourcePath)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
byKey[key] = file
|
||||
}
|
||||
edges := make(map[componentKey][]componentEdge)
|
||||
for key, file := range byKey {
|
||||
if file.source == nil {
|
||||
continue
|
||||
}
|
||||
for _, node := range file.source.Nodes {
|
||||
if node.Kind != nodeComponent {
|
||||
continue
|
||||
}
|
||||
expression, err := parser.ParseExpr(node.Text)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
calledName := rootCalledIdentifier(expression)
|
||||
if calledName == "" {
|
||||
continue
|
||||
}
|
||||
target := componentKey{directory: key.directory, packageID: key.packageID, name: calledName}
|
||||
if _, exists := byKey[target]; exists {
|
||||
edges[key] = append(edges[key], componentEdge{target: target, position: node.Pos})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(edges[key], func(i, j int) bool { return edges[key][i].target.name < edges[key][j].target.name })
|
||||
}
|
||||
|
||||
const (
|
||||
unvisited = iota
|
||||
visiting
|
||||
visited
|
||||
)
|
||||
state := make(map[componentKey]int)
|
||||
stack := make([]componentKey, 0)
|
||||
reported := make(map[componentKey]bool)
|
||||
var visit func(componentKey)
|
||||
visit = func(key componentKey) {
|
||||
state[key] = visiting
|
||||
stack = append(stack, key)
|
||||
for _, edge := range edges[key] {
|
||||
target := edge.target
|
||||
if state[target] == unvisited {
|
||||
visit(target)
|
||||
continue
|
||||
}
|
||||
if state[target] != visiting {
|
||||
continue
|
||||
}
|
||||
cycleStart := 0
|
||||
for cycleStart < len(stack) && stack[cycleStart] != target {
|
||||
cycleStart++
|
||||
}
|
||||
cycle := append(append([]componentKey(nil), stack[cycleStart:]...), target)
|
||||
names := make([]string, 0, len(cycle))
|
||||
for _, member := range cycle {
|
||||
names = append(names, member.name)
|
||||
}
|
||||
for memberIndex, member := range cycle[:len(cycle)-1] {
|
||||
if reported[member] {
|
||||
continue
|
||||
}
|
||||
reported[member] = true
|
||||
file := byKey[member]
|
||||
position := sourcePosition{Line: 1, Column: 1}
|
||||
next := cycle[memberIndex+1]
|
||||
for _, memberEdge := range edges[member] {
|
||||
if memberEdge.target == next {
|
||||
position = memberEdge.position
|
||||
break
|
||||
}
|
||||
}
|
||||
diagnostics = append(diagnostics, diagnostic(file.SourcePath, position, "HIM1501", "static component cycle detected: "+fmt.Sprint(names)))
|
||||
}
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
state[key] = visited
|
||||
}
|
||||
keys := make([]componentKey, 0, len(byKey))
|
||||
for key := range byKey {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
if keys[i].directory != keys[j].directory {
|
||||
return keys[i].directory < keys[j].directory
|
||||
}
|
||||
if keys[i].packageID != keys[j].packageID {
|
||||
return keys[i].packageID < keys[j].packageID
|
||||
}
|
||||
return keys[i].name < keys[j].name
|
||||
})
|
||||
for _, key := range keys {
|
||||
if state[key] == unvisited {
|
||||
visit(key)
|
||||
}
|
||||
}
|
||||
sortDiagnostics(diagnostics)
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func rootCalledIdentifier(expression ast.Expr) string {
|
||||
for {
|
||||
switch typed := expression.(type) {
|
||||
case *ast.ParenExpr:
|
||||
expression = typed.X
|
||||
case *ast.CallExpr:
|
||||
expression = typed.Fun
|
||||
case *ast.IndexExpr:
|
||||
expression = typed.X
|
||||
case *ast.IndexListExpr:
|
||||
expression = typed.X
|
||||
case *ast.Ident:
|
||||
return typed.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/scanner"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const headerOpen = "<?sando"
|
||||
|
||||
type positionTable struct {
|
||||
lineStarts []int
|
||||
size int
|
||||
}
|
||||
|
||||
func newPositionTable(source []byte) positionTable {
|
||||
starts := []int{0}
|
||||
for i, b := range source {
|
||||
if b == '\n' {
|
||||
starts = append(starts, i+1)
|
||||
}
|
||||
}
|
||||
return positionTable{lineStarts: starts, size: len(source)}
|
||||
}
|
||||
|
||||
func (t positionTable) at(offset int) sourcePosition {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > t.size {
|
||||
offset = t.size
|
||||
}
|
||||
lineIndex := sort.Search(len(t.lineStarts), func(i int) bool {
|
||||
return t.lineStarts[i] > offset
|
||||
}) - 1
|
||||
if lineIndex < 0 {
|
||||
lineIndex = 0
|
||||
}
|
||||
return sourcePosition{
|
||||
Offset: offset,
|
||||
Line: lineIndex + 1,
|
||||
Column: offset - t.lineStarts[lineIndex] + 1,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSource(path string, source []byte) (*sourceFile, []Diagnostic) {
|
||||
table := newPositionTable(source)
|
||||
var diagnostics []Diagnostic
|
||||
|
||||
if !utf8.Valid(source) {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(0), "HIM1001", "source is not valid UTF-8"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
if offset := bytes.IndexByte(source, 0); offset >= 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(offset), "HIM1002", "NUL bytes are not permitted in .sando sources"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
headerStart := 0
|
||||
if bytes.HasPrefix(source, []byte{0xef, 0xbb, 0xbf}) {
|
||||
headerStart = 3
|
||||
}
|
||||
for headerStart < len(source) && isSpace(source[headerStart]) {
|
||||
headerStart++
|
||||
}
|
||||
if !bytes.HasPrefix(source[headerStart:], []byte(headerOpen)) {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(headerStart), "HIM1101", "file must begin (after optional UTF-8 BOM and whitespace) with a <?sando go header"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
afterMarker := headerStart + len(headerOpen)
|
||||
if afterMarker >= len(source) || !isSpace(source[afterMarker]) {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(afterMarker), "HIM1105", "whitespace is required between <?sando and the target name"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
headerClose := findGoDelimiter(source, afterMarker)
|
||||
if headerClose < 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(headerStart), "HIM1102", "unterminated <?sando header"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
directiveBody := source[afterMarker:headerClose]
|
||||
directiveStart := afterMarker
|
||||
leading := len(directiveBody) - len(bytes.TrimLeft(directiveBody, " \t\r\n"))
|
||||
directiveBody = directiveBody[leading:]
|
||||
directiveStart += leading
|
||||
|
||||
if len(directiveBody) < len("go") || string(directiveBody[:2]) != "go" || (len(directiveBody) > 2 && !isSpace(directiveBody[2])) {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(directiveStart), "HIM1103", "unsupported or missing header target; v1 requires <?sando go"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
declarations := directiveBody[2:]
|
||||
declarationsStart := directiveStart + 2
|
||||
declLeading := len(declarations) - len(bytes.TrimLeft(declarations, " \t\r\n"))
|
||||
declarations = declarations[declLeading:]
|
||||
declarationsStart += declLeading
|
||||
if len(declarations) == 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(declarationsStart), "HIM1104", "header must declare a package and one component function"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
parsedHeader, headerDiagnostics := parseHeader(path, declarations, declarationsStart, table)
|
||||
diagnostics = append(diagnostics, headerDiagnostics...)
|
||||
if parsedHeader == nil {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
file := &sourceFile{
|
||||
Path: path,
|
||||
Mapping: filepath.ToSlash(filepath.Base(path)),
|
||||
Package: parsedHeader.Package,
|
||||
Name: parsedHeader.Name,
|
||||
TypeParams: parsedHeader.TypeParams,
|
||||
Params: parsedHeader.Params,
|
||||
Imports: parsedHeader.Imports,
|
||||
Source: source,
|
||||
HeaderEnd: headerClose + 2,
|
||||
AST: parsedHeader.AST,
|
||||
}
|
||||
|
||||
templateDiagnostics := tokenizeTemplate(file, source[headerClose+2:], headerClose+2, table)
|
||||
diagnostics = append(diagnostics, templateDiagnostics...)
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, diagnostics
|
||||
}
|
||||
return file, diagnostics
|
||||
}
|
||||
|
||||
func isSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\r' || b == '\n'
|
||||
}
|
||||
|
||||
type parsedHeader struct {
|
||||
Package string
|
||||
Name string
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
func parseHeader(path string, declarations []byte, sourceOffset int, table positionTable) (*parsedHeader, []Diagnostic) {
|
||||
parseInput, syntheticBraceOffset := insertSyntheticFunctionBody(declarations)
|
||||
fset := token.NewFileSet()
|
||||
parsed, err := parser.ParseFile(fset, filepath.Base(path), parseInput, parser.AllErrors)
|
||||
if err != nil {
|
||||
return nil, parserDiagnostics(path, err, sourceOffset, table, "HIM1110", "invalid Go header")
|
||||
}
|
||||
|
||||
var function *ast.FuncDecl
|
||||
var imports []sourceImport
|
||||
var diagnostics []Diagnostic
|
||||
for _, declaration := range parsed.Decls {
|
||||
switch declaration := declaration.(type) {
|
||||
case *ast.GenDecl:
|
||||
if declaration.Tok != token.IMPORT {
|
||||
pos := fset.Position(declaration.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1111", "header may contain only imports and one component function signature"))
|
||||
continue
|
||||
}
|
||||
for _, spec := range declaration.Specs {
|
||||
importSpec, ok := spec.(*ast.ImportSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
importPath, unquoteErr := strconv.Unquote(importSpec.Path.Value)
|
||||
if unquoteErr != nil || importPath == "" {
|
||||
pos := fset.Position(importSpec.Path.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1112", "invalid import path"))
|
||||
continue
|
||||
}
|
||||
alias := ""
|
||||
if importSpec.Name != nil {
|
||||
alias = importSpec.Name.Name
|
||||
}
|
||||
if alias == "." {
|
||||
pos := fset.Position(importSpec.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1113", "dot imports are not supported in .sando headers"))
|
||||
continue
|
||||
}
|
||||
imports = append(imports, sourceImport{Alias: alias, Path: importPath})
|
||||
}
|
||||
case *ast.FuncDecl:
|
||||
if function != nil {
|
||||
pos := fset.Position(declaration.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1114", "a .sando file declares exactly one component"))
|
||||
continue
|
||||
}
|
||||
function = declaration
|
||||
default:
|
||||
pos := fset.Position(declaration.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1111", "unsupported declaration in header"))
|
||||
}
|
||||
}
|
||||
|
||||
if function == nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset), "HIM1115", "header must contain one bodyless component function signature"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
functionPosition := fset.Position(function.Pos())
|
||||
if function.Name.Name == "init" || parsed.Name.Name == "main" && function.Name.Name == "main" {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1123", fmt.Sprintf("%s.%s is reserved by Go and cannot be a component API", parsed.Name.Name, function.Name.Name)))
|
||||
}
|
||||
if function.Recv != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1116", "component functions cannot have receivers"))
|
||||
}
|
||||
if function.Type.Results != nil && len(function.Type.Results.List) != 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1117", "component signatures do not declare results; Hime-san generates sando.Component"))
|
||||
}
|
||||
if function.Body == nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1118", "component signature could not be parsed"))
|
||||
} else {
|
||||
bodyOffset := fset.Position(function.Body.Lbrace).Offset
|
||||
if bodyOffset != syntheticBraceOffset {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+bodyOffset), "HIM1119", "component signature must be bodyless; ?> begins the template body"))
|
||||
}
|
||||
}
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
formattedType, formatErr := formatNode(fset, function.Type)
|
||||
if formatErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1120", "could not format component parameters: "+formatErr.Error()))
|
||||
return nil, diagnostics
|
||||
}
|
||||
typeParams, params, splitErr := splitFormattedFuncType(formattedType)
|
||||
if splitErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1121", "could not recover formatted component signature: "+splitErr.Error()))
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
sort.SliceStable(imports, func(i, j int) bool {
|
||||
if imports[i].Path != imports[j].Path {
|
||||
return imports[i].Path < imports[j].Path
|
||||
}
|
||||
return imports[i].Alias < imports[j].Alias
|
||||
})
|
||||
for index := 1; index < len(imports); index++ {
|
||||
if imports[index-1].Path == imports[index].Path {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset), "HIM1122", fmt.Sprintf("import path %q is declared more than once", imports[index].Path)))
|
||||
}
|
||||
}
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
return &parsedHeader{
|
||||
Package: parsed.Name.Name,
|
||||
Name: function.Name.Name,
|
||||
TypeParams: typeParams,
|
||||
Params: params,
|
||||
Imports: imports,
|
||||
AST: parsed,
|
||||
}, diagnostics
|
||||
}
|
||||
|
||||
func insertSyntheticFunctionBody(declarations []byte) ([]byte, int) {
|
||||
trimmed := bytes.TrimRight(declarations, " \t\r\n")
|
||||
fileSet := token.NewFileSet()
|
||||
file := fileSet.AddFile("header.go", -1, len(trimmed))
|
||||
var lexical scanner.Scanner
|
||||
lexical.Init(file, trimmed, nil, scanner.ScanComments)
|
||||
seenFunction := false
|
||||
squareDepth := 0
|
||||
curlyDepth := 0
|
||||
parameterDepth := 0
|
||||
parameterListStarted := false
|
||||
insertion := -1
|
||||
for {
|
||||
position, tok, _ := lexical.Scan()
|
||||
if tok == token.EOF {
|
||||
break
|
||||
}
|
||||
if !seenFunction {
|
||||
if tok == token.FUNC {
|
||||
seenFunction = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if parameterListStarted {
|
||||
switch tok {
|
||||
case token.LPAREN:
|
||||
parameterDepth++
|
||||
case token.RPAREN:
|
||||
parameterDepth--
|
||||
if parameterDepth == 0 {
|
||||
insertion = fileSet.Position(position).Offset + 1
|
||||
}
|
||||
}
|
||||
if insertion >= 0 {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch tok {
|
||||
case token.LBRACK:
|
||||
squareDepth++
|
||||
case token.RBRACK:
|
||||
if squareDepth > 0 {
|
||||
squareDepth--
|
||||
}
|
||||
case token.LBRACE:
|
||||
curlyDepth++
|
||||
case token.RBRACE:
|
||||
if curlyDepth > 0 {
|
||||
curlyDepth--
|
||||
}
|
||||
case token.LPAREN:
|
||||
if squareDepth == 0 && curlyDepth == 0 {
|
||||
parameterListStarted = true
|
||||
parameterDepth = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if insertion < 0 || insertion > len(trimmed) {
|
||||
insertion = len(trimmed)
|
||||
}
|
||||
parseInput := make([]byte, 0, len(trimmed)+3)
|
||||
parseInput = append(parseInput, trimmed[:insertion]...)
|
||||
parseInput = append(parseInput, ' ', '{', '}')
|
||||
parseInput = append(parseInput, trimmed[insertion:]...)
|
||||
return parseInput, insertion + 1
|
||||
}
|
||||
|
||||
func splitFormattedFuncType(formatted string) (typeParams, params string, err error) {
|
||||
remainder := strings.TrimSpace(strings.TrimPrefix(formatted, "func"))
|
||||
bracketDepth := 0
|
||||
quote := byte(0)
|
||||
escaped := false
|
||||
for index := 0; index < len(remainder); index++ {
|
||||
b := remainder[index]
|
||||
if quote != 0 {
|
||||
if quote != '`' && escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if quote != '`' && b == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if b == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if b == '"' || b == '\'' || b == '`' {
|
||||
quote = b
|
||||
continue
|
||||
}
|
||||
switch b {
|
||||
case '[':
|
||||
bracketDepth++
|
||||
case ']':
|
||||
if bracketDepth > 0 {
|
||||
bracketDepth--
|
||||
}
|
||||
case '(':
|
||||
if bracketDepth == 0 {
|
||||
return strings.TrimSpace(remainder[:index]), strings.TrimSpace(remainder[index:]), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("formatted function type has no parameter list")
|
||||
}
|
||||
|
||||
func formatNode(fset *token.FileSet, node any) (string, error) {
|
||||
var output bytes.Buffer
|
||||
configuration := printer.Config{Mode: printer.UseSpaces | printer.TabIndent, Tabwidth: 8}
|
||||
if err := configuration.Fprint(&output, fset, node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func parserDiagnostics(path string, err error, sourceOffset int, table positionTable, code, prefix string) []Diagnostic {
|
||||
var diagnostics []Diagnostic
|
||||
if list, ok := err.(scanner.ErrorList); ok {
|
||||
for _, parseError := range list {
|
||||
offset := sourceOffset + parseError.Pos.Offset
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(offset), code, prefix+": "+parseError.Msg))
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
return []Diagnostic{diagnostic(path, table.at(sourceOffset), code, prefix+": "+err.Error())}
|
||||
}
|
||||
|
||||
func tokenizeTemplate(file *sourceFile, template []byte, sourceOffset int, table positionTable) []Diagnostic {
|
||||
var diagnostics []Diagnostic
|
||||
cursor := 0
|
||||
for cursor < len(template) {
|
||||
openRelative := bytes.Index(template[cursor:], []byte("<?"))
|
||||
if openRelative < 0 {
|
||||
if cursor < len(template) {
|
||||
file.Nodes = append(file.Nodes, rendererNode{Kind: nodeText, Text: string(template[cursor:]), Pos: table.at(sourceOffset + cursor)})
|
||||
}
|
||||
break
|
||||
}
|
||||
open := cursor + openRelative
|
||||
if open > cursor {
|
||||
file.Nodes = append(file.Nodes, rendererNode{Kind: nodeText, Text: string(template[cursor:open]), Pos: table.at(sourceOffset + cursor)})
|
||||
}
|
||||
close := -1
|
||||
if open+2 < len(template) && template[open+2] == '#' {
|
||||
if closeRelative := bytes.Index(template[open+2:], []byte("?>")); closeRelative >= 0 {
|
||||
close = open + 2 + closeRelative
|
||||
}
|
||||
} else {
|
||||
close = findGoDelimiter(template, open+2)
|
||||
}
|
||||
if close < 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, table.at(sourceOffset+open), "HIM1201", "unterminated template tag"))
|
||||
break
|
||||
}
|
||||
kind := nodeStatement
|
||||
contentStart := open + 2
|
||||
if contentStart < close {
|
||||
switch template[contentStart] {
|
||||
case '=':
|
||||
kind = nodeExpression
|
||||
contentStart++
|
||||
case '~':
|
||||
kind = nodeComponent
|
||||
contentStart++
|
||||
case '#':
|
||||
kind = nodeComment
|
||||
contentStart++
|
||||
}
|
||||
}
|
||||
content := template[contentStart:close]
|
||||
trimmed := bytes.TrimSpace(content)
|
||||
trimLeading := len(content) - len(bytes.TrimLeft(content, " \t\r\n"))
|
||||
position := table.at(sourceOffset + contentStart + trimLeading)
|
||||
if bytes.HasPrefix(bytes.TrimSpace(template[open+2:close]), []byte("sando")) {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, table.at(sourceOffset+open), "HIM1202", "<?sando is only valid as the file header"))
|
||||
} else if kind != nodeComment && len(trimmed) == 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, table.at(sourceOffset+open), "HIM1203", "empty template tag"))
|
||||
} else {
|
||||
node := rendererNode{Kind: kind, Text: string(trimmed), Context: ContextNone, Pos: position}
|
||||
if kind == nodeExpression || kind == nodeComponent {
|
||||
if _, err := parser.ParseExprFrom(token.NewFileSet(), filepath.Base(file.Path), trimmed, parser.AllErrors); err != nil {
|
||||
diagnostics = append(diagnostics, expressionDiagnostics(file.Path, err, position, table, sourceOffset+contentStart+trimLeading)...)
|
||||
} else {
|
||||
file.Nodes = append(file.Nodes, node)
|
||||
}
|
||||
} else {
|
||||
file.Nodes = append(file.Nodes, node)
|
||||
}
|
||||
}
|
||||
cursor = close + 2
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
// findGoDelimiter returns the first ?> outside Go strings, rune literals, raw
|
||||
// strings, and comments. Statement tags may be structurally incomplete across
|
||||
// template regions, so requiring each region to parse independently would
|
||||
// reject ordinary `if { ?>...<? }` usage.
|
||||
func findGoDelimiter(source []byte, start int) int {
|
||||
type lexicalState uint8
|
||||
const (
|
||||
lexicalNormal lexicalState = iota
|
||||
lexicalString
|
||||
lexicalRune
|
||||
lexicalRawString
|
||||
lexicalLineComment
|
||||
lexicalBlockComment
|
||||
)
|
||||
state := lexicalNormal
|
||||
escaped := false
|
||||
for index := start; index < len(source); index++ {
|
||||
b := source[index]
|
||||
next := byte(0)
|
||||
if index+1 < len(source) {
|
||||
next = source[index+1]
|
||||
}
|
||||
switch state {
|
||||
case lexicalNormal:
|
||||
switch {
|
||||
case b == '?' && next == '>':
|
||||
return index
|
||||
case b == '"':
|
||||
state = lexicalString
|
||||
escaped = false
|
||||
case b == '\'':
|
||||
state = lexicalRune
|
||||
escaped = false
|
||||
case b == '`':
|
||||
state = lexicalRawString
|
||||
case b == '/' && next == '/':
|
||||
state = lexicalLineComment
|
||||
index++
|
||||
case b == '/' && next == '*':
|
||||
state = lexicalBlockComment
|
||||
index++
|
||||
}
|
||||
case lexicalString, lexicalRune:
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if b == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (state == lexicalString && b == '"') || (state == lexicalRune && b == '\'') {
|
||||
state = lexicalNormal
|
||||
}
|
||||
case lexicalRawString:
|
||||
if b == '`' {
|
||||
state = lexicalNormal
|
||||
}
|
||||
case lexicalLineComment:
|
||||
if b == '\n' {
|
||||
state = lexicalNormal
|
||||
}
|
||||
case lexicalBlockComment:
|
||||
if b == '*' && next == '/' {
|
||||
state = lexicalNormal
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func expressionDiagnostics(path string, err error, fallback sourcePosition, table positionTable, sourceOffset int) []Diagnostic {
|
||||
if list, ok := err.(scanner.ErrorList); ok {
|
||||
diagnostics := make([]Diagnostic, 0, len(list))
|
||||
for _, parseError := range list {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+parseError.Pos.Offset), "HIM1210", "invalid Go expression: "+parseError.Msg))
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
return []Diagnostic{diagnostic(path, fallback, "HIM1210", "invalid Go expression: "+strings.TrimSpace(err.Error()))}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build !windows
|
||||
|
||||
package compiler
|
||||
|
||||
import "os"
|
||||
|
||||
func replaceFile(replacement, destination string) error {
|
||||
return os.Rename(replacement, destination)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build windows
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var replaceFileW = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW")
|
||||
|
||||
// replaceFile uses ReplaceFileW when a destination exists because os.Rename is
|
||||
// not an atomic replacement primitive on Windows. A new destination can use
|
||||
// os.Rename: there is no last-good file whose visibility must be preserved.
|
||||
func replaceFile(replacement, destination string) error {
|
||||
if _, err := os.Lstat(destination); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return os.Rename(replacement, destination)
|
||||
}
|
||||
return err
|
||||
}
|
||||
destinationUTF16, err := syscall.UTF16PtrFromString(destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replacementUTF16, err := syscall.UTF16PtrFromString(replacement)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, _, callErr := replaceFileW.Call(
|
||||
uintptr(unsafe.Pointer(destinationUTF16)),
|
||||
uintptr(unsafe.Pointer(replacementUTF16)),
|
||||
0,
|
||||
1, // REPLACEFILE_WRITE_THROUGH
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if result == 0 {
|
||||
return callErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Package devserver implements Hime-san's local-only development supervisor.
|
||||
// It is intentionally independent from the template compiler and production
|
||||
// runtime.
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// ConfigVersion is the himesan.json schema version understood by this
|
||||
// package.
|
||||
ConfigVersion = 1
|
||||
|
||||
defaultListenAddressEnv = "HIMESAN_LISTEN_ADDR"
|
||||
defaultHealthPath = "/"
|
||||
defaultProxyAddress = "127.0.0.1:7331"
|
||||
)
|
||||
|
||||
var environmentNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
// Config is the versioned, non-secret himesan.json development configuration.
|
||||
// Arguments are passed directly to the application; they are never interpreted
|
||||
// by a shell.
|
||||
type Config struct {
|
||||
Version int `json:"version"`
|
||||
SourceRoots []string `json:"sourceRoots"`
|
||||
GoPackage string `json:"goPackage"`
|
||||
AppArgs []string `json:"appArgs,omitempty"`
|
||||
ListenAddressEnv string `json:"listenAddressEnv"`
|
||||
HealthPath string `json:"healthPath"`
|
||||
ProxyAddress string `json:"proxyAddress"`
|
||||
AdditionalWatchRoots []string `json:"additionalWatchRoots,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns safe defaults for a simple, single-module project.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Version: ConfigVersion,
|
||||
SourceRoots: []string{"."},
|
||||
GoPackage: ".",
|
||||
ListenAddressEnv: defaultListenAddressEnv,
|
||||
HealthPath: defaultHealthPath,
|
||||
ProxyAddress: defaultProxyAddress,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfig reads a himesan.json file, applies defaults for omitted optional
|
||||
// fields, rejects unknown fields, and validates the result. Paths remain
|
||||
// relative to the project root supplied later through Options.RootDir.
|
||||
func LoadConfig(path string) (Config, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("open development config: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
// Unlike optional fields, the schema version must be written explicitly so
|
||||
// future defaults cannot silently reinterpret an old file.
|
||||
cfg.Version = 0
|
||||
decoder := json.NewDecoder(f)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("decode development config: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return Config{}, errors.New("decode development config: multiple JSON values")
|
||||
}
|
||||
return Config{}, fmt.Errorf("decode development config: %w", err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, fmt.Errorf("validate development config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks the schema and all values that do not require filesystem
|
||||
// access. In particular, the stable proxy is restricted to loopback.
|
||||
func (c Config) Validate() error {
|
||||
if c.Version != ConfigVersion {
|
||||
return fmt.Errorf("unsupported config version %d (want %d)", c.Version, ConfigVersion)
|
||||
}
|
||||
if len(c.SourceRoots) == 0 {
|
||||
return errors.New("sourceRoots must contain at least one path")
|
||||
}
|
||||
for _, root := range append(append([]string(nil), c.SourceRoots...), c.AdditionalWatchRoots...) {
|
||||
if err := validatePathValue(root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(c.GoPackage) == "" {
|
||||
return errors.New("goPackage must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(c.GoPackage, "\x00\r\n") {
|
||||
return errors.New("goPackage contains a control character")
|
||||
}
|
||||
for _, arg := range c.AppArgs {
|
||||
if strings.ContainsRune(arg, '\x00') {
|
||||
return errors.New("appArgs contains a NUL byte")
|
||||
}
|
||||
}
|
||||
if !environmentNamePattern.MatchString(c.ListenAddressEnv) {
|
||||
return fmt.Errorf("listenAddressEnv %q is not a valid environment variable name", c.ListenAddressEnv)
|
||||
}
|
||||
if !strings.HasPrefix(c.HealthPath, "/") || strings.HasPrefix(c.HealthPath, "//") {
|
||||
return errors.New("healthPath must be an absolute URL path")
|
||||
}
|
||||
if strings.ContainsAny(c.HealthPath, "\x00\r\n?#") {
|
||||
return errors.New("healthPath must not contain controls, a query, or a fragment")
|
||||
}
|
||||
if err := ValidateLoopbackAddress(c.ProxyAddress); err != nil {
|
||||
return fmt.Errorf("proxyAddress: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePathValue(path string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return errors.New("watch paths must not be empty")
|
||||
}
|
||||
if strings.ContainsRune(path, '\x00') {
|
||||
return errors.New("watch path contains a NUL byte")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateLoopbackAddress rejects wildcard, public, malformed, and
|
||||
// hostname-based proxy bindings. Requiring a literal loopback IP prevents a
|
||||
// hosts-file or DNS change from broadening the development server's exposure.
|
||||
func ValidateLoopbackAddress(address string) error {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be host:port: %w", err)
|
||||
}
|
||||
portNumber, err := strconv.Atoi(port)
|
||||
if err != nil || portNumber < 0 || portNumber > 65535 {
|
||||
return fmt.Errorf("port %q is not numeric or is outside 0-65535", port)
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return fmt.Errorf("host %q is not a loopback IP", host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveProjectPath(rootDir, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
return filepath.Join(rootDir, filepath.Clean(path))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigDefaultsAndRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "himesan.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"proxyAddress":"[::1]:0"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.GoPackage != "." || cfg.ListenAddressEnv != defaultListenAddressEnv || cfg.HealthPath != "/" {
|
||||
t.Fatalf("LoadConfig() did not apply defaults: %#v", cfg)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"mystery":true}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadConfig(path); err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("LoadConfig() unknown field error = %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(`{"proxyAddress":"127.0.0.1:0"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadConfig(path); err == nil || !strings.Contains(err.Error(), "version") {
|
||||
t.Fatalf("LoadConfig() missing version error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
}{
|
||||
{"public proxy", func(c *Config) { c.ProxyAddress = "0.0.0.0:7331" }},
|
||||
{"hostname proxy", func(c *Config) { c.ProxyAddress = "localhost:7331" }},
|
||||
{"bad port", func(c *Config) { c.ProxyAddress = "127.0.0.1:http" }},
|
||||
{"bad environment", func(c *Config) { c.ListenAddressEnv = "bad-name" }},
|
||||
{"health query", func(c *Config) { c.HealthPath = "/health?full=1" }},
|
||||
{"empty source roots", func(c *Config) { c.SourceRoots = nil }},
|
||||
{"nul argument", func(c *Config) { c.AppArgs = []string{"a\x00b"} }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
test.mutate(&cfg)
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, address := range []string{"127.0.0.1:0", "127.12.3.4:65535", "[::1]:7331"} {
|
||||
if err := ValidateLoopbackAddress(address); err != nil {
|
||||
t.Errorf("ValidateLoopbackAddress(%q) = %v", address, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const eventsPath = "/__himesan/events"
|
||||
|
||||
// Diagnostic is a compiler/build diagnostic suitable for the development
|
||||
// browser overlay. The CLI may map its compiler's native diagnostics through
|
||||
// Options.MapDiagnostics without coupling this package to the compiler.
|
||||
type Diagnostic struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Column int `json:"column,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
// Event is delivered both to Options.OnEvent and to connected browser clients.
|
||||
// Type is currently one of "ready", "reload", or "diagnostic".
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type eventHub struct {
|
||||
mu sync.Mutex
|
||||
subscribers map[chan Event]struct{}
|
||||
latest *Event
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newEventHub() *eventHub {
|
||||
return &eventHub{subscribers: make(map[chan Event]struct{})}
|
||||
}
|
||||
|
||||
func (h *eventHub) publish(event Event) {
|
||||
if event.At.IsZero() {
|
||||
event.At = time.Now().UTC()
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.closed {
|
||||
return
|
||||
}
|
||||
if event.Type == "diagnostic" {
|
||||
copy := event
|
||||
h.latest = ©
|
||||
} else if event.Type == "reload" {
|
||||
h.latest = nil
|
||||
}
|
||||
for subscriber := range h.subscribers {
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
// Reload and diagnostic events are snapshots, not a log. Replace the
|
||||
// oldest queued snapshot so a slow browser still receives the newest
|
||||
// state transition.
|
||||
select {
|
||||
case <-subscriber:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHub) subscribe() (<-chan Event, func()) {
|
||||
updates := make(chan Event, 8)
|
||||
h.mu.Lock()
|
||||
if h.closed {
|
||||
close(updates)
|
||||
h.mu.Unlock()
|
||||
return updates, func() {}
|
||||
}
|
||||
h.subscribers[updates] = struct{}{}
|
||||
if h.latest != nil {
|
||||
updates <- *h.latest
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
return updates, func() {
|
||||
once.Do(func() {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.subscribers[updates]; ok {
|
||||
delete(h.subscribers, updates)
|
||||
close(updates)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHub) close() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.closed {
|
||||
return
|
||||
}
|
||||
h.closed = true
|
||||
for subscriber := range h.subscribers {
|
||||
close(subscriber)
|
||||
delete(h.subscribers, subscriber)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHub) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming is unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := writeSSE(w, Event{Type: "ready", At: time.Now().UTC()}); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
updates, unsubscribe := h.subscribe()
|
||||
defer unsubscribe()
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case event, ok := <-updates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := writeSSE(w, event); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case <-heartbeat.C:
|
||||
if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, event Event) error {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "event: %s\n", event.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type candidateProcess struct {
|
||||
command *exec.Cmd
|
||||
address string
|
||||
binaryPath string
|
||||
processTree uintptr
|
||||
exited chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
waitErr error
|
||||
}
|
||||
|
||||
func taskkillArguments(pid int, force bool) []string {
|
||||
arguments := []string{"/PID", strconv.Itoa(pid), "/T"}
|
||||
if force {
|
||||
arguments = append(arguments, "/F")
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
|
||||
func startManagedProcess(command *exec.Cmd, address, binaryPath string) (*candidateProcess, error) {
|
||||
configureProcess(command)
|
||||
if err := command.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processTree, err := attachProcessTree(command)
|
||||
if err != nil {
|
||||
// Never return an unmanaged child. In particular, a Windows candidate
|
||||
// must be attached to its Job Object before it can be considered usable.
|
||||
_ = killProcess(command, 0)
|
||||
_ = command.Wait()
|
||||
return nil, errors.New("attach managed process tree: " + err.Error())
|
||||
}
|
||||
candidate := &candidateProcess{
|
||||
command: command,
|
||||
address: address,
|
||||
binaryPath: binaryPath,
|
||||
processTree: processTree,
|
||||
exited: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
err := command.Wait()
|
||||
candidate.mu.Lock()
|
||||
candidate.waitErr = err
|
||||
candidate.mu.Unlock()
|
||||
close(candidate.exited)
|
||||
}()
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func (c *candidateProcess) result() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.waitErr
|
||||
}
|
||||
|
||||
func (c *candidateProcess) hasExited() bool {
|
||||
select {
|
||||
case <-c.exited:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *candidateProcess) cleanupProcessTree() error {
|
||||
c.mu.Lock()
|
||||
processTree := c.processTree
|
||||
c.processTree = 0
|
||||
c.mu.Unlock()
|
||||
return cleanupProcess(c.command, processTree)
|
||||
}
|
||||
|
||||
func (c *candidateProcess) stop(ctx context.Context) error {
|
||||
defer func() {
|
||||
if c.binaryPath != "" {
|
||||
_ = os.Remove(c.binaryPath)
|
||||
}
|
||||
}()
|
||||
if c.hasExited() {
|
||||
return errors.Join(acceptableStopError(c.result()), c.cleanupProcessTree())
|
||||
}
|
||||
if err := terminateProcess(c.command, c.processTree); err != nil {
|
||||
// A graceful signal is best effort. Failure to deliver it immediately
|
||||
// escalates to the platform's process-tree termination primitive.
|
||||
_ = killProcess(c.command, c.processTree)
|
||||
}
|
||||
select {
|
||||
case <-c.exited:
|
||||
return errors.Join(acceptableStopError(c.result()), c.cleanupProcessTree())
|
||||
case <-ctx.Done():
|
||||
killErr := killProcess(c.command, c.processTree)
|
||||
select {
|
||||
case <-c.exited:
|
||||
return errors.Join(ctx.Err(), killErr, acceptableStopError(c.result()), c.cleanupProcessTree())
|
||||
case <-time.After(2 * time.Second):
|
||||
// Closing a Windows Job Object configured with
|
||||
// KILL_ON_JOB_CLOSE is the final bounded fallback. On Unix this
|
||||
// repeats the process-group kill without retaining resources.
|
||||
return errors.Join(ctx.Err(), killErr, c.cleanupProcessTree())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func acceptableStopError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagedProcessStopsAndWaits(t *testing.T) {
|
||||
command := exec.Command(os.Args[0], "-test.run=TestManagedProcessHelper$")
|
||||
command.Env = append(os.Environ(), "HIMESAN_PROCESS_HELPER=1")
|
||||
candidate, err := startManagedProcess(command, "127.0.0.1:1", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := candidate.stop(ctx); err != nil {
|
||||
t.Fatalf("stop() error = %v", err)
|
||||
}
|
||||
if !candidate.hasExited() {
|
||||
t.Fatal("candidate process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessStopsDescendantTree(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("helper-process integration test")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
gatePath := filepath.Join(directory, "start-child")
|
||||
readyPath := filepath.Join(directory, "child-address")
|
||||
command := exec.Command(os.Args[0], "-test.run=TestManagedProcessHelper$")
|
||||
command.Env = append(os.Environ(),
|
||||
"HIMESAN_PROCESS_HELPER=tree-parent",
|
||||
"HIMESAN_PROCESS_GATE="+gatePath,
|
||||
"HIMESAN_PROCESS_READY="+readyPath,
|
||||
)
|
||||
candidate, err := startManagedProcess(command, "127.0.0.1:1", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(gatePath, []byte("start"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := waitForChildAddress(t, readyPath)
|
||||
waitForChildListener(t, address)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := candidate.stop(ctx); err != nil {
|
||||
t.Fatalf("stop() error = %v", err)
|
||||
}
|
||||
if !candidate.hasExited() {
|
||||
t.Fatal("candidate root process was not reaped")
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
connection, dialErr := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||
if dialErr != nil {
|
||||
break
|
||||
}
|
||||
_ = connection.Close()
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("managed descendant still accepts connections at %s", address)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskkillArguments(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got, want := taskkillArguments(42, false), []string{"/PID", "42", "/T"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("taskkillArguments(graceful) = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := taskkillArguments(42, true), []string{"/PID", "42", "/T", "/F"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("taskkillArguments(force) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessHelper(t *testing.T) {
|
||||
switch os.Getenv("HIMESAN_PROCESS_HELPER") {
|
||||
case "":
|
||||
return
|
||||
case "tree-parent":
|
||||
runTreeParentHelper()
|
||||
case "tree-child":
|
||||
runTreeChildHelper()
|
||||
}
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals)
|
||||
<-signals
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func runTreeParentHelper() {
|
||||
gatePath := os.Getenv("HIMESAN_PROCESS_GATE")
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(gatePath); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
os.Exit(2)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
child := exec.Command(os.Args[0], "-test.run=TestManagedProcessHelper$")
|
||||
child.Env = replaceEnvironment(os.Environ(), "HIMESAN_PROCESS_HELPER", "tree-child")
|
||||
if err := child.Start(); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func runTreeChildHelper() {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
defer listener.Close()
|
||||
if err := os.WriteFile(os.Getenv("HIMESAN_PROCESS_READY"), []byte(listener.Addr().String()), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
for {
|
||||
connection, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
_ = connection.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func waitForChildAddress(t *testing.T, readyPath string) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
contents, err := os.ReadFile(readyPath)
|
||||
if err == nil && strings.TrimSpace(string(contents)) != "" {
|
||||
return strings.TrimSpace(string(contents))
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("managed descendant did not report its address: %v", err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForChildListener(t *testing.T, address string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var lastErr error
|
||||
for {
|
||||
connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = connection.Close()
|
||||
return
|
||||
}
|
||||
lastErr = err
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("managed descendant did not accept a connection at %s: %v", address, lastErr)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build !windows
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureProcess(command *exec.Cmd) {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
func attachProcessTree(_ *exec.Cmd) (uintptr, error) { return 0, nil }
|
||||
|
||||
func terminateProcess(command *exec.Cmd, _ uintptr) error {
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := syscall.Kill(-command.Process.Pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return command.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func killProcess(command *exec.Cmd, _ uintptr) error {
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return command.Process.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupProcess(command *exec.Cmd, processTree uintptr) error {
|
||||
return killProcess(command, processTree)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build windows
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
processSetQuota = 0x0100
|
||||
jobObjectExtendedLimitInformation = 9
|
||||
jobObjectLimitKillOnJobClose = 0x00002000
|
||||
)
|
||||
|
||||
type ioCounters struct {
|
||||
ReadOperationCount uint64
|
||||
WriteOperationCount uint64
|
||||
OtherOperationCount uint64
|
||||
ReadTransferCount uint64
|
||||
WriteTransferCount uint64
|
||||
OtherTransferCount uint64
|
||||
}
|
||||
|
||||
type jobObjectExtendedLimitInfo struct {
|
||||
BasicLimitInformation jobObjectBasicLimitInfo
|
||||
IOInfo ioCounters
|
||||
ProcessMemoryLimit uintptr
|
||||
JobMemoryLimit uintptr
|
||||
PeakProcessMemoryUsed uintptr
|
||||
PeakJobMemoryUsed uintptr
|
||||
}
|
||||
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
assignProcessToJobObject = kernel32.NewProc("AssignProcessToJobObject")
|
||||
createJobObjectW = kernel32.NewProc("CreateJobObjectW")
|
||||
generateConsoleCtrlEvent = kernel32.NewProc("GenerateConsoleCtrlEvent")
|
||||
setInformationJobObject = kernel32.NewProc("SetInformationJobObject")
|
||||
terminateJobObject = kernel32.NewProc("TerminateJobObject")
|
||||
)
|
||||
|
||||
func configureProcess(command *exec.Cmd) {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
|
||||
// attachProcessTree places the candidate in a Windows Job Object. Job
|
||||
// membership is inherited by descendants, so they remain terminable even when
|
||||
// the root process exits before cleanup reaches it.
|
||||
func attachProcessTree(command *exec.Cmd) (uintptr, error) {
|
||||
if command.Process == nil {
|
||||
return 0, errors.New("candidate process is unavailable")
|
||||
}
|
||||
job, _, createErr := createJobObjectW.Call(0, 0)
|
||||
if job == 0 {
|
||||
return 0, fmt.Errorf("CreateJobObjectW: %w", createErr)
|
||||
}
|
||||
limits := jobObjectExtendedLimitInfo{}
|
||||
limits.BasicLimitInformation.LimitFlags = jobObjectLimitKillOnJobClose
|
||||
configured, _, configureErr := setInformationJobObject.Call(
|
||||
job,
|
||||
jobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&limits)),
|
||||
unsafe.Sizeof(limits),
|
||||
)
|
||||
if configured == 0 {
|
||||
_ = syscall.CloseHandle(syscall.Handle(job))
|
||||
return 0, fmt.Errorf("SetInformationJobObject: %w", configureErr)
|
||||
}
|
||||
process, err := syscall.OpenProcess(processSetQuota|syscall.PROCESS_TERMINATE, false, uint32(command.Process.Pid))
|
||||
if err != nil {
|
||||
_ = syscall.CloseHandle(syscall.Handle(job))
|
||||
return 0, fmt.Errorf("open candidate for Job Object assignment: %w", err)
|
||||
}
|
||||
defer syscall.CloseHandle(process)
|
||||
assigned, _, assignErr := assignProcessToJobObject.Call(job, uintptr(process))
|
||||
if assigned == 0 {
|
||||
_ = syscall.CloseHandle(syscall.Handle(job))
|
||||
return 0, fmt.Errorf("AssignProcessToJobObject: %w", assignErr)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func terminateProcess(command *exec.Cmd, _ uintptr) error {
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
}
|
||||
result, _, callErr := generateConsoleCtrlEvent.Call(syscall.CTRL_BREAK_EVENT, uintptr(command.Process.Pid))
|
||||
if result == 0 {
|
||||
return fmt.Errorf("GenerateConsoleCtrlEvent: %w", callErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func killProcess(command *exec.Cmd, processTree uintptr) error {
|
||||
if processTree != 0 {
|
||||
result, _, callErr := terminateJobObject.Call(processTree, 1)
|
||||
if result != 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("TerminateJobObject: %w", callErr)
|
||||
}
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := runTaskkill(command.Process.Pid, true); err != nil {
|
||||
return errors.Join(err, command.Process.Kill())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupProcess(command *exec.Cmd, processTree uintptr) error {
|
||||
if processTree == 0 {
|
||||
// A successfully returned Windows candidate always owns a Job Object.
|
||||
// Zero therefore means cleanup already ran; do not target a potentially
|
||||
// recycled process ID.
|
||||
return nil
|
||||
}
|
||||
terminateErr := killProcess(command, processTree)
|
||||
closeErr := syscall.CloseHandle(syscall.Handle(processTree))
|
||||
return errors.Join(terminateErr, closeErr)
|
||||
}
|
||||
|
||||
func runTaskkill(pid int, force bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, "taskkill", taskkillArguments(pid, force)...).Run()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build windows && (386 || arm)
|
||||
|
||||
package devserver
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// jobObjectBasicLimitInfo mirrors JOBOBJECT_BASIC_LIMIT_INFORMATION. Windows
|
||||
// 32-bit ABIs pad this structure to an eight-byte boundary.
|
||||
type jobObjectBasicLimitInfo struct {
|
||||
PerProcessUserTimeLimit int64
|
||||
PerJobUserTimeLimit int64
|
||||
LimitFlags uint32
|
||||
MinimumWorkingSetSize uintptr
|
||||
MaximumWorkingSetSize uintptr
|
||||
ActiveProcessLimit uint32
|
||||
Affinity uintptr
|
||||
PriorityClass uint32
|
||||
SchedulingClass uint32
|
||||
_ uint32
|
||||
}
|
||||
|
||||
var (
|
||||
_ [48 - unsafe.Sizeof(jobObjectBasicLimitInfo{})]byte
|
||||
_ [unsafe.Sizeof(jobObjectBasicLimitInfo{}) - 48]byte
|
||||
_ [112 - unsafe.Sizeof(jobObjectExtendedLimitInfo{})]byte
|
||||
_ [unsafe.Sizeof(jobObjectExtendedLimitInfo{}) - 112]byte
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build windows && (amd64 || arm64)
|
||||
|
||||
package devserver
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// jobObjectBasicLimitInfo mirrors JOBOBJECT_BASIC_LIMIT_INFORMATION on the
|
||||
// supported 64-bit Windows architectures.
|
||||
type jobObjectBasicLimitInfo struct {
|
||||
PerProcessUserTimeLimit int64
|
||||
PerJobUserTimeLimit int64
|
||||
LimitFlags uint32
|
||||
MinimumWorkingSetSize uintptr
|
||||
MaximumWorkingSetSize uintptr
|
||||
ActiveProcessLimit uint32
|
||||
Affinity uintptr
|
||||
PriorityClass uint32
|
||||
SchedulingClass uint32
|
||||
}
|
||||
|
||||
var (
|
||||
_ [64 - unsafe.Sizeof(jobObjectBasicLimitInfo{})]byte
|
||||
_ [unsafe.Sizeof(jobObjectBasicLimitInfo{}) - 64]byte
|
||||
_ [144 - unsafe.Sizeof(jobObjectExtendedLimitInfo{})]byte
|
||||
_ [unsafe.Sizeof(jobObjectExtendedLimitInfo{}) - 144]byte
|
||||
)
|
||||
@@ -0,0 +1,410 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const (
|
||||
maxInjectableHTML = 16 << 20
|
||||
reloadClient = `(function(){var id="__himesan_overlay";function show(e){var d=document.getElementById(id);if(!d){d=document.createElement("dialog");d.id=id;var b=document.createElement("button");b.textContent="Close";b.addEventListener("click",function(){d.close()});var p=document.createElement("pre");d.appendChild(b);d.appendChild(p);document.body.appendChild(d)}var p=d.querySelector("pre"),xs=e.diagnostics||[];p.textContent=(e.phase?e.phase+": ":"")+(e.message||"Hime-san could not reload")+(xs.length?"\n\n"+xs.map(function(x){return (x.path||"")+(x.line?":"+x.line+(x.column?":"+x.column:""):"")+(x.code?" ["+x.code+"]":"")+" "+x.message}).join("\n"):"");if(!d.open)d.showModal()}var s=new EventSource("/__himesan/events");s.addEventListener("reload",function(){location.reload()});s.addEventListener("diagnostic",function(e){try{show(JSON.parse(e.data))}catch(_){show({message:e.data})}})})();`
|
||||
)
|
||||
|
||||
var (
|
||||
reloadClientTag = []byte("<script data-himesan-reload>" + reloadClient + "</script>")
|
||||
reloadClientHash = makeReloadClientHash()
|
||||
)
|
||||
|
||||
func makeReloadClientHash() string {
|
||||
digest := sha256.Sum256([]byte(reloadClient))
|
||||
return "'sha256-" + base64.StdEncoding.EncodeToString(digest[:]) + "'"
|
||||
}
|
||||
|
||||
type developmentProxy struct {
|
||||
target atomic.Pointer[url.URL]
|
||||
authority atomic.Pointer[localProxyAuthority]
|
||||
hub *eventHub
|
||||
proxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
type localProxyAuthority struct {
|
||||
port int
|
||||
}
|
||||
|
||||
func newDevelopmentProxy(hub *eventHub) *developmentProxy {
|
||||
d := &developmentProxy{hub: hub}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
d.proxy = &httputil.ReverseProxy{
|
||||
Transport: transport,
|
||||
Rewrite: func(request *httputil.ProxyRequest) {
|
||||
target := d.target.Load()
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
request.SetURL(target)
|
||||
request.SetXForwarded()
|
||||
request.Out.Header.Set("Accept-Encoding", "identity")
|
||||
request.Out.Header.Del("If-Modified-Since")
|
||||
request.Out.Header.Del("If-None-Match")
|
||||
request.Out.Header.Set("Cache-Control", "no-cache")
|
||||
},
|
||||
ModifyResponse: injectDevelopmentClient,
|
||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
http.Error(w, "Hime-san development upstream is unavailable: "+err.Error(), http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *developmentProxy) closeIdleConnections() {
|
||||
if transport, ok := d.proxy.Transport.(interface{ CloseIdleConnections() }); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *developmentProxy) setAuthority(address string) error {
|
||||
if err := ValidateLoopbackAddress(address); err != nil {
|
||||
return fmt.Errorf("development proxy authority %q: %w", address, err)
|
||||
}
|
||||
_, rawPort, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("split development proxy authority: %w", err)
|
||||
}
|
||||
port, err := strconv.Atoi(rawPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse development proxy port: %w", err)
|
||||
}
|
||||
d.authority.Store(&localProxyAuthority{port: port})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *developmentProxy) setTarget(address string) error {
|
||||
if err := ValidateLoopbackAddress(address); err != nil {
|
||||
return fmt.Errorf("development upstream %q: %w", address, err)
|
||||
}
|
||||
target, err := url.Parse("http://" + address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse development upstream: %w", err)
|
||||
}
|
||||
d.target.Store(target)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *developmentProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if status, message := d.validateRequest(r); status != 0 {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.Error(w, message, status)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == eventsPath {
|
||||
d.hub.serveHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if d.target.Load() == nil {
|
||||
d.serveWaitingPage(w)
|
||||
return
|
||||
}
|
||||
d.proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (d *developmentProxy) validateRequest(r *http.Request) (int, string) {
|
||||
authority := d.authority.Load()
|
||||
if authority == nil {
|
||||
return http.StatusServiceUnavailable, "Hime-san development proxy is not ready"
|
||||
}
|
||||
requestAuthority, ok := canonicalLoopbackAuthority(r.Host, authority.port)
|
||||
if !ok {
|
||||
return http.StatusMisdirectedRequest, "Hime-san development proxy requires a loopback Host authority"
|
||||
}
|
||||
|
||||
if values := r.Header.Values("Sec-Fetch-Site"); len(values) > 1 {
|
||||
return http.StatusForbidden, "cross-origin development proxy request denied"
|
||||
} else if len(values) == 1 {
|
||||
switch strings.ToLower(strings.TrimSpace(values[0])) {
|
||||
case "", "none", "same-origin":
|
||||
default:
|
||||
return http.StatusForbidden, "cross-origin development proxy request denied"
|
||||
}
|
||||
}
|
||||
|
||||
origins := r.Header.Values("Origin")
|
||||
if len(origins) > 1 {
|
||||
return http.StatusForbidden, "cross-origin development proxy request denied"
|
||||
}
|
||||
if len(origins) == 1 {
|
||||
originAuthority, ok := canonicalHTTPOrigin(origins[0], authority.port)
|
||||
if !ok || originAuthority != requestAuthority {
|
||||
return http.StatusForbidden, "cross-origin development proxy request denied"
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
func canonicalHTTPOrigin(raw string, port int) (string, bool) {
|
||||
if raw == "" || strings.TrimSpace(raw) != raw {
|
||||
return "", false
|
||||
}
|
||||
origin, err := url.Parse(raw)
|
||||
if err != nil || !strings.EqualFold(origin.Scheme, "http") || origin.Host == "" || origin.User != nil || origin.Opaque != "" || origin.Path != "" || origin.RawPath != "" || origin.RawQuery != "" || origin.Fragment != "" || origin.ForceQuery {
|
||||
return "", false
|
||||
}
|
||||
return canonicalLoopbackAuthority(origin.Host, port)
|
||||
}
|
||||
|
||||
func canonicalLoopbackAuthority(authority string, requiredPort int) (string, bool) {
|
||||
if authority == "" || strings.TrimSpace(authority) != authority {
|
||||
return "", false
|
||||
}
|
||||
host, rawPort, err := net.SplitHostPort(authority)
|
||||
if err != nil {
|
||||
if requiredPort != 80 {
|
||||
return "", false
|
||||
}
|
||||
host = authority
|
||||
rawPort = "80"
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
}
|
||||
requestPort, err := strconv.Atoi(rawPort)
|
||||
if err != nil || requestPort != requiredPort {
|
||||
return "", false
|
||||
}
|
||||
|
||||
normalizedHost := strings.ToLower(host)
|
||||
if normalizedHost == "localhost" || normalizedHost == "localhost." {
|
||||
return "localhost:" + strconv.Itoa(requiredPort), true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return "", false
|
||||
}
|
||||
return ip.String() + ":" + strconv.Itoa(requiredPort), true
|
||||
}
|
||||
|
||||
func (d *developmentProxy) serveWaitingPage(w http.ResponseWriter) {
|
||||
body := append([]byte("<!doctype html><html><body><h1>Hime-san is waiting for a healthy application build.</h1>"), reloadClientTag...)
|
||||
body = append(body, []byte("</body></html>")...)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src "+reloadClientHash+"; connect-src 'self'")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func injectDevelopmentClient(response *http.Response) error {
|
||||
disableDevelopmentCaching(response)
|
||||
if !eligibleHTMLResponse(response) {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix, err := io.ReadAll(io.LimitReader(response.Body, maxInjectableHTML+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read HTML for development reload injection: %w", err)
|
||||
}
|
||||
if len(prefix) > maxInjectableHTML {
|
||||
response.Body = &prefixedReadCloser{
|
||||
Reader: io.MultiReader(bytes.NewReader(prefix), response.Body),
|
||||
Closer: response.Body,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return fmt.Errorf("close upstream HTML response: %w", err)
|
||||
}
|
||||
if !isFullHTMLDocument(prefix) {
|
||||
response.Body = io.NopCloser(bytes.NewReader(prefix))
|
||||
response.ContentLength = int64(len(prefix))
|
||||
response.Header.Set("Content-Length", strconv.Itoa(len(prefix)))
|
||||
return nil
|
||||
}
|
||||
|
||||
contents := insertReloadClient(prefix)
|
||||
response.Body = io.NopCloser(bytes.NewReader(contents))
|
||||
response.ContentLength = int64(len(contents))
|
||||
response.Header.Set("Content-Length", strconv.Itoa(len(contents)))
|
||||
response.Header.Del("ETag")
|
||||
response.Header.Del("Last-Modified")
|
||||
adjustCSP(response.Header, "Content-Security-Policy")
|
||||
adjustCSP(response.Header, "Content-Security-Policy-Report-Only")
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableDevelopmentCaching(response *http.Response) {
|
||||
response.Header.Set("Cache-Control", "no-store")
|
||||
response.Header.Set("Pragma", "no-cache")
|
||||
response.Header.Set("Expires", "0")
|
||||
response.Header.Del("ETag")
|
||||
response.Header.Del("Last-Modified")
|
||||
}
|
||||
|
||||
func eligibleHTMLResponse(response *http.Response) bool {
|
||||
if response.StatusCode != http.StatusOK || response.Request == nil || response.Body == nil || response.Request.Method == http.MethodHead {
|
||||
return false
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||
if err != nil || !strings.EqualFold(mediaType, "text/html") {
|
||||
return false
|
||||
}
|
||||
if encoding := strings.TrimSpace(response.Header.Get("Content-Encoding")); encoding != "" && !strings.EqualFold(encoding, "identity") {
|
||||
return false
|
||||
}
|
||||
rawDisposition := strings.TrimSpace(response.Header.Get("Content-Disposition"))
|
||||
disposition, _, dispositionErr := mime.ParseMediaType(rawDisposition)
|
||||
if strings.EqualFold(disposition, "attachment") || (dispositionErr != nil && strings.HasPrefix(strings.ToLower(rawDisposition), "attachment")) {
|
||||
return false
|
||||
}
|
||||
request := response.Request
|
||||
for _, header := range []string{"HX-Request", "Turbo-Frame", "X-PJAX", "X-Requested-With"} {
|
||||
if strings.TrimSpace(request.Header.Get(header)) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(response.Header.Get("X-Himesan-Fragment"), "true") {
|
||||
return false
|
||||
}
|
||||
destination := strings.TrimSpace(request.Header.Get("Sec-Fetch-Dest"))
|
||||
return destination == "" || strings.EqualFold(destination, "document")
|
||||
}
|
||||
|
||||
func isFullHTMLDocument(contents []byte) bool {
|
||||
remaining := bytes.TrimSpace(contents)
|
||||
remaining = bytes.TrimSpace(bytes.TrimPrefix(remaining, []byte{0xef, 0xbb, 0xbf}))
|
||||
for bytes.HasPrefix(remaining, []byte("<!--")) {
|
||||
end := bytes.Index(remaining[4:], []byte("-->"))
|
||||
if end < 0 {
|
||||
return false
|
||||
}
|
||||
remaining = bytes.TrimSpace(remaining[4+end+3:])
|
||||
}
|
||||
lower := bytes.ToLower(remaining)
|
||||
return hasHTMLTokenPrefix(lower, "<!doctype html") || hasHTMLTokenPrefix(lower, "<html")
|
||||
}
|
||||
|
||||
func hasHTMLTokenPrefix(contents []byte, prefix string) bool {
|
||||
if !bytes.HasPrefix(contents, []byte(prefix)) || len(contents) == len(prefix) {
|
||||
return false
|
||||
}
|
||||
next := contents[len(prefix)]
|
||||
return next == '>' || next == '/' || next == ' ' || next == '\t' || next == '\r' || next == '\n' || next == '\f'
|
||||
}
|
||||
|
||||
func insertReloadClient(contents []byte) []byte {
|
||||
lower := bytes.ToLower(contents)
|
||||
position := bytes.LastIndex(lower, []byte("</body>"))
|
||||
if position < 0 {
|
||||
position = bytes.LastIndex(lower, []byte("</html>"))
|
||||
}
|
||||
if position < 0 {
|
||||
position = len(contents)
|
||||
}
|
||||
result := make([]byte, 0, len(contents)+len(reloadClientTag))
|
||||
result = append(result, contents[:position]...)
|
||||
result = append(result, reloadClientTag...)
|
||||
result = append(result, contents[position:]...)
|
||||
return result
|
||||
}
|
||||
|
||||
func adjustCSP(header http.Header, name string) {
|
||||
policies := header.Values(name)
|
||||
if len(policies) == 0 {
|
||||
return
|
||||
}
|
||||
header.Del(name)
|
||||
for _, policy := range policies {
|
||||
policy = addCSPSource(policy, "script-src", reloadClientHash, "default-src")
|
||||
// CSP3 gives script-src-elem precedence over script-src for an inline
|
||||
// <script>. Preserve that directive's restrictions while granting the
|
||||
// same single hash, otherwise a policy such as script-src-elem 'none'
|
||||
// silently blocks the injected reload client.
|
||||
policy = addCSPSource(policy, "script-src-elem", reloadClientHash, "script-src")
|
||||
policy = addCSPSource(policy, "connect-src", "'self'", "default-src")
|
||||
header.Add(name, policy)
|
||||
}
|
||||
}
|
||||
|
||||
func addCSPSource(policy, directive, source, fallback string) string {
|
||||
parts := strings.Split(policy, ";")
|
||||
fallbackSources := []string(nil)
|
||||
fallbackSeen := false
|
||||
for index, raw := range parts {
|
||||
fields := strings.Fields(raw)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
// CSP ignores duplicate directives after the first occurrence. Mirror
|
||||
// that rule when deriving a missing directive from its fallback so an
|
||||
// ignored, more-permissive duplicate cannot broaden the development page.
|
||||
if !fallbackSeen && strings.EqualFold(fields[0], fallback) {
|
||||
fallbackSeen = true
|
||||
fallbackSources = append([]string(nil), fields[1:]...)
|
||||
}
|
||||
if !strings.EqualFold(fields[0], directive) {
|
||||
continue
|
||||
}
|
||||
for _, existing := range fields[1:] {
|
||||
if existing == source {
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
}
|
||||
currentSources := withoutCSPNone(fields[1:])
|
||||
parts[index] = fields[0]
|
||||
if len(currentSources) != 0 {
|
||||
parts[index] += " " + strings.Join(currentSources, " ")
|
||||
}
|
||||
parts[index] += " " + source
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
if !fallbackSeen {
|
||||
// Without this directive or a default-src fallback the resource is
|
||||
// already unrestricted; introducing a directive would unnecessarily
|
||||
// restrict the application under test.
|
||||
return policy
|
||||
}
|
||||
addition := directive
|
||||
if retained := withoutCSPNone(fallbackSources); len(retained) != 0 {
|
||||
addition += " " + strings.Join(retained, " ")
|
||||
}
|
||||
addition += " " + source
|
||||
if strings.TrimSpace(policy) == "" {
|
||||
return addition
|
||||
}
|
||||
if strings.HasSuffix(strings.TrimSpace(policy), ";") {
|
||||
return policy + " " + addition
|
||||
}
|
||||
return policy + "; " + addition
|
||||
}
|
||||
|
||||
func withoutCSPNone(sources []string) []string {
|
||||
result := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
if !strings.EqualFold(source, "'none'") {
|
||||
result = append(result, source)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type prefixedReadCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInjectDevelopmentClientAndCSP(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := "<!doctype html><html><body><h1>Hello</h1></body></html>"
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.Header.Set("Sec-Fetch-Dest", "document")
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
ContentLength: int64(len(body)),
|
||||
Request: request,
|
||||
}
|
||||
response.Header.Set("Content-Type", "text/html; charset=utf-8")
|
||||
response.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
response.Header.Set("ETag", `"old"`)
|
||||
response.Header.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; script-src-elem 'none'")
|
||||
|
||||
if err := injectDevelopmentClient(response); err != nil {
|
||||
t.Fatalf("injectDevelopmentClient() error = %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(got), string(reloadClientTag)) {
|
||||
t.Fatalf("injected body does not contain reload client: %s", got)
|
||||
}
|
||||
if strings.Index(string(got), string(reloadClientTag)) > strings.Index(string(got), "</body>") {
|
||||
t.Fatal("reload client was not inserted inside body")
|
||||
}
|
||||
policy := response.Header.Get("Content-Security-Policy")
|
||||
if !strings.Contains(policy, reloadClientHash) || strings.Contains(policy, "unsafe-inline") {
|
||||
t.Fatalf("CSP did not contain only the reload hash allowance: %q", policy)
|
||||
}
|
||||
scriptElementPolicy := cspDirective(policy, "script-src-elem")
|
||||
if !strings.Contains(scriptElementPolicy, reloadClientHash) || strings.Contains(scriptElementPolicy, "'none'") {
|
||||
t.Fatalf("CSP script-src-elem still blocks the reload client: %q", policy)
|
||||
}
|
||||
if !strings.Contains(policy, "connect-src") || !strings.Contains(policy, "'self'") {
|
||||
t.Fatalf("CSP does not allow same-origin SSE: %q", policy)
|
||||
}
|
||||
if response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("ETag") != "" {
|
||||
t.Fatalf("development cache headers = %#v", response.Header)
|
||||
}
|
||||
if response.ContentLength != int64(len(got)) {
|
||||
t.Fatalf("ContentLength = %d, want %d", response.ContentLength, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func cspDirective(policy, name string) string {
|
||||
for _, raw := range strings.Split(policy, ";") {
|
||||
fields := strings.Fields(raw)
|
||||
if len(fields) != 0 && strings.EqualFold(fields[0], name) {
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestInjectionExcludesFragmentsAndNonHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
header string
|
||||
method string
|
||||
status int
|
||||
}{
|
||||
{name: "unmarked HTML fragment", contentType: "text/html", status: http.StatusOK},
|
||||
{name: "htmx fragment", contentType: "text/html", header: "HX-Request", status: http.StatusOK},
|
||||
{name: "turbo fragment", contentType: "text/html", header: "Turbo-Frame", status: http.StatusOK},
|
||||
{name: "json api", contentType: "application/json", status: http.StatusOK},
|
||||
{name: "HEAD response", contentType: "text/html", method: http.MethodHead, status: http.StatusOK},
|
||||
{name: "no content", contentType: "text/html", status: http.StatusNoContent},
|
||||
{name: "not modified", contentType: "text/html", status: http.StatusNotModified},
|
||||
{name: "partial content", contentType: "text/html", status: http.StatusPartialContent},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := "<p>fragment</p>"
|
||||
method := test.method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
request := httptest.NewRequest(method, "http://example.test/items", nil)
|
||||
if test.header != "" {
|
||||
request.Header.Set(test.header, "true")
|
||||
}
|
||||
response := &http.Response{
|
||||
StatusCode: test.status,
|
||||
Header: http.Header{"Content-Type": []string{test.contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: request,
|
||||
}
|
||||
if err := injectDevelopmentClient(response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != body {
|
||||
t.Fatalf("fragment was modified: %q", got)
|
||||
}
|
||||
if response.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("fragment caching was not disabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullDocumentEvidenceAndCSPNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := " \n<!-- generated -->\n<!DOCTYPE HTML><html><body>page</body></html>"
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/html"},
|
||||
"Content-Security-Policy": []string{"default-src 'none'"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: request,
|
||||
}
|
||||
if err := injectDevelopmentClient(response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(got), string(reloadClientTag)) {
|
||||
t.Fatal("full document with a leading comment was not injected")
|
||||
}
|
||||
policy := response.Header.Get("Content-Security-Policy")
|
||||
if strings.Contains(policy, "script-src 'none'") || strings.Contains(policy, "connect-src 'none'") {
|
||||
t.Fatalf("CSP 'none' was combined with an allowance: %q", policy)
|
||||
}
|
||||
if !strings.Contains(policy, "script-src "+reloadClientHash) || !strings.Contains(policy, "connect-src 'self'") {
|
||||
t.Fatalf("CSP missing narrow development allowances: %q", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSPFallbackUsesFirstDuplicateDirective(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, first := range []string{"'none'", ""} {
|
||||
header := make(http.Header)
|
||||
header.Set("Content-Security-Policy", "default-src "+first+"; default-src https://ignored-attacker.example")
|
||||
adjustCSP(header, "Content-Security-Policy")
|
||||
policy := header.Get("Content-Security-Policy")
|
||||
for _, directive := range []string{"script-src", "script-src-elem"} {
|
||||
value := cspDirective(policy, directive)
|
||||
if !strings.Contains(value, reloadClientHash) || strings.Contains(value, "ignored-attacker.example") {
|
||||
t.Fatalf("%s was broadened from an ignored duplicate fallback: %q", directive, policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStream(t *testing.T) {
|
||||
hub := newEventHub()
|
||||
hub.publish(Event{Type: "diagnostic", Phase: "generate", Message: "broken before connect"})
|
||||
server := httptest.NewServer(http.HandlerFunc(hub.serveHTTP))
|
||||
t.Cleanup(func() {
|
||||
hub.close()
|
||||
server.Close()
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
scanner := bufio.NewScanner(response.Body)
|
||||
ready := readSSEEvent(t, scanner)
|
||||
if ready.Type != "ready" {
|
||||
t.Fatalf("first event = %#v", ready)
|
||||
}
|
||||
replayed := readSSEEvent(t, scanner)
|
||||
if replayed.Type != "diagnostic" || replayed.Message != "broken before connect" {
|
||||
t.Fatalf("replayed event = %#v", replayed)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
hub.mu.Lock()
|
||||
count := len(hub.subscribers)
|
||||
hub.mu.Unlock()
|
||||
if count != 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("SSE handler did not subscribe")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
hub.publish(Event{Type: "diagnostic", Phase: "generate", Message: "broken"})
|
||||
event := readSSEEvent(t, scanner)
|
||||
if event.Type != "diagnostic" || event.Phase != "generate" || event.Message != "broken" {
|
||||
t.Fatalf("streamed event = %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingPageConnectsToEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
proxy := newDevelopmentProxy(newEventHub())
|
||||
if err := proxy.setAuthority("127.0.0.1:7331"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:7331/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(recorder, request)
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
body, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), string(reloadClientTag)) {
|
||||
t.Fatalf("waiting response status/body = %d %q", result.StatusCode, body)
|
||||
}
|
||||
policy := result.Header.Get("Content-Security-Policy")
|
||||
if !strings.Contains(policy, reloadClientHash) || strings.Contains(policy, "unsafe-inline") {
|
||||
t.Fatalf("waiting page CSP = %q", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentProxyRequiresLocalAuthorityAndSameOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
var upstreamRequests atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
upstreamRequests.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
proxy := newDevelopmentProxy(newEventHub())
|
||||
if err := proxy.setAuthority("127.0.0.1:7331"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := proxy.setTarget(target.Host); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
origin string
|
||||
fetchSite string
|
||||
wantStatus int
|
||||
wantForwarded bool
|
||||
}{
|
||||
{name: "IPv4 loopback", host: "127.0.0.1:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "alternate loopback", host: "127.0.0.2:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "IPv6 loopback", host: "[::1]:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "localhost", host: "localhost:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "localhost trailing dot", host: "LOCALHOST.:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "same origin", host: "localhost:7331", origin: "http://localhost:7331", fetchSite: "same-origin", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "DNS rebinding host", host: "attacker.example:7331", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "localhost suffix", host: "localhost.attacker.example:7331", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "public IP host", host: "192.0.2.1:7331", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "wrong port", host: "127.0.0.1:7332", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "missing port", host: "127.0.0.1", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "cross origin", host: "127.0.0.1:7331", origin: "https://attacker.example", wantStatus: http.StatusForbidden},
|
||||
{name: "different local origin", host: "127.0.0.1:7331", origin: "http://localhost:7331", wantStatus: http.StatusForbidden},
|
||||
{name: "null origin", host: "127.0.0.1:7331", origin: "null", wantStatus: http.StatusForbidden},
|
||||
{name: "cross site metadata", host: "127.0.0.1:7331", fetchSite: "cross-site", wantStatus: http.StatusForbidden},
|
||||
{name: "same site but cross origin metadata", host: "localhost:7331", fetchSite: "same-site", wantStatus: http.StatusForbidden},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
before := upstreamRequests.Load()
|
||||
request := httptest.NewRequest(http.MethodGet, "http://"+test.host+"/", nil)
|
||||
request.Host = test.host
|
||||
if test.origin != "" {
|
||||
request.Header.Set("Origin", test.origin)
|
||||
}
|
||||
if test.fetchSite != "" {
|
||||
request.Header.Set("Sec-Fetch-Site", test.fetchSite)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(recorder, request)
|
||||
if recorder.Code != test.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, test.wantStatus, recorder.Body.String())
|
||||
}
|
||||
forwarded := upstreamRequests.Load() != before
|
||||
if forwarded != test.wantForwarded {
|
||||
t.Fatalf("forwarded = %v, want %v", forwarded, test.wantForwarded)
|
||||
}
|
||||
if !test.wantForwarded && recorder.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("rejection was cacheable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentProxyProtectsEventStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := newEventHub()
|
||||
t.Cleanup(hub.close)
|
||||
proxy := newDevelopmentProxy(hub)
|
||||
if err := proxy.setAuthority("127.0.0.1:7331"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rejected := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:7331"+eventsPath, nil)
|
||||
rejected.Header.Set("Origin", "https://attacker.example")
|
||||
rejectedRecorder := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rejectedRecorder, rejected)
|
||||
if rejectedRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-origin event stream status = %d, want %d", rejectedRecorder.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
allowed := httptest.NewRequest(http.MethodGet, "http://localhost:7331"+eventsPath, nil).WithContext(ctx)
|
||||
allowed.Header.Set("Origin", "http://localhost:7331")
|
||||
cancel()
|
||||
allowedRecorder := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(allowedRecorder, allowed)
|
||||
if allowedRecorder.Code != http.StatusOK || !strings.Contains(allowedRecorder.Body.String(), "event: ready") {
|
||||
t.Fatalf("same-origin event stream status/body = %d %q", allowedRecorder.Code, allowedRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventHubRetainsNewestEventForSlowSubscriber(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := newEventHub()
|
||||
updates, unsubscribe := hub.subscribe()
|
||||
defer unsubscribe()
|
||||
for index := 0; index < 20; index++ {
|
||||
hub.publish(Event{Type: "diagnostic", Message: strconv.Itoa(index)})
|
||||
}
|
||||
hub.publish(Event{Type: "reload"})
|
||||
var last Event
|
||||
for len(updates) != 0 {
|
||||
last = <-updates
|
||||
}
|
||||
if last.Type != "reload" {
|
||||
t.Fatalf("newest queued event = %#v, want reload", last)
|
||||
}
|
||||
}
|
||||
|
||||
func readSSEEvent(t *testing.T, scanner *bufio.Scanner) Event {
|
||||
t.Helper()
|
||||
var data string
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data = strings.TrimPrefix(line, "data: ")
|
||||
}
|
||||
if line == "" && data != "" {
|
||||
var event Event
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
t.Fatalf("decode SSE event: %v", err)
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
t.Fatalf("SSE stream ended: %v", scanner.Err())
|
||||
return Event{}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxDiagnosticOutput = 64 << 10
|
||||
|
||||
// GenerateFunc regenerates affected .sando.go files. The supervisor does not
|
||||
// import the compiler: the CLI supplies this hook.
|
||||
type GenerateFunc func(context.Context) error
|
||||
|
||||
// Options configures a Supervisor. Durations and output writers have safe
|
||||
// defaults when omitted.
|
||||
type Options struct {
|
||||
RootDir string
|
||||
Config Config
|
||||
Generate GenerateFunc
|
||||
MapDiagnostics func(error) []Diagnostic
|
||||
OnEvent func(Event)
|
||||
|
||||
Output io.Writer
|
||||
ErrorOutput io.Writer
|
||||
GoCommand string
|
||||
CacheDir string
|
||||
|
||||
PollInterval time.Duration
|
||||
Debounce time.Duration
|
||||
BuildTimeout time.Duration
|
||||
StartupTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Supervisor owns the local proxy, watcher, build candidates, and current
|
||||
// healthy application child.
|
||||
type Supervisor struct {
|
||||
options Options
|
||||
rootDir string
|
||||
cacheDir string
|
||||
|
||||
hub *eventHub
|
||||
proxy *developmentProxy
|
||||
|
||||
running atomic.Bool
|
||||
addressMu sync.RWMutex
|
||||
address string
|
||||
}
|
||||
|
||||
// New validates and normalizes a local development supervisor without opening
|
||||
// listeners or starting processes.
|
||||
func New(options Options) (*Supervisor, error) {
|
||||
if err := options.Config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("development config: %w", err)
|
||||
}
|
||||
if options.Generate == nil {
|
||||
return nil, errors.New("development generate hook is required")
|
||||
}
|
||||
options.Config.SourceRoots = append([]string(nil), options.Config.SourceRoots...)
|
||||
options.Config.AppArgs = append([]string(nil), options.Config.AppArgs...)
|
||||
options.Config.AdditionalWatchRoots = append([]string(nil), options.Config.AdditionalWatchRoots...)
|
||||
rootDir := options.RootDir
|
||||
if rootDir == "" {
|
||||
var err error
|
||||
rootDir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get project directory: %w", err)
|
||||
}
|
||||
}
|
||||
rootDir, err := filepath.Abs(rootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve project directory: %w", err)
|
||||
}
|
||||
info, err := os.Stat(rootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect project directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("project root %q is not a directory", rootDir)
|
||||
}
|
||||
|
||||
applyOptionDefaults(&options)
|
||||
cacheDir := options.CacheDir
|
||||
if cacheDir == "" {
|
||||
userCache, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("locate user cache directory: %w", err)
|
||||
}
|
||||
key := sha256.Sum256([]byte(rootDir + "\x00" + options.Config.GoPackage))
|
||||
cacheDir = filepath.Join(userCache, "himesan", "dev", hex.EncodeToString(key[:8]))
|
||||
} else if !filepath.IsAbs(cacheDir) {
|
||||
cacheDir = filepath.Join(rootDir, cacheDir)
|
||||
}
|
||||
|
||||
hub := newEventHub()
|
||||
return &Supervisor{
|
||||
options: options,
|
||||
rootDir: rootDir,
|
||||
cacheDir: filepath.Clean(cacheDir),
|
||||
hub: hub,
|
||||
proxy: newDevelopmentProxy(hub),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyOptionDefaults(options *Options) {
|
||||
if options.Output == nil {
|
||||
options.Output = io.Discard
|
||||
}
|
||||
if options.ErrorOutput == nil {
|
||||
options.ErrorOutput = io.Discard
|
||||
}
|
||||
if options.GoCommand == "" {
|
||||
options.GoCommand = "go"
|
||||
}
|
||||
if options.PollInterval <= 0 {
|
||||
options.PollInterval = 250 * time.Millisecond
|
||||
}
|
||||
if options.Debounce <= 0 {
|
||||
options.Debounce = 150 * time.Millisecond
|
||||
}
|
||||
if options.BuildTimeout <= 0 {
|
||||
options.BuildTimeout = 2 * time.Minute
|
||||
}
|
||||
if options.StartupTimeout <= 0 {
|
||||
options.StartupTimeout = 10 * time.Second
|
||||
}
|
||||
if options.ShutdownTimeout <= 0 {
|
||||
options.ShutdownTimeout = 5 * time.Second
|
||||
}
|
||||
if options.HTTPClient == nil {
|
||||
options.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{Proxy: nil},
|
||||
Timeout: time.Second,
|
||||
}
|
||||
} else {
|
||||
copy := *options.HTTPClient
|
||||
options.HTTPClient = ©
|
||||
}
|
||||
// Health redirects are status results, not permission to leave loopback.
|
||||
options.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyAddress reports the bound stable proxy address after Run has opened its
|
||||
// listener. It is useful when Config.ProxyAddress requests port zero in tests.
|
||||
func (s *Supervisor) ProxyAddress() string {
|
||||
s.addressMu.RLock()
|
||||
defer s.addressMu.RUnlock()
|
||||
return s.address
|
||||
}
|
||||
|
||||
// Run serves until ctx is canceled or the stable proxy fails. A generation,
|
||||
// build, startup, or health-check failure is reported as a diagnostic event and
|
||||
// leaves the last healthy child serving.
|
||||
func (s *Supervisor) Run(ctx context.Context) error {
|
||||
if !s.running.CompareAndSwap(false, true) {
|
||||
return errors.New("development supervisor may only be run once")
|
||||
}
|
||||
if err := os.MkdirAll(s.cacheDir, 0o700); err != nil {
|
||||
return fmt.Errorf("create development cache: %w", err)
|
||||
}
|
||||
if err := os.Chmod(s.cacheDir, 0o700); err != nil {
|
||||
return fmt.Errorf("secure development cache: %w", err)
|
||||
}
|
||||
listener, err := net.Listen("tcp", s.options.Config.ProxyAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on development proxy: %w", err)
|
||||
}
|
||||
if err := s.proxy.setAuthority(listener.Addr().String()); err != nil {
|
||||
_ = listener.Close()
|
||||
return err
|
||||
}
|
||||
s.addressMu.Lock()
|
||||
s.address = listener.Addr().String()
|
||||
s.addressMu.Unlock()
|
||||
|
||||
server := &http.Server{
|
||||
Handler: s.proxy,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 75 * time.Second,
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
err := server.Serve(listener)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
serverErrors <- err
|
||||
}()
|
||||
|
||||
var current *candidateProcess
|
||||
defer func() {
|
||||
s.hub.close()
|
||||
s.proxy.closeIdleConnections()
|
||||
serverCtx, cancelServer := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
_ = server.Shutdown(serverCtx)
|
||||
cancelServer()
|
||||
if current != nil {
|
||||
processCtx, cancelProcess := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
_ = current.stop(processCtx)
|
||||
cancelProcess()
|
||||
}
|
||||
}()
|
||||
|
||||
s.emit(Event{Type: "ready", Phase: "proxy", Message: "http://" + listener.Addr().String()})
|
||||
if candidate := s.buildHealthyCandidate(ctx); candidate != nil {
|
||||
current = s.activateCandidate(candidate, current)
|
||||
}
|
||||
|
||||
roots := makeWatchRoots(s.rootDir, s.options.Config)
|
||||
snapshot, snapshotErr := takeSnapshot(roots)
|
||||
lastWatchError := ""
|
||||
if snapshotErr != nil {
|
||||
lastWatchError = snapshotErr.Error()
|
||||
s.report("watch", snapshotErr)
|
||||
}
|
||||
ticker := time.NewTicker(s.options.PollInterval)
|
||||
defer ticker.Stop()
|
||||
var pending bool
|
||||
var changedAt time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-serverErrors:
|
||||
if err != nil {
|
||||
return fmt.Errorf("development proxy: %w", err)
|
||||
}
|
||||
return nil
|
||||
case now := <-ticker.C:
|
||||
next, watchErr := takeSnapshot(roots)
|
||||
watchError := ""
|
||||
if watchErr != nil {
|
||||
watchError = watchErr.Error()
|
||||
}
|
||||
if watchError != "" && watchError != lastWatchError {
|
||||
s.report("watch", watchErr)
|
||||
}
|
||||
lastWatchError = watchError
|
||||
if !snapshotsEqual(snapshot, next) {
|
||||
snapshot = next
|
||||
pending = true
|
||||
changedAt = now
|
||||
}
|
||||
if current != nil && current.hasExited() {
|
||||
exitErr := current.result()
|
||||
if exitErr == nil {
|
||||
exitErr = errors.New("application exited")
|
||||
} else {
|
||||
exitErr = fmt.Errorf("application exited: %w", exitErr)
|
||||
}
|
||||
s.report("run", exitErr)
|
||||
_ = current.cleanupProcessTree()
|
||||
_ = os.Remove(current.binaryPath)
|
||||
current = nil
|
||||
}
|
||||
if pending && now.Sub(changedAt) >= s.options.Debounce {
|
||||
pending = false
|
||||
if candidate := s.buildHealthyCandidate(ctx); candidate != nil {
|
||||
current = s.activateCandidate(candidate, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) buildHealthyCandidate(ctx context.Context) *candidateProcess {
|
||||
if err := s.options.Generate(ctx); err != nil {
|
||||
s.report("generate", err)
|
||||
return nil
|
||||
}
|
||||
binaryPath, err := s.build(ctx)
|
||||
if err != nil {
|
||||
s.report("build", err)
|
||||
return nil
|
||||
}
|
||||
candidate, err := s.startAndCheck(ctx, binaryPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(binaryPath)
|
||||
s.report("startup", err)
|
||||
return nil
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func (s *Supervisor) build(ctx context.Context) (string, error) {
|
||||
buildCtx, cancel := context.WithTimeout(ctx, s.options.BuildTimeout)
|
||||
defer cancel()
|
||||
template := "candidate-*"
|
||||
if runtime.GOOS == "windows" {
|
||||
template += ".exe"
|
||||
}
|
||||
placeholder, err := os.CreateTemp(s.cacheDir, template)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reserve candidate binary: %w", err)
|
||||
}
|
||||
binaryPath := placeholder.Name()
|
||||
if err := placeholder.Close(); err != nil {
|
||||
_ = os.Remove(binaryPath)
|
||||
return "", fmt.Errorf("close candidate placeholder: %w", err)
|
||||
}
|
||||
if err := os.Remove(binaryPath); err != nil {
|
||||
return "", fmt.Errorf("prepare candidate binary: %w", err)
|
||||
}
|
||||
command := exec.CommandContext(buildCtx, s.options.GoCommand, "build", "-o", binaryPath, "--", s.options.Config.GoPackage)
|
||||
command.Dir = s.rootDir
|
||||
var diagnostics limitedDiagnosticBuffer
|
||||
command.Stdout = io.MultiWriter(s.options.Output, &diagnostics)
|
||||
command.Stderr = io.MultiWriter(s.options.ErrorOutput, &diagnostics)
|
||||
if err := command.Run(); err != nil {
|
||||
_ = os.Remove(binaryPath)
|
||||
message := diagnostics.String()
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("go build failed: %s", message)
|
||||
}
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
func (s *Supervisor) startAndCheck(ctx context.Context, binaryPath string) (*candidateProcess, error) {
|
||||
address, err := unusedLoopbackAddress()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command := exec.Command(binaryPath, s.options.Config.AppArgs...)
|
||||
command.Dir = s.rootDir
|
||||
command.Env = replaceEnvironment(os.Environ(), s.options.Config.ListenAddressEnv, address)
|
||||
command.Stdout = s.options.Output
|
||||
command.Stderr = s.options.ErrorOutput
|
||||
candidate, err := startManagedProcess(command, address, binaryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start candidate: %w", err)
|
||||
}
|
||||
startupCtx, cancel := context.WithTimeout(ctx, s.options.StartupTimeout)
|
||||
defer cancel()
|
||||
if err := s.waitUntilHealthy(startupCtx, candidate); err != nil {
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
defer stopCancel()
|
||||
_ = candidate.stop(stopCtx)
|
||||
return nil, err
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func unusedLoopbackAddress() (string, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reserve candidate address: %w", err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
if err := listener.Close(); err != nil {
|
||||
return "", fmt.Errorf("release candidate address: %w", err)
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func (s *Supervisor) waitUntilHealthy(ctx context.Context, candidate *candidateProcess) error {
|
||||
url := "http://" + candidate.address + s.options.Config.HealthPath
|
||||
ticker := time.NewTicker(75 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
var lastError error
|
||||
for {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create health request: %w", err)
|
||||
}
|
||||
response, err := s.options.HTTPClient.Do(request)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 400 {
|
||||
return nil
|
||||
}
|
||||
lastError = fmt.Errorf("health endpoint returned %s", response.Status)
|
||||
} else {
|
||||
lastError = err
|
||||
}
|
||||
select {
|
||||
case <-candidate.exited:
|
||||
exitErr := candidate.result()
|
||||
if exitErr == nil {
|
||||
return errors.New("candidate exited before becoming healthy")
|
||||
}
|
||||
return fmt.Errorf("candidate exited before becoming healthy: %w", exitErr)
|
||||
case <-ctx.Done():
|
||||
if lastError == nil {
|
||||
lastError = ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("candidate did not become healthy: %w", lastError)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) activateCandidate(candidate, previous *candidateProcess) *candidateProcess {
|
||||
if err := s.proxy.setTarget(candidate.address); err != nil {
|
||||
s.report("proxy", err)
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
defer cancel()
|
||||
_ = candidate.stop(stopCtx)
|
||||
return previous
|
||||
}
|
||||
s.emit(Event{Type: "reload", Phase: "serve", Message: "healthy application activated"})
|
||||
if previous != nil {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
_ = previous.stop(stopCtx)
|
||||
cancel()
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func (s *Supervisor) emit(event Event) {
|
||||
if event.At.IsZero() {
|
||||
event.At = time.Now().UTC()
|
||||
}
|
||||
s.hub.publish(event)
|
||||
if s.options.OnEvent != nil {
|
||||
s.options.OnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) report(phase string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
event := Event{Type: "diagnostic", Phase: phase, Message: truncateDiagnostic(err.Error())}
|
||||
if s.options.MapDiagnostics != nil {
|
||||
event.Diagnostics = s.options.MapDiagnostics(err)
|
||||
}
|
||||
s.emit(event)
|
||||
}
|
||||
|
||||
func replaceEnvironment(environment []string, name, value string) []string {
|
||||
result := make([]string, 0, len(environment)+1)
|
||||
for _, item := range environment {
|
||||
itemName, _, ok := strings.Cut(item, "=")
|
||||
matches := ok && itemName == name
|
||||
if runtime.GOOS == "windows" {
|
||||
matches = ok && strings.EqualFold(itemName, name)
|
||||
}
|
||||
if matches {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return append(result, name+"="+value)
|
||||
}
|
||||
|
||||
func truncateDiagnostic(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if len(message) <= maxDiagnosticOutput {
|
||||
return message
|
||||
}
|
||||
return strings.ToValidUTF8(message[:maxDiagnosticOutput], "�") + "\n… diagnostic output truncated"
|
||||
}
|
||||
|
||||
type limitedDiagnosticBuffer struct {
|
||||
bytes.Buffer
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (b *limitedDiagnosticBuffer) Write(contents []byte) (int, error) {
|
||||
originalLength := len(contents)
|
||||
remaining := maxDiagnosticOutput - b.Buffer.Len()
|
||||
writtenLength := 0
|
||||
if remaining > 0 {
|
||||
if len(contents) > remaining {
|
||||
contents = contents[:remaining]
|
||||
}
|
||||
writtenLength, _ = b.Buffer.Write(contents)
|
||||
}
|
||||
if originalLength > writtenLength {
|
||||
b.truncated = true
|
||||
}
|
||||
return originalLength, nil
|
||||
}
|
||||
|
||||
func (b *limitedDiagnosticBuffer) String() string {
|
||||
message := strings.TrimSpace(strings.ToValidUTF8(b.Buffer.String(), "�"))
|
||||
if b.truncated {
|
||||
message += "\n… diagnostic output truncated"
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("integration test builds temporary Go applications")
|
||||
}
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-test\n\ngo 1.25\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := filepath.Join(root, "main.go")
|
||||
writeTestApplication(t, mainPath, "version one", true)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.ProxyAddress = "127.0.0.1:0"
|
||||
cfg.HealthPath = "/healthz"
|
||||
var generations atomic.Int32
|
||||
events := make(chan Event, 32)
|
||||
supervisor, err := New(Options{
|
||||
RootDir: root,
|
||||
Config: cfg,
|
||||
Generate: func(context.Context) error {
|
||||
generations.Add(1)
|
||||
return nil
|
||||
},
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
PollInterval: 25 * time.Millisecond,
|
||||
Debounce: 25 * time.Millisecond,
|
||||
BuildTimeout: 30 * time.Second,
|
||||
StartupTimeout: 750 * time.Millisecond,
|
||||
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+"/", "version one")
|
||||
firstUpstream := supervisor.proxy.target.Load().Host
|
||||
|
||||
if err := os.WriteFile(mainPath, []byte("package main\nfunc"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForPhase(t, events, "build")
|
||||
waitForBody(t, "http://"+proxyAddress+"/", "version one")
|
||||
|
||||
writeTestApplication(t, mainPath, "unhealthy candidate", false)
|
||||
waitForPhase(t, events, "startup")
|
||||
waitForBody(t, "http://"+proxyAddress+"/", "version one")
|
||||
|
||||
writeTestApplication(t, mainPath, "version two", true)
|
||||
waitForBody(t, "http://"+proxyAddress+"/", "version two")
|
||||
if generations.Load() < 4 {
|
||||
t.Fatalf("Generate hook ran %d times, want at least 4", generations.Load())
|
||||
}
|
||||
secondUpstream := supervisor.proxy.target.Load().Host
|
||||
if firstUpstream == secondUpstream {
|
||||
t.Fatalf("healthy candidate was not swapped: %s", firstUpstream)
|
||||
}
|
||||
// The proxy target changes before graceful shutdown of the replaced child
|
||||
// completes. Wait for that bounded cleanup instead of racing the supervisor
|
||||
// immediately after the first response from the new target.
|
||||
waitForConnectionRefused(t, firstUpstream, 3*time.Second)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-runResult:
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run() did not stop after cancellation")
|
||||
}
|
||||
waitForConnectionRefused(t, secondUpstream, 3*time.Second)
|
||||
}
|
||||
|
||||
func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "last good")
|
||||
})}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = upstream.Close() })
|
||||
go func() { _ = upstream.Serve(listener) }()
|
||||
|
||||
wantError := errors.New("templates are invalid")
|
||||
events := make(chan Event, 1)
|
||||
supervisor, err := New(Options{
|
||||
RootDir: t.TempDir(),
|
||||
Config: DefaultConfig(),
|
||||
Generate: func(context.Context) error {
|
||||
return wantError
|
||||
},
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := supervisor.proxy.setTarget(listener.Addr().String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTarget := supervisor.proxy.target.Load().String()
|
||||
if candidate := supervisor.buildHealthyCandidate(context.Background()); candidate != nil {
|
||||
t.Fatal("generation failure unexpectedly produced a candidate")
|
||||
}
|
||||
if got := supervisor.proxy.target.Load().String(); got != wantTarget {
|
||||
t.Fatalf("proxy target changed from %q to %q", wantTarget, got)
|
||||
}
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type != "diagnostic" || event.Phase != "generate" || !strings.Contains(event.Message, wantError.Error()) {
|
||||
t.Fatalf("generation event = %#v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("generation diagnostic was not emitted")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestApplication(t *testing.T, path, message string, healthy bool) {
|
||||
t.Helper()
|
||||
healthStatus := "http.StatusNoContent"
|
||||
if !healthy {
|
||||
healthStatus = "http.StatusServiceUnavailable"
|
||||
}
|
||||
contents := fmt.Sprintf(`package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(%s) })
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, "<!doctype html><html><body>%s</body></html>")
|
||||
})
|
||||
if err := http.ListenAndServe(os.Getenv("HIMESAN_LISTEN_ADDR"), mux); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
`, healthStatus, message)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPhase(t *testing.T, events <-chan Event, 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 {
|
||||
return
|
||||
}
|
||||
case <-timer.C:
|
||||
t.Fatalf("did not receive %s diagnostic", phase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForProxyAddress(t *testing.T, supervisor *Supervisor) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if address := supervisor.ProxyAddress(); address != "" {
|
||||
return address
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("development proxy did not start")
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForBody(t *testing.T, url, want string) {
|
||||
t.Helper()
|
||||
client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: time.Second}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var last string
|
||||
for time.Now().Before(deadline) {
|
||||
response, err := client.Get(url)
|
||||
if err == nil {
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
_ = response.Body.Close()
|
||||
if readErr == nil {
|
||||
last = string(body)
|
||||
if response.StatusCode == http.StatusOK && strings.Contains(last, want) && strings.Contains(last, string(reloadClientTag)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("proxy never served %q; last body = %q", want, last)
|
||||
}
|
||||
|
||||
func waitForConnectionRefused(t *testing.T, address string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = connection.Close()
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("replaced child still accepts connections at %s after %s", address, timeout)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitedDiagnosticBuffer(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buffer limitedDiagnosticBuffer
|
||||
contents := strings.Repeat("x", maxDiagnosticOutput+100)
|
||||
written, err := buffer.Write([]byte(contents))
|
||||
if err != nil || written != len(contents) {
|
||||
t.Fatalf("Write() = %d, %v", written, err)
|
||||
}
|
||||
if buffer.Buffer.Len() != maxDiagnosticOutput {
|
||||
t.Fatalf("stored bytes = %d, want %d", buffer.Buffer.Len(), maxDiagnosticOutput)
|
||||
}
|
||||
if !strings.HasSuffix(buffer.String(), "diagnostic output truncated") {
|
||||
t.Fatalf("String() did not report truncation: %q", buffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceEnvironment(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := replaceEnvironment([]string{"A=one", "HIMESAN_LISTEN_ADDR=old", "B=two"}, "HIMESAN_LISTEN_ADDR", "127.0.0.1:1")
|
||||
want := []string{"A=one", "B=two", "HIMESAN_LISTEN_ADDR=127.0.0.1:1"}
|
||||
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
|
||||
t.Fatalf("replaceEnvironment() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckDoesNotFollowRedirectOffLoopback(t *testing.T) {
|
||||
t.Parallel()
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", "http://example.invalid/escaped")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
supervisor, err := New(Options{
|
||||
RootDir: t.TempDir(),
|
||||
Config: cfg,
|
||||
Generate: func(context.Context) error { return nil },
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate := &candidateProcess{
|
||||
address: strings.TrimPrefix(upstream.URL, "http://"),
|
||||
exited: make(chan struct{}),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := supervisor.waitUntilHealthy(ctx, candidate); err != nil {
|
||||
t.Fatalf("loopback redirect status should be observed without following it: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type watchRoot struct {
|
||||
path string
|
||||
allRegular bool
|
||||
}
|
||||
|
||||
type fileFingerprint struct {
|
||||
size int64
|
||||
mode fs.FileMode
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
type fileSnapshot map[string]fileFingerprint
|
||||
|
||||
func makeWatchRoots(rootDir string, cfg Config) []watchRoot {
|
||||
roots := make([]watchRoot, 0, 1+len(cfg.SourceRoots)+len(cfg.AdditionalWatchRoots))
|
||||
// GoPackage may live outside a narrowly configured template source root.
|
||||
// Watching the containing module (while respecting nested module boundaries)
|
||||
// makes ordinary Go edits rebuild without asking users to duplicate roots.
|
||||
rootDir = filepath.Clean(rootDir)
|
||||
roots = append(roots, watchRoot{path: rootDir})
|
||||
seen := map[string]bool{rootDir: true}
|
||||
for _, root := range cfg.SourceRoots {
|
||||
path := resolveProjectPath(rootDir, root)
|
||||
if !seen[path] {
|
||||
roots = append(roots, watchRoot{path: path})
|
||||
seen[path] = true
|
||||
}
|
||||
}
|
||||
for _, root := range cfg.AdditionalWatchRoots {
|
||||
path := resolveProjectPath(rootDir, root)
|
||||
if seen[path] {
|
||||
for index := range roots {
|
||||
if roots[index].path == path {
|
||||
roots[index].allRegular = true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
roots = append(roots, watchRoot{path: path, allRegular: true})
|
||||
seen[path] = true
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func takeSnapshot(roots []watchRoot) (fileSnapshot, error) {
|
||||
snapshot := make(fileSnapshot)
|
||||
var problems []error
|
||||
for _, root := range roots {
|
||||
rootInfo, err := os.Lstat(root.path)
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("watch %s: %w", root.path, err))
|
||||
continue
|
||||
}
|
||||
if rootInfo.Mode()&os.ModeSymlink != 0 {
|
||||
problems = append(problems, fmt.Errorf("watch %s: symbolic-link roots are not followed", root.path))
|
||||
continue
|
||||
}
|
||||
err = filepath.WalkDir(root.path, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
problems = append(problems, fmt.Errorf("watch %s: %w", path, walkErr))
|
||||
if entry != nil && entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path != root.path && entry.IsDir() {
|
||||
if shouldSkipWatchDirectory(entry.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !root.allRegular {
|
||||
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(entry.Name()), ".sando.go") {
|
||||
return nil
|
||||
}
|
||||
if !root.allRegular && !isDevelopmentSource(path) {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("watch %s: %w", path, err))
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
snapshot[path] = fileFingerprint{
|
||||
size: info.Size(),
|
||||
mode: info.Mode(),
|
||||
modTime: info.ModTime(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("watch %s: %w", root.path, err))
|
||||
}
|
||||
}
|
||||
return snapshot, errors.Join(problems...)
|
||||
}
|
||||
|
||||
func shouldSkipWatchDirectory(name string) bool {
|
||||
switch name {
|
||||
case ".git", ".hg", ".svn", ".himesan", "node_modules", "vendor":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDevelopmentSource(path string) bool {
|
||||
name := filepath.Base(path)
|
||||
// Generated output is rebuilt from its .sando source and is therefore not a
|
||||
// separate watch trigger. Excluding it prevents generation from causing a
|
||||
// redundant build while still preserving edits made during an active build.
|
||||
if strings.HasSuffix(strings.ToLower(name), ".sando.go") {
|
||||
return false
|
||||
}
|
||||
switch name {
|
||||
case "go.mod", "go.sum", "go.work", "go.work.sum", "himesan.json":
|
||||
return true
|
||||
}
|
||||
extension := strings.ToLower(filepath.Ext(name))
|
||||
return extension == ".go" || extension == ".sando"
|
||||
}
|
||||
|
||||
func snapshotsEqual(left, right fileSnapshot) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for path, leftFingerprint := range left {
|
||||
if rightFingerprint, ok := right[path]; !ok || rightFingerprint != leftFingerprint {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSnapshotWatchesSourcesAssetsAndStopsAtNestedModules(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
writeWatchFile(t, filepath.Join(root, "main.go"), "package main")
|
||||
writeWatchFile(t, filepath.Join(root, "views", "home.sando"), "<h1>home</h1>")
|
||||
writeWatchFile(t, filepath.Join(root, "views", "home.sando.go"), "// generated")
|
||||
writeWatchFile(t, filepath.Join(root, "notes.txt"), "not watched")
|
||||
writeWatchFile(t, filepath.Join(root, "assets", "site.css"), "body{}")
|
||||
writeWatchFile(t, filepath.Join(root, "nested", "go.mod"), "module nested.test")
|
||||
writeWatchFile(t, filepath.Join(root, "nested", "ignored.go"), "package ignored")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.SourceRoots = []string{"views"}
|
||||
cfg.AdditionalWatchRoots = []string{"assets"}
|
||||
snapshot, err := takeSnapshot(makeWatchRoots(root, cfg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"main.go", filepath.Join("views", "home.sando"), filepath.Join("assets", "site.css")} {
|
||||
if _, ok := snapshot[filepath.Join(root, want)]; !ok {
|
||||
t.Errorf("snapshot does not contain %s", want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"notes.txt", filepath.Join("views", "home.sando.go"), filepath.Join("nested", "go.mod"), filepath.Join("nested", "ignored.go")} {
|
||||
if _, ok := snapshot[filepath.Join(root, unwanted)]; ok {
|
||||
t.Errorf("snapshot unexpectedly contains %s", unwanted)
|
||||
}
|
||||
}
|
||||
|
||||
before := snapshot
|
||||
writeWatchFile(t, filepath.Join(root, "assets", "site.css"), "body{color:green}")
|
||||
after, err := takeSnapshot(makeWatchRoots(root, cfg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshotsEqual(before, after) {
|
||||
t.Fatal("asset change was not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func writeWatchFile(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Package version contains build identifiers shared by the compiler and CLI.
|
||||
package version
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const developmentCompilerVersion = "0.1.0-dev"
|
||||
|
||||
// Compiler may be replaced in release binaries with
|
||||
// `-X gamertan.com/sandwich-hime/internal/version.Compiler=vX.Y.Z`.
|
||||
// A versioned `go install module/package@vX.Y.Z` instead supplies the main
|
||||
// module version through Go build information; init adopts that version when
|
||||
// no explicit linker value was provided.
|
||||
var Compiler = developmentCompilerVersion
|
||||
|
||||
var (
|
||||
taggedCompilerVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$`)
|
||||
pseudoVersionSuffixPattern = regexp.MustCompile(`(?:^|[.-])(?:0\.)?[0-9]{14}-[0-9a-f]{12,}$`)
|
||||
)
|
||||
|
||||
func init() {
|
||||
if information, ok := debug.ReadBuildInfo(); ok {
|
||||
Compiler = selectCompilerVersion(Compiler, information.Main.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func selectCompilerVersion(linkerValue, moduleVersion string) string {
|
||||
if linkerValue != developmentCompilerVersion {
|
||||
return linkerValue
|
||||
}
|
||||
moduleVersion = strings.TrimSpace(moduleVersion)
|
||||
if !isTaggedCompilerVersion(moduleVersion) {
|
||||
return linkerValue
|
||||
}
|
||||
return moduleVersion
|
||||
}
|
||||
|
||||
func isTaggedCompilerVersion(value string) bool {
|
||||
matches := taggedCompilerVersionPattern.FindStringSubmatch(value)
|
||||
if matches == nil {
|
||||
// Build metadata is deliberately excluded. In particular, local VCS
|
||||
// builds can carry +dirty and must remain development builds.
|
||||
return false
|
||||
}
|
||||
prerelease := matches[1]
|
||||
if pseudoVersionSuffixPattern.MatchString(prerelease) {
|
||||
// Go synthesizes valid-semver pseudo-versions for local VCS builds. They
|
||||
// identify source, but they are not signed/tagged Hime-san releases.
|
||||
return false
|
||||
}
|
||||
for _, identifier := range strings.Split(prerelease, ".") {
|
||||
if len(identifier) > 1 && identifier[0] == '0' && allDecimal(identifier) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allDecimal(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const (
|
||||
// RuntimeABI identifies the generated-code/runtime contract.
|
||||
RuntimeABI = "sando.v1"
|
||||
|
||||
// ConfigSchema is the supported himesan.json schema version.
|
||||
ConfigSchema = 1
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package version
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSelectCompilerVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
linkerValue string
|
||||
moduleVersion string
|
||||
want string
|
||||
}{
|
||||
{name: "local build", linkerValue: developmentCompilerVersion, moduleVersion: "(devel)", want: developmentCompilerVersion},
|
||||
{name: "missing build info", linkerValue: developmentCompilerVersion, moduleVersion: "", want: developmentCompilerVersion},
|
||||
{name: "versioned go install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0", want: "v1.0.0"},
|
||||
{name: "versioned prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-rc.1", want: "v1.0.0-rc.1"},
|
||||
{name: "hyphenated prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.2.3-beta-2", want: "v1.2.3-beta-2"},
|
||||
{name: "pseudo version", linkerValue: developmentCompilerVersion, moduleVersion: "v0.0.0-20260811120000-0123456789ab", want: developmentCompilerVersion},
|
||||
{name: "pseudo version after release", linkerValue: developmentCompilerVersion, moduleVersion: "v1.2.4-0.20260811120000-0123456789ab", want: developmentCompilerVersion},
|
||||
{name: "pseudo version after prerelease", linkerValue: developmentCompilerVersion, moduleVersion: "v1.2.3-rc.1.0.20260811120000-0123456789ab", want: developmentCompilerVersion},
|
||||
{name: "dirty pseudo version", linkerValue: developmentCompilerVersion, moduleVersion: "v0.0.0-20260811123456-fedcba987654+dirty", want: developmentCompilerVersion},
|
||||
{name: "dirty release checkout", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0+dirty", want: developmentCompilerVersion},
|
||||
{name: "build metadata", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0+build.1", want: developmentCompilerVersion},
|
||||
{name: "leading zero release", linkerValue: developmentCompilerVersion, moduleVersion: "v01.0.0", want: developmentCompilerVersion},
|
||||
{name: "leading zero numeric prerelease", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-rc.01", want: developmentCompilerVersion},
|
||||
{name: "linker override wins", linkerValue: "v1.0.0-rc.1", moduleVersion: "v1.0.0", want: "v1.0.0-rc.1"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := selectCompilerVersion(test.linkerValue, test.moduleVersion); got != test.want {
|
||||
t.Fatalf("selectCompilerVersion(%q, %q) = %q, want %q", test.linkerValue, test.moduleVersion, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user