Publish the sanitized Beta 1 source candidate with version-stable generated provenance, classroom/evaluation support boundaries, provisional macOS support, signed-source release policy, and an exact candidate preflight. Material implementation, drafting, and review were assisted by OpenAI Codex. Cole Speelman reviewed the public snapshot and accepts human responsibility for the contribution. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
344 lines
11 KiB
Go
344 lines
11 KiB
Go
// 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")
|
|
// A version-specific exported marker makes the generated/runtime ABI a Go
|
|
// build-time contract. The descriptive ABI string alone cannot enforce
|
|
// compatibility because constant values are not part of symbol resolution.
|
|
fmt.Fprintf(&output, "var _ = %s.ABISandoV1\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
|
|
}
|
|
|
|
// generatedCodeEqual compares the freshness-relevant portions of two
|
|
// generated files. The compiler release is informational provenance: changing
|
|
// only that line must not make otherwise identical output stale. Every other
|
|
// byte remains part of the generated-code contract.
|
|
func generatedCodeEqual(a, b []byte) bool {
|
|
if bytes.Equal(a, b) {
|
|
return true
|
|
}
|
|
aBody, aOK := generatedCodeWithoutCompilerVersion(a)
|
|
bBody, bOK := generatedCodeWithoutCompilerVersion(b)
|
|
return aOK && bOK && bytes.Equal(aBody, bBody)
|
|
}
|
|
|
|
func generatedCodeWithoutCompilerVersion(code []byte) ([]byte, bool) {
|
|
prefix := []byte(generatedPrefix + "\n// himesan:compiler ")
|
|
if !bytes.HasPrefix(code, prefix) {
|
|
return nil, false
|
|
}
|
|
remainder := code[len(prefix):]
|
|
lineEnd := bytes.IndexByte(remainder, '\n')
|
|
if lineEnd <= 0 {
|
|
return nil, false
|
|
}
|
|
return remainder[lineEnd:], true
|
|
}
|