Prepare Sandwich Hime v1 release candidate source
This commit is contained in:
@@ -16,4 +16,7 @@ type ComponentFunc func(context.Context, io.Writer) error
|
||||
|
||||
`Render` invokes components; generated code uses the context-specific writer helpers and checks `ABI`. Opaque `TrustedHTML`, `TrustedURL`, `TrustedJS`, and `TrustedCSS` values can be constructed only by conspicuous `Trust*` calls in trusted application code.
|
||||
|
||||
The exact exported v1 symbol, value, method, field, and signature inventory is
|
||||
machine-checked in [`testdata/public-api-v1.txt`](testdata/public-api-v1.txt).
|
||||
|
||||
The runtime has its own `go.mod`, Apache-2.0 license, `COPYRIGHT` record, semantic version, and `sando/vX.Y.Z` tags. It never imports the AGPL compiler. Importing the runtime does not make an application AGPL under the project's terms. Applications may license their own code and generated files under terms they choose to the extent they hold the necessary rights, while redistribution of the runtime remains subject to Apache-2.0.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/importer"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestV1PublicAPIContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fileSet := token.NewFileSet()
|
||||
var files []*ast.File
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(fileSet, entry.Name(), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", entry.Name(), err)
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
configuration := types.Config{Importer: importer.Default()}
|
||||
checked, err := configuration.Check("gamertan.com/sandwich-hime/sando", fileSet, files, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
qualifier := func(pkg *types.Package) string {
|
||||
if pkg == nil || pkg.Path() == checked.Path() {
|
||||
return ""
|
||||
}
|
||||
return pkg.Name()
|
||||
}
|
||||
var actual []string
|
||||
for _, name := range checked.Scope().Names() {
|
||||
if !token.IsExported(name) {
|
||||
continue
|
||||
}
|
||||
object := checked.Scope().Lookup(name)
|
||||
switch object := object.(type) {
|
||||
case *types.Const:
|
||||
actual = append(actual, fmt.Sprintf("const %s = %s", object.Name(), object.Val().ExactString()))
|
||||
case *types.TypeName:
|
||||
named, ok := object.Type().(*types.Named)
|
||||
if !ok {
|
||||
actual = append(actual, types.ObjectString(object, qualifier))
|
||||
break
|
||||
}
|
||||
if structure, ok := named.Underlying().(*types.Struct); ok {
|
||||
var fields []string
|
||||
for index := 0; index < structure.NumFields(); index++ {
|
||||
field := structure.Field(index)
|
||||
if field.Exported() {
|
||||
fields = append(fields, field.Name()+" "+types.TypeString(field.Type(), qualifier))
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
actual = append(actual, "type "+object.Name()+" struct{ /* opaque */ }")
|
||||
} else {
|
||||
actual = append(actual, "type "+object.Name()+" struct{"+strings.Join(fields, "; ")+"}")
|
||||
}
|
||||
} else {
|
||||
actual = append(actual, types.ObjectString(object, qualifier))
|
||||
}
|
||||
default:
|
||||
actual = append(actual, types.ObjectString(object, qualifier))
|
||||
}
|
||||
typeName, ok := object.(*types.TypeName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
named, ok := typeName.Type().(*types.Named)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for index := 0; index < named.NumMethods(); index++ {
|
||||
method := named.Method(index)
|
||||
if method.Exported() {
|
||||
actual = append(actual, types.ObjectString(method, qualifier))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(actual)
|
||||
got := strings.Join(actual, "\n") + "\n"
|
||||
want, err := os.ReadFile("testdata/public-api-v1.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = []byte(strings.TrimPrefix(string(want), "# SPDX-License-Identifier: Apache-2.0\n\n"))
|
||||
if string(want) != got {
|
||||
t.Fatalf("v1 public API drifted\n--- committed contract ---\n%s--- observed API ---\n%s", want, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"html/template"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type benchmarkView struct {
|
||||
Title string
|
||||
URL string
|
||||
Admin bool
|
||||
Items []string
|
||||
}
|
||||
|
||||
var (
|
||||
benchmarkContext = context.Background()
|
||||
benchmarkData = benchmarkView{
|
||||
Title: `A typed <view> & its "output"`,
|
||||
URL: "/projects/sandwich-hime/?from=benchmark&mode=equivalent",
|
||||
Admin: true,
|
||||
Items: []string{"compiler", "runtime", "language server", "editor tooling"},
|
||||
}
|
||||
benchmarkHTMLTemplate = template.Must(template.New("v1-corpus").Parse(`<article data-title="{{.Title}}"><h1>{{.Title}}</h1>{{if .Admin}}<strong>Admin</strong>{{end}}<ul>{{range .Items}}<li>{{.}}</li>{{end}}</ul><a href="{{.URL}}">Open</a></article>`))
|
||||
)
|
||||
|
||||
func benchmarkSandoComponent(view benchmarkView) Component {
|
||||
return ComponentFunc(func(_ context.Context, writer io.Writer) error {
|
||||
if err := WriteString(writer, `<article data-title="`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteAttr(writer, view.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteString(writer, `"><h1>`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteText(writer, view.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteString(writer, `</h1>`); err != nil {
|
||||
return err
|
||||
}
|
||||
if view.Admin {
|
||||
if err := WriteString(writer, `<strong>Admin</strong>`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := WriteString(writer, `<ul>`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range view.Items {
|
||||
if err := WriteString(writer, `<li>`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteText(writer, item); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteString(writer, `</li>`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := WriteString(writer, `</ul><a href="`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteURL(writer, view.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteString(writer, `">Open</a></article>`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBenchmarkCorpusEquivalent(t *testing.T) {
|
||||
t.Parallel()
|
||||
var himeOutput bytes.Buffer
|
||||
if err := Render(benchmarkContext, &himeOutput, benchmarkSandoComponent(benchmarkData)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var standardOutput bytes.Buffer
|
||||
if err := benchmarkHTMLTemplate.Execute(&standardOutput, benchmarkData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if himeOutput.String() != standardOutput.String() {
|
||||
t.Fatalf("benchmark corpus is not output-equivalent\nhtml/template: %q\nSandwich Hime: %q", standardOutput.String(), himeOutput.String())
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkV1CorpusSandwichHime(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(renderedBenchmarkSize(b)))
|
||||
for b.Loop() {
|
||||
if err := Render(benchmarkContext, io.Discard, benchmarkSandoComponent(benchmarkData)); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkV1CorpusHTMLTemplate(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(renderedBenchmarkSize(b)))
|
||||
for b.Loop() {
|
||||
if err := benchmarkHTMLTemplate.Execute(io.Discard, benchmarkData); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderedBenchmarkSize(tb testing.TB) int {
|
||||
tb.Helper()
|
||||
var output bytes.Buffer
|
||||
if err := Render(benchmarkContext, &output, benchmarkSandoComponent(benchmarkData)); err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
return output.Len()
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestHTMLTemplateDifferentialCorpus(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
prefix string
|
||||
suffix string
|
||||
write func(*bytes.Buffer, any) error
|
||||
values []string
|
||||
}{
|
||||
{
|
||||
name: "HTML text", source: `<p>{{.}}</p>`, prefix: `<p>`, suffix: `</p>`,
|
||||
write: func(output *bytes.Buffer, value any) error { return WriteText(output, value) },
|
||||
values: differentialTextValues(),
|
||||
},
|
||||
{
|
||||
name: "quoted attribute", source: `<p title="{{.}}">x</p>`, prefix: `<p title="`, suffix: `">x</p>`,
|
||||
write: func(output *bytes.Buffer, value any) error { return WriteAttr(output, value) },
|
||||
values: differentialTextValues(),
|
||||
},
|
||||
{
|
||||
name: "RCDATA", source: `<textarea>{{.}}</textarea>`, prefix: `<textarea>`, suffix: `</textarea>`,
|
||||
write: func(output *bytes.Buffer, value any) error { return WriteRCDATA(output, value) },
|
||||
values: differentialTextValues(),
|
||||
},
|
||||
{
|
||||
name: "safe URL", source: `<a href="{{.}}">x</a>`, prefix: `<a href="`, suffix: `">x</a>`,
|
||||
write: func(output *bytes.Buffer, value any) error { return WriteURL(output, value) },
|
||||
values: []string{"", "/", "./relative", "?q=a&next=b", "#section", "https://example.test/a?x=1&y=2", "HTTP://example.test/", "mailto:reader@example.test"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed := template.Must(template.New(test.name).Parse(test.source))
|
||||
for _, value := range test.values {
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatalf("html/template value %q: %v", value, err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
output.WriteString(test.prefix)
|
||||
if err := test.write(&output, value); err != nil {
|
||||
t.Fatalf("Sandwich Hime value %q: %v", value, err)
|
||||
}
|
||||
output.WriteString(test.suffix)
|
||||
if output.String() != baseline.String() {
|
||||
t.Fatalf("differential mismatch for %q\nhtml/template: %q\nSandwich Hime: %q", value, baseline.String(), output.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTemplateDifferentialUnsafeURLPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed := template.Must(template.New("url").Parse(`<a href="{{.}}">x</a>`))
|
||||
values := []string{
|
||||
"javascript:alert(1)",
|
||||
" JAVASCRIPT:alert(1) ",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
"unknown:opaque",
|
||||
"java%73cript:alert(1)",
|
||||
}
|
||||
for _, value := range values {
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatalf("html/template value %q: %v", value, err)
|
||||
}
|
||||
if !strings.Contains(baseline.String(), "#ZgotmplZ") {
|
||||
t.Fatalf("html/template did not block corpus URL %q: %q", value, baseline.String())
|
||||
}
|
||||
var output bytes.Buffer
|
||||
err := WriteURL(&output, value)
|
||||
if !errors.Is(err, ErrUnsafeURL) {
|
||||
t.Fatalf("Sandwich Hime accepted corpus URL %q: output=%q err=%v", value, output.String(), err)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("Sandwich Hime wrote bytes before rejecting %q: %q", value, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTemplateDifferentialDocumentedStrictness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invalid UTF-8", func(t *testing.T) {
|
||||
value := "invalid UTF-8: \xff:end"
|
||||
parsed := template.Must(template.New("text").Parse(`<p>{{.}}</p>`))
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if utf8.Valid(baseline.Bytes()) {
|
||||
t.Fatalf("baseline unexpectedly normalized invalid UTF-8: %q", baseline.Bytes())
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := WriteText(&output, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !utf8.Valid(output.Bytes()) || !strings.Contains(output.String(), "\uFFFD") {
|
||||
t.Fatalf("Sandwich Hime did not normalize invalid UTF-8: %q", output.Bytes())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("control in otherwise allowed URL", func(t *testing.T) {
|
||||
value := "https:\n//example.test/"
|
||||
parsed := template.Must(template.New("url").Parse(`<a href="{{.}}">x</a>`))
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(baseline.String()), "%0a") {
|
||||
t.Fatalf("baseline did not visibly encode the control: %q", baseline.String())
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := WriteURL(&output, value); !errors.Is(err, ErrUnsafeURL) {
|
||||
t.Fatalf("Sandwich Hime did not fail closed: output=%q err=%v", output.String(), err)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("Sandwich Hime wrote before rejecting the control: %q", output.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit tel allowlist", func(t *testing.T) {
|
||||
value := "tel:+15555550100"
|
||||
parsed := template.Must(template.New("url").Parse(`<a href="{{.}}">x</a>`))
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(baseline.String(), "#ZgotmplZ") {
|
||||
t.Fatalf("baseline URL policy changed: %q", baseline.String())
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := WriteURL(&output, value); err != nil {
|
||||
t.Fatalf("Sandwich Hime rejected its documented tel scheme: %v", err)
|
||||
}
|
||||
if output.String() != value {
|
||||
t.Fatalf("Sandwich Hime tel output = %q", output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTMLTemplateDifferentialExplicitTrustedHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
value := `<strong data-note="reviewed & trusted">ok</strong>`
|
||||
parsed := template.Must(template.New("trusted HTML").Parse(`<div>{{.}}</div>`))
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, template.HTML(value)); err != nil { // #nosec G203 -- the test is the explicit trust-boundary comparison.
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
output.WriteString("<div>")
|
||||
if err := WriteText(&output, TrustHTML(value)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output.WriteString("</div>")
|
||||
if output.String() != baseline.String() {
|
||||
t.Fatalf("trusted HTML mismatch\nhtml/template: %q\nSandwich Hime: %q", baseline.String(), output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func differentialTextValues() []string {
|
||||
return []string{
|
||||
"",
|
||||
"ordinary text",
|
||||
`<script>alert("x")</script>`,
|
||||
`quotes: "double" and 'single' & ampersand`,
|
||||
"Unicode: 雪 🥪 e\u0301",
|
||||
"NUL:\x00:end",
|
||||
"line separators: \u2028\u2029",
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzWriteURLPolicy(f *testing.F) {
|
||||
for _, seed := range []string{
|
||||
"",
|
||||
"/relative?one=1&two=2",
|
||||
"https://example.test/path",
|
||||
" JAVASCRIPT:alert(1) ",
|
||||
"https:\n//example.test/",
|
||||
"tel:+15555550100",
|
||||
"invalid:\xff",
|
||||
} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, value string) {
|
||||
if len(value) > 64<<10 {
|
||||
t.Skip()
|
||||
}
|
||||
var first, second bytes.Buffer
|
||||
firstErr := WriteURL(&first, value)
|
||||
secondErr := WriteURL(&second, value)
|
||||
if first.String() != second.String() || fmt.Sprint(firstErr) != fmt.Sprint(secondErr) {
|
||||
t.Fatal("URL policy was not deterministic")
|
||||
}
|
||||
if firstErr != nil {
|
||||
if !errors.Is(firstErr, ErrUnsafeURL) || first.Len() != 0 {
|
||||
t.Fatalf("URL rejection was not fail-closed: output=%q err=%v", first.String(), firstErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !utf8.ValidString(first.String()) || strings.ContainsAny(first.String(), "\x00\r\n") {
|
||||
t.Fatalf("accepted URL output is not valid single-line UTF-8: %q", first.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
const ABI = "sando.v1"
|
||||
const ABISandoV1 = "sando.v1"
|
||||
const RuntimeABI = "sando.v1"
|
||||
func (*URLSafetyError).Error() string
|
||||
func (*URLSafetyError).Unwrap() error
|
||||
func (ComponentFunc).Render(ctx context.Context, w io.Writer) error
|
||||
func Render(ctx context.Context, w io.Writer, component Component) error
|
||||
func TrustCSS(value string) TrustedCSS
|
||||
func TrustHTML(value string) TrustedHTML
|
||||
func TrustJS(value string) TrustedJS
|
||||
func TrustURL(value string) TrustedURL
|
||||
func WriteAttr(w io.Writer, value any) error
|
||||
func WriteCSS(w io.Writer, value TrustedCSS) error
|
||||
func WriteHTML(w io.Writer, value TrustedHTML) error
|
||||
func WriteJS(w io.Writer, value TrustedJS) error
|
||||
func WriteRCDATA(w io.Writer, value any) error
|
||||
func WriteString(w io.Writer, value string) error
|
||||
func WriteText(w io.Writer, value any) error
|
||||
func WriteURL(w io.Writer, value any) error
|
||||
type Component interface{Render(context.Context, io.Writer) error}
|
||||
type ComponentFunc func(context.Context, io.Writer) error
|
||||
type TrustedCSS struct{ /* opaque */ }
|
||||
type TrustedHTML struct{ /* opaque */ }
|
||||
type TrustedJS struct{ /* opaque */ }
|
||||
type TrustedURL struct{ /* opaque */ }
|
||||
type URLSafetyError struct{Scheme string; Reason string}
|
||||
var ErrNilComponent error
|
||||
var ErrNilContext error
|
||||
var ErrNilWriter error
|
||||
var ErrUnsafeURL error
|
||||
Reference in New Issue
Block a user