security: publish hardened v1 initiative snapshot

Publish the reviewed security policy and evidence, exact runtime ABI enforcement, orphan-output and permission safeguards, dead-upstream cleanup, and the evidence-gated v1 launch plan.

This commit is an exact sanitized export from the private development record. Material implementation and review were assisted by OpenAI Codex; Cole Speelman reviewed the changes and accepts human responsibility.

Himesan-Output-Permission: v1.0
Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
2026-08-12 03:54:46 -04:00
parent 4166a66c66
commit 113c95c21e
22 changed files with 1028 additions and 73 deletions
+1 -1
View File
@@ -1 +1 @@
{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"release","file_count":77,"allowlist_sha256":"db978285858ba5a1fefeb732d716338d8c652c5fc583f465d08f80b9ec74f0a9","manifest_sha256":"5abb8eaf376ec390da5b1b5d811ec9aa685bf9e47e54086a51d049193738a49b"}
{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"release","file_count":80,"allowlist_sha256":"e40a56b3431efdd99b3a78c38c387722de347216640383fc849569a817edf5c6","manifest_sha256":"0b27ea55bc1f2083ac07ae777d20735e651c5026888b973bfe02ee764b9487dc"}
+20 -17
View File
@@ -15,8 +15,8 @@ b6aa08e5ccaec3c5dccdc19d7cd7f54a70adae4d57966263c7aa353c7ba70e08 ./MAINTAINERS.
6638db2f1fba831c79de835ce95c847a5b36c5b5c693b99a28655b2d096cc440 ./OUTPUT_EXCEPTION.md
327386b40ee9fb92a8568b6a51722578890393fa23051af632f3180385a4e739 ./README.md
ce32945cf5f16ab1a0202615bcf2053f46d421dcdd8ff2f1292f95bb5cf4493d ./RELEASE.md
29eebbdfcff05d4ba709bf13d45052c6994767303ddb470031620c9987bca53a ./ROADMAP.md
2c86f5b983dfeb97a02d46850fa42e18cab1ed23201822aa3c344b9d2e1b0c3f ./SECURITY.md
419c334aeb20dc22ceba8d031aaa95314b77125bf3267fd9fdc003e3865f0327 ./ROADMAP.md
d70d89db6bf0142a42a95f45537be5a4562258646d36d56bd9a70096ec78ed91 ./SECURITY.md
53bd6eda804d6b782bdb07115ec197c890813cf2d5d0125dfe8f47f5f92f75b0 ./SPEC.md
842beff8afa72d120fcad0ac73afb2049d580ff3000975f3b1786c4ade6a14d4 ./TRADEMARKS.md
136a6d82db842547b342f8b0c9ffdc7c04f7c9b473b4ef6dca9dbc940cb24b54 ./cmd/himesan/main.go
@@ -25,24 +25,27 @@ ce32945cf5f16ab1a0202615bcf2053f46d421dcdd8ff2f1292f95bb5cf4493d ./RELEASE.md
9c598559a89fa4a9bdd2311bd1ed8330992d0a0f74ec8b29ac151fc0ff8fef16 ./docs/BENCHMARKS.md
5c3a62fed80ca28d56558b8c75e8b5be8ba7d2554127adf4609d96da314e85b0 ./docs/BRAND.md
35f5b4b7d195a7b5c071d4665505afef189c7b386d4e3079e9ce8a96ace07f3a ./docs/COMPATIBILITY.md
e4021b554ebc479954321586012add57a5fbfb58a1f7fce001d5638880912fc6 ./docs/DEVELOPMENT_SERVER.md
5f4ac209a16ab110baeaa64a40c19d9239c903e17550c3f05e1e1473ddcc33a3 ./docs/DEVELOPMENT_SERVER.md
51aa57a81131b64f76c45552122de842f22be92d81c8bba8f6fd38a18a7670d6 ./docs/DIAGNOSTICS.md
f1a8e78c5aa521324ad2fcb386512158d0c0f9956e97f9a1bc8f97aa5d5e9844 ./docs/THREAT_MODEL.md
04c6b3f93588177a87edbca8f56af0e0f2a7c5ba31936f3174570c8282a1a7c2 ./docs/SECURITY_EVIDENCE.md
d969c7b5486ee93e54232fd69d9db06f3b4dc1bba63001596ec48545073c2680 ./docs/THREAT_MODEL.md
76ac444771ac0a4f584ee0cf86ebfd34233412e6e511485f6cc90131a9a50387 ./docs/V1_RELEASE_PLAN.md
f27c46ca63707bb8cc570eab1ea521824e94bc59b1d153998a5e91c2c7340d16 ./go.mod
07d161772e9c6eec0dcc12179286e5c686dcabdc4e56a7cb8d112f640b072563 ./internal/compiler/backend.go
28123757d27298dd81cf13ebd9242b24556734a35e36c2ac731f2a8475d70d28 ./internal/compiler/compiler_test.go
d99ba263bf501ca81ed38ba88216c063d2fc22f4b45a3f28d5105957f449c4de ./internal/compiler/context.go
ca0bf5051d356d2602f46201fb1637ce48b629ad42161877eec13f743f215dc5 ./internal/compiler/abi_test.go
0a624f76214afbed561a6f6490405c5083e49a53ae301c6ede763b78407ec0c7 ./internal/compiler/backend.go
7c96a4b31a34201cb9c48a7f0bac1c201865e4ecc7c080691af5ce68b3d7c207 ./internal/compiler/compiler_test.go
dbba23e360bd6dd1e8f42953a49a7cfcc241aa3ac76f5ce505ec8f8558833c84 ./internal/compiler/context.go
b2a96ef1ad572ad9cd0e9247328ca261de6f9f3689da41e3f3e111d405a6dee6 ./internal/compiler/diagnostic.go
42ccf512381e130bf593b065dccd7697560fb00240818efde6321c9095f6b4a6 ./internal/compiler/discover.go
45562a41ef9ab1116746e4962ce8f93d4d8651e1e468a38122c626f8a34a2874 ./internal/compiler/discover.go
f5a6b31416027cb69a61d1a1421cba779ec3accae59c9ba9dd45d2c31b72149e ./internal/compiler/e2e_test.go
eefb05a35bd07660a293c8af97949cd6f69a22709728f3fe2cc9132b863b7d5a ./internal/compiler/fuzz_test.go
4c1625114f92f9cc097c2fb1394fa0e4d43a03156f3be0aa537a485ec8243a57 ./internal/compiler/model.go
00180df94e3c73e1614eeae387ef00c3cec90e9f64b45a5a148c79408e2d10c2 ./internal/compiler/operation.go
d166096f185d76b2698aa3ab3251f00e58f84cdedc3af667e88ddd528ca0cb76 ./internal/compiler/model.go
0c7a7a4d6a51a8b58dfe7c12ecd8c608aff639fd6157a9657a0663ceb58c3c8c ./internal/compiler/operation.go
d7d8181455d5f37ef9bcc6bdbf86e0630f20e8a5b3b81688d12742687b434c99 ./internal/compiler/parse.go
80cf170514a3b955d24440cb086d34e19f3a305510e3c5db95cb897be91f922a ./internal/compiler/replace_unix.go
0fff1c67447bf5353ed1df6e7dfc4b14581b67adc1bf02f7a4a7c1f2680c392f ./internal/compiler/replace_windows.go
f4ba01010ed5f5ba1e979702d82e95312bc0a4b13cc205c098926839be4ecb73 ./internal/compiler/testdata/golden/basic.sando
b190a6a8aed288378ea13d12ec06bac68890c473c03c60016f7fa7534f142008 ./internal/compiler/testdata/golden/basic.sando.go
63fa75a3049a3a8a12d769d7f9b6b510dfe763baacf706775b75cef2c57a984f ./internal/compiler/testdata/golden/basic.sando.go
eafbe9f7d8abb8fa792ec9e01f56655f9ec9d67279ffaac66d6035f9b2bfc404 ./internal/devserver/config.go
99807040a870dd38ad1e04ae179243316778f94a41feb5d2c3076d463f52f9fe ./internal/devserver/config_test.go
eddac51aecaac99bd11cfcf98f8a47cec5d51672efedad75d6f2a862c5d57fc1 ./internal/devserver/events.go
@@ -52,10 +55,10 @@ eddac51aecaac99bd11cfcf98f8a47cec5d51672efedad75d6f2a862c5d57fc1 ./internal/dev
c0f76ef5c14b0a28ed1e68d8d518102ffcbf067285b087eed4d13cd3c87b0e00 ./internal/devserver/process_windows.go
6fbbe08813385ed43a9377b3772260e577dbd9f742f7a9ed5140a02f4c7991a4 ./internal/devserver/process_windows_32.go
1dae73304532faac4aa8cedda5df65c6d199aaafc001708cc96529c07dee0588 ./internal/devserver/process_windows_64.go
a9b7649562f39a707214dc4a67c2353bd29b2df7fce7a05cac9a6451d1a0fcca ./internal/devserver/proxy.go
54f0fea40c0a19d268dcc33cab35d2c7d12f50134cd73ce9a609288e356f9fa8 ./internal/devserver/proxy_test.go
3d85066927da7e88ccb0a05afa261e06568c007711dbd9fd0219569aff59f568 ./internal/devserver/supervisor.go
6c53d6d10eaa36d9286471e21c61a9e80d858fa0ccfd47be0e72d1838ee440ea ./internal/devserver/supervisor_test.go
7f1efbefea3a277f0f4d96a29219293efd78d9dc44823c09b9667b19d5042047 ./internal/devserver/proxy.go
aebf8388576d7bc9b047ceedf8a893acb3ace5fe16f44cb883efe63eef072ef9 ./internal/devserver/proxy_test.go
e6561e693138a3b77be06c1a98999e71494bbca0d0c72ccb9bff57b8e8575c0f ./internal/devserver/supervisor.go
b94103cd4b582968cdb0b61b0164f57ade006fa4e5187fcaa05944274192526a ./internal/devserver/supervisor_test.go
e0a682c0153bf4f2a1f26cc6095d7893ad96e6199cbe76d0150785fc996f1141 ./internal/devserver/watch.go
b7a7fabf9a6c497f7ac2262628c5fb37a6bd00da676e1b7d5088d5f649c9f14c ./internal/devserver/watch_test.go
d8c6f37c94ef426fc2d95c82331265f7d700d2e2a23100ad78c92849280ff6d8 ./internal/version/version.go
@@ -63,8 +66,8 @@ d8c6f37c94ef426fc2d95c82331265f7d700d2e2a23100ad78c92849280ff6d8 ./internal/ver
e8a3026ec920d7312f843e2001e50ae4e34fd1ba5f9b2ae25a6113de1fa88385 ./sando/COPYRIGHT
c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 ./sando/LICENSE
b4a7bffe678a97209881e07989563a5085aa0ead9e1b67306087dac6b97bad70 ./sando/README.md
a8131a53016401fe8cc3f1be660983c79bb8d267fabef262aca8c55667908279 ./sando/component.go
ff905eacdef265e8ea04a8e462656f37b5f8ccd579b604b917143b0b1a7e5d6e ./sando/component_test.go
7ec3fe73755a385e0950b9fbf833dd4b6a753a769ab743e97b9d94e77370a32c ./sando/component.go
a242fd3bebb9cb8786c92651950999c6a2575d0be9602bac562e0b63c9ded015 ./sando/component_test.go
ff76daee5b642ad84af31701833246d68b54d09580192312d750a7a2e893a692 ./sando/go.mod
80ff53787919e809b8085d6ad9c3e183c9c7c1d74cfeda73369ac5c4607c236f ./sando/trust.go
85621a44c730582f4410ac2c70418b739fb55e916f7e6b73a1a619982c459572 ./sando/write.go
+4
View File
@@ -4,6 +4,10 @@
Unchecked items are release blockers, not aspirational marketing.
The ordered initiative, repository topology, release-candidate sequence, and
definition of confidence are maintained in
[docs/V1_RELEASE_PLAN.md](docs/V1_RELEASE_PLAN.md).
## Compiler and runtime
- [ ] Compiler-owned deterministic golden output repeated across Linux, macOS, and Windows.
+117 -5
View File
@@ -2,12 +2,124 @@
# Security policy
Sandwich Hime is an unsupported public pre-1.0 source preview. No version is yet supported for production use, and the project makes no vulnerability-response SLA or bug-bounty promise.
Sandwich Hime is a public pre-1.0 source preview. Security reports are welcome
now, even though no version is currently designated as supported for production
use. The project would rather receive a careful early report than project
confidence it has not earned.
Do not put undisclosed vulnerability details, credentials, personal data, or a working exploit in a public issue. Until a dedicated confidential address is published, use the repository owner's published Gitea contact method to ask for a private channel without disclosing the issue. If no private contact method is available, retain the details rather than publishing them. A tested confidential contact and documented response targets remain blockers for a supported release.
## Supported versions
The compiler treats templates and embedded Go as trusted source and rendered values as untrusted data. It does not sandbox template authors. The security boundary and known non-goals are specified in [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md).
| Version | Security status |
| --- | --- |
| Public `main` source preview | Best-effort assessment and fixes; interfaces may change |
| Versioned releases | None published yet |
For a future supported release, the intended process includes a private reproducer, regression test, coordinated disclosure when appropriate, checksums, and an advisory. Release artifacts and tags must be signed. Dependencies are minimized and scanned; generation/checking never fetch dependencies or execute project code.
This table will name supported release lines once immutable compiler and runtime
versions are published. A pre-1.0 release is not a promise of API stability or
fitness for a particular application.
This policy describes the project's current process and limitations; it is not legal advice and does not promise that every report can be accepted, embargoed, or fixed on a particular schedule.
## Report a vulnerability privately
Email **security@sandwichhime.com**. Please do not put an undisclosed
vulnerability, working exploit, credential, secret, or personal data in a
public issue.
Helpful reports include:
- the affected compiler/runtime version or exact commit;
- the relevant `.sando` source, generated Go, or development configuration;
- a minimal reproducer and the observed security impact;
- operating system, architecture, Go version, and browser when relevant;
- whether the issue is already public or has a disclosure deadline; and
- a safe way to credit the reporter, or a request to remain anonymous.
Minimize sensitive data. The mailbox is the private reporting route, but
ordinary email is not end-to-end encrypted. Do not send production secrets or
unnecessary personal data. An encryption key will be published only after its
ownership, backup, and recovery procedure have been tested.
## What to expect
These are best-effort targets for a founder-maintained project, not an SLA:
- acknowledge a report within 7 calendar days;
- provide an initial severity/scope assessment within 14 calendar days when a
reproducible issue is available; and
- provide an update at least every 30 calendar days while an accepted report
remains unresolved.
Health, disability, family responsibility, incomplete evidence, or incident
complexity may make those targets impossible. If that happens, the maintainer
will communicate the delay when safely able rather than inventing certainty.
For a reproducible accepted vulnerability, the project aims to retain a private
reproducer where safe, add a regression test where practical, document affected
versions, and agree on a coordinated disclosure plan when appropriate. A fix
may be delivered through a new immutable version, a retraction, an advisory, or
documentation that narrows an incorrect guarantee. Published tags will not be
moved or silently replaced.
## Scope and trust boundary
The most useful reports concern:
- contextual escaping or browser-parser disagreements;
- unsafe URL acceptance or trusted-value boundary confusion;
- parser, generator, path, symlink, ownership, or atomic-write failures;
- generated-code/runtime ABI mismatches;
- deterministic-output or source-provenance failures;
- development proxy exposure, request-origin controls, process cleanup, or
unintended execution; and
- dependency, release, signing, checksum, or artifact-integrity problems.
Templates and embedded Go are trusted application source. Sandwich Hime is not
a sandbox for an untrusted template author. Handwritten Go implementations of
`sando.Component` and explicit `sando.Trust*` calls are trusted output
capabilities. Application routing, authorization, HTTP headers, database
security, deployment, and production process isolation remain application
responsibilities unless a defect originates in Sandwich Hime itself.
The complete boundary and known non-goals are maintained in
[the threat model](docs/THREAT_MODEL.md). Reproducible assessment results and
open gaps are recorded separately in
[the security evidence ledger](docs/SECURITY_EVIDENCE.md).
## Good-faith research
Good-faith research means making a reasonable effort to:
- test only systems, repositories, and data you own or are authorized to test;
- prefer local reproductions and the smallest proof necessary;
- stop if testing risks availability, privacy, data integrity, or another
person's account;
- avoid persistence, destructive changes, social engineering, spam, denial of
service, credential collection, and unnecessary data access;
- retain and transmit the minimum sensitive information required; and
- allow reasonable time for investigation before public disclosure.
This policy permits research on local copies of the source. It does not
authorize active testing of project-operated websites, Gitea infrastructure,
or third-party deployments without separate written permission. Passively
observed issues are welcome. It does not create a bug bounty, safe-harbor
contract, embargo obligation, or promise that every report can be accepted.
The project will not pursue action against research that the maintainer
reasonably believes followed this policy in good faith, but cannot bind third
parties or override applicable law. When uncertain, contact the security
mailbox before testing.
## Current assurance level
The code has maintainer-led threat modeling, adversarial unit and integration
tests, race testing, bounded fuzz smoke tests, static analysis, dependency
inventory, and known-vulnerability scanning. The evidence ledger records those
maintainer-run checks against named commits and dates; its results are
point-in-time evidence, not continuous assurance. The project has not received
an independent security audit, certification, or formal verification. Coverage
percentages, passing scanners, and a clean vulnerability database result are
evidence of specific checks—not proof that no vulnerability exists.
Release artifacts and tags are intended to carry signatures, checksums, an
SBOM, and exact source/build provenance. Those controls are publication gates
until the first versioned release is actually available.
This policy is practical project guidance, not legal advice.
+4
View File
@@ -31,4 +31,8 @@ The stable proxy reserves `/__himesan/events` for SSE. It injects a fixed reload
When an existing CSP is present, the proxy adds the fixed script's SHA-256 source and same-origin SSE connection permission; it does not add `unsafe-inline` or `unsafe-eval`. The proxy and every candidate upstream are literal loopback addresses. Replaced process groups are terminated and waited for on Unix and Windows.
If the active application exits, the proxy immediately forgets that exact upstream and closes its idle connections. Requests receive the waiting page until another candidate passes its health check; a different process that later acquires the old loopback port is not selected implicitly.
Loopback is host-local, not user-local. Host, Origin, and Fetch Metadata checks defend against browser cross-site and DNS-rebinding requests, but they are not authentication against another process or account on the same workstation. Run `himesan dev` only on a trusted, single-user development machine and do not place secrets in its diagnostics. It invokes the configured Go toolchain, may fetch dependencies according to the user's Go environment, executes the project binary with the user's inherited environment, and forwards the application's requests and responses. Eligible HTML responses may be buffered up to 16 MiB for reload injection; application request, response, and SSE concurrency limits remain the application's and operating system's responsibility.
This is not a production proxy, TLS terminator, public preview server, process orchestrator, or deployment system. V1 refuses non-loopback binding.
+163
View File
@@ -0,0 +1,163 @@
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# Security evidence ledger
This ledger records what was actually inspected and executed. It is a
maintainer-led self-assessment, not an independent audit, certification, formal
verification, or guarantee that no vulnerability exists.
## Assessment identity
| Field | Value |
| --- | --- |
| Assessment date | 2026-08-12 |
| Public evidence identity | Exact file checksums in the co-published `PUBLIC-SNAPSHOT.sha256`; private/public commit mapping is retained only in the non-exported operational ledger |
| Assessment phases | Clean pre-remediation source followed by clean remediated source |
| Primary environment | Linux amd64 under WSL, Go 1.26.5 |
| Declared minimum Go | Go 1.25 |
| Assessor | Project maintainer with AI-assisted code review; human responsibility retained |
Security remediation discovered during this assessment was committed and the
named checks were rerun from a clean source state. Before this ledger can support
a versioned release, the complete campaign must be rerun from the exact
sanitized public release commit. Private-to-public commit mappings are retained
outside the exported source rather than being disclosed here.
## Observed evidence
| Property examined | Enforcement or test surface | Result observed on 2026-08-12 |
| --- | --- | --- |
| Root correctness | `go test -count=1 ./...` | Pass |
| Concurrent access | `go test -race -count=1 ./...` | Pass |
| Runtime concurrency | `(cd sando && go test -race -count=1 ./...)` | Pass |
| Standard static analysis | `go vet ./...` and runtime equivalent | Pass |
| Reachable known vulnerabilities | `govulncheck@v1.6.0` on both modules | No vulnerabilities found on 2026-08-12 |
| Dependency surface | `go list -m -json all` in both modules | Zero third-party module requirements |
| Statement coverage | Go cover profiles on remediated source | compiler 75.2%; devserver 80.2%; runtime 96.6% |
| Parser robustness smoke | Two bounded Go fuzz targets | Pass; no panic found |
| Deterministic generation | repeated generate/check/hash/mtime gates | Pass |
| Writer failures | runtime error/short-write/nil-writer tests | Pass |
| HTML text/attribute/RCDATA escaping | compiler and runtime adversarial cases | Pass for enumerated cases |
| URL scheme handling | ordinary/trusted URL test matrix | Pass for enumerated cases |
| Filesystem boundaries | symlink, nested-module, VCS, ownership, stale-output tests | Pass for tested cases; see open findings |
| Development proxy browser boundary | Host, Origin, Fetch Metadata, CSP, fragment and response tests | Pass for tested cases |
| Native platform behavior | Linux execution; Windows/macOS cross-compilation | Native Windows/macOS execution not yet evidenced |
Coverage measures statements executed by tests. It is not branch completeness
and is not evidence that the executed behavior is secure.
`govulncheck` reports vulnerabilities known to the Go vulnerability database
and reachable through its analysis. A clean result cannot detect unknown flaws,
design errors, or vulnerabilities outside its model.
## Security-relevant design evidence
### Production boundary
The production `sando` module contains rendering contracts and contextual write
helpers. It contains no HTTP server, router, middleware, template discovery,
development proxy, plugin loader, or production process manager. Both compiler
and runtime modules currently have no third-party Go module requirements.
### Compiler behavior
Compilation builds and formats outputs in memory before generation writes.
Recursive discovery rejects or skips observed symlinks, nested modules, VCS
trees, vendor trees, and detected filesystem crossings. Existing non-owned,
symlink, and non-regular output files are rejected. Each changed file uses an
atomic replacement primitive; the whole set is not a filesystem transaction if
a later replacement fails.
`generate` and `check` do not run project code, invoke the Go toolchain, fetch
dependencies, or edit module metadata. `himesan dev` is intentionally separate:
it builds and executes trusted project code and may fetch modules under the
user's normal Go configuration.
### Output contexts
The compiler accepts dynamic values only in its enumerated contexts. It rejects
dynamic markup construction, unquoted attributes, event-handler values,
dynamic style attributes, unsupported URL lists, foreign content, meta refresh,
malformed tags, and unbalanced generated components. Runtime helpers escape
ordinary text/attributes, validate ordinary whole-URL schemes before writing,
and escape every trusted wrapper in RCDATA.
Script and style output require opaque trusted types. Those types deliberately
move responsibility to trusted application code; they are not sanitizers.
## Reproduction commands
Run from a clean canonical checkout. Networked scans contact the Go module proxy
and vulnerability database.
```sh
go version
git status --short
git rev-parse HEAD^{commit} HEAD^{tree}
./scripts/check-licenses.sh
HIMESAN_RACE=1 ./scripts/verify.sh
go test -count=1 -cover ./...
(cd sando && go test -count=1 -cover ./...)
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
(cd sando && go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...)
go test ./internal/compiler -run '^$' \
-fuzz '^FuzzCompileNeverPanics$' -fuzztime=20s
go test ./internal/compiler -run '^$' \
-fuzz '^FuzzGoDelimiterNeverPanics$' -fuzztime=20s
```
The fuzz targets currently assert process robustness and result bounds. They do
not yet prove semantic HTML safety.
## Assessment findings and remediation status
The 2026-08-12 assessment identified six concrete gaps. Their current
working-tree status is recorded here without rewriting the original baseline:
| Finding | Current remediation | Executable evidence |
| --- | --- | --- |
| Generated code named an ABI but did not enforce the exact contract | Generated code now requires the version-specific `sando.ABISandoV1` symbol | `TestGeneratedCodeRequiresVersionedRuntimeABIMarker`; `TestRuntimeABIMarker` |
| Deleted or renamed sources could leave owned `.sando.go` orphans invisible to directory-level `check` | Directory discovery now reports owned outputs whose adjacent source is absent or non-regular and blocks the operation before writes | `TestDirectoryOperationsRejectOrphanedOwnedOutputBeforeWrites` |
| Component-context prose included arbitrary handwritten implementations in the generated-component guarantee | Runtime documentation, policy, and threat model now classify handwritten components as trusted output capabilities | API documentation plus policy review; generated-balance tests retain their narrower scope |
| An exited development child left its former upstream selected | Exit notification now clears only the matching active target immediately, independent of the watcher poll interval | `TestClearTargetOnlyClearsSelectedUpstream`; `TestSupervisorClearsTargetWhenCurrentApplicationExits` |
| Trusted-value warnings were described more broadly than their analysis supports | Policy and threat-model copy now call them best-effort lexical audit hints rather than type or taint analysis | Documentation assertion and review |
| Public copy implied a completed systematic `html/template` differential campaign | Policy and public security copy now describe fixed adversarial cases and list systematic differential work as open | Documentation assertion and review |
The remediated clean source passed the race-enabled repository verifier,
sanitized-snapshot tests, both bounded fuzz-smoke targets, compiler/runtime
known-vulnerability scans, and Windows/macOS cross-compilation on 2026-08-12.
Those results do not become release evidence until the changes are committed,
exported to the sanitized canonical public tree, and re-run from that exact
public commit. Native Windows/macOS execution and the other gaps below remain
separate release decisions.
## Open assurance gaps
- confidential mailbox delivery and response/recovery procedure must be tested;
- release signing, checksum, SBOM, and provenance rehearsal is incomplete;
- native Windows/macOS execution remains outstanding;
- browser-parser differential and semantic property testing need expansion;
- compiler input size, CPU, and memory have no built-in hard budget;
- filesystem checks do not defend against a hostile local actor racing path
components between inspection and use;
- the watcher is a convenience mechanism, not a filesystem-integrity monitor;
- human-readable diagnostics can include hostile local filenames or child-tool
output and should not be treated as a sanitized log protocol;
- development CSP rewriting is convenience, not production CSP validation;
- deliberately detached child descendants may evade process-tree cleanup;
- rendering has no built-in recursion, output-size, allocation, CPU, panic, or
deadline enforcement;
- static cycle detection and trust-use warnings are best-effort analyses; and
- the project has no independent security audit or bug-bounty program.
## Interpreting this ledger
“Pass” means the named command or case produced its expected result in the named
environment on the assessment date. It does not mean “secure.” Confidence comes
from keeping the boundary small, making risky capabilities explicit, preserving
ordinary generated Go for review, publishing reproducible tests, recording
failures, and correcting claims when evidence is weaker than the prose.
+167 -22
View File
@@ -2,38 +2,183 @@
# Threat model
## Trusted
This document separates demonstrated behavior from intended release work. It
defines the boundary Sandwich Hime can reasonably defend; it is not a claim
that the project or an application using it is universally secure.
- `.sando` files and embedded Go statements;
- handwritten application Go;
- explicit calls to `sando.TrustHTML`, `TrustURL`, `TrustJS`, and `TrustCSS`;
- the selected compiler binary and runtime module version.
## Security objective
## Untrusted
For supported HTML contexts, data supplied to a compiler-generated component
should remain data. It must not change HTML structure, create executable code,
escape a quoted attribute, or introduce a disallowed URL scheme unless trusted
application source makes an explicit security-sensitive decision.
- values supplied to components unless deliberately wrapped in a trusted type;
That objective follows the same high-level model documented by Go's
`html/template`: template authors are trusted while rendered data is not. The
implementations and accepted languages differ. A systematic differential test
campaign against `html/template` remains open work; current tests cover fixed
adversarial cases and do not establish equivalence.
## Trusted capabilities
- `.sando` source, including its static markup and embedded Go statements;
- handwritten application Go and values whose formatting methods execute Go;
- handwritten implementations of `sando.Component`;
- explicit `sando.TrustHTML`, `TrustURL`, `TrustJS`, and `TrustCSS` calls;
- the selected compiler binary, Go toolchain, runtime module, and generated Go;
- local project code built and executed by `himesan dev`; and
- the user account, filesystem, environment, and other processes on the
development workstation.
Template semantics become trusted source when built into an application;
templates are not an untrusted-content sandbox. Someone allowed to edit one can
execute ordinary Go through the application build and must receive the same
trust as any other code contributor. Arbitrary or malformed template bytes
remain adversarial input to compiler robustness while they are being inspected.
A handwritten `sando.Component` is a trusted output capability. It may write
arbitrary bytes, change HTML parser context, recurse, block, panic, or perform
side effects. Hime-generated components are independently checked for balanced
HTML and may be inserted with `<?~` only at an HTML content boundary. The open
Go interface does not extend that proof to handwritten implementations.
The `Trust*` constructors do not sanitize. They record that trusted application
code accepts responsibility for the supplied bytes. `himesan check` emits
best-effort lexical audit hints for direct constructor/type use visible inside
`.sando` source; it is not Go type analysis, taint analysis, or a complete
inventory of trust created transitively in handwritten Go.
## Untrusted inputs
- ordinary values supplied to generated components;
- filenames and directory entries encountered during discovery;
- stale or manually modified generated output;
- missing, stale, or manually modified generated output;
- browser requests reaching the development proxy;
- child process output and health failures.
- child-process output, exit behavior, and health failures; and
- malformed template bytes from a repository being inspected, provided the
template is not subsequently built and executed as trusted Go.
## Guarantees sought by v1
## Demonstrated controls
- Context-sensitive escaping for supported HTML text, quoted attributes, URL attributes, and explicitly trusted script/style values.
- Compilation failure for unsupported or ambiguous output contexts.
- Dangerous normalized URL schemes fail rendering unless explicitly trusted.
- Component calls cannot change the surrounding HTML parser context.
- Dynamic `title` and `textarea` content uses a distinct RCDATA writer that escapes even `TrustedHTML`; trusted HTML cannot close those elements.
- Writer failures propagate and partial output is visible to the caller as an error; applications can buffer when atomic responses matter.
- Generation plans all outputs before atomic replacement, targets only owned files, preserves last-good output on failure, and follows neither symlinks nor nested-module traversal.
- `generate` and `check` do not execute project code, invoke Go tooling, fetch dependencies, or alter `go.mod`.
These are point-in-time implementation and test observations indexed to named
commits in the evidence ledger, not continuous assurance.
- Untrusted dynamic output is accepted only in supported HTML text, quoted
attribute, URL, and RCDATA contexts. Script and style interpolation requires
an explicit `TrustedJS` or `TrustedCSS` capability.
- Dynamic tag names, attribute names, unquoted values, event-handler values,
style attributes, foreign SVG/MathML content, URL lists, `srcdoc`, meta
refresh, malformed HTML, and ambiguous parser states are rejected.
- Ordinary text and quoted attributes are escaped; `title` and `textarea` use
an RCDATA writer that escapes every trusted wrapper as ordinary text.
- Ordinary whole URL values are normalized and checked before any bytes are
written. Schemes outside `http`, `https`, `mailto`, and `tel` fail unless a
`TrustedURL` deliberately bypasses that check.
- Writer errors and contract-violating short writes propagate to the caller.
- All sources in one operation are parsed/context-checked/formatted in memory
before the first output change. Each changed output is replaced atomically,
and existing destinations lacking the generated-file ownership marker, plus
symlink or non-regular destinations, are rejected. The marker prevents
accidents; it is not authentication against a hostile local actor.
- Recursive discovery skips VCS, vendor, symlink, nested-module, and detected
filesystem boundaries. Explicit files are still subject to no-symlink and
regular-file checks.
- `generate` and `check` do not invoke the Go toolchain, fetch dependencies,
execute project code, or edit `go.mod`. `himesan dev` is a separate command
that intentionally does all of generate, build, and execute trusted project
code.
- The development proxy binds to a literal loopback address, validates Host
authority, and checks Origin and Sec-Fetch-Site when those headers are
present. These checks are hardening, not a user-authentication boundary. Its
injected client is fixed and authorized with a hash rather than
`unsafe-inline`.
Executable tests and their latest maintainer-observed results are indexed in
[SECURITY_EVIDENCE.md](SECURITY_EVIDENCE.md).
## Important limits
### URLs
URL checking prevents disallowed or ambiguous schemes; it does not decide
whether a destination is authorized or trustworthy. `https:` and relative URLs
can still leave an origin, submit data, change a document base, or load active
content depending on the element and attribute. Applications must validate
destinations and apply tighter policy for sensitive sinks.
### Trusted raw values
`TrustedHTML`, `TrustedJS`, and `TrustedCSS` are intentional escape hatches.
Their authors must preserve the surrounding HTML parser state, including
container-closing and legacy parser-transition sequences. Prefer ordinary data,
keep trust conversion beside its validator, and review each use manually.
### Rendering resources and failures
The runtime does not impose output-size, recursion, CPU, allocation, or time
limits; recover panics; or make an arbitrary `io.Writer` transactional.
Applications should render into a buffer when an all-or-error HTTP body matters
and should apply their own request deadlines, bounded writers, input limits, and
panic policy. A context is passed through components, but generated output does
not automatically stop between writes when it is canceled.
The compiler likewise has no hard source-size, memory, or compile-time budget.
Run it only against repositories whose resource use the caller is willing to
accept.
### Filesystem concurrency
Discovery and generation defend against symlinks and non-regular files observed
at their checks. They are not currently a security boundary against a hostile
local actor racing path components between inspection and use. Run the compiler
inside a trusted workspace and user account. Atomic replacement describes each
file's visibility; a multi-file generation is not a filesystem transaction if
a later replacement fails.
The watcher is a development convenience, not a filesystem-integrity monitor.
It may miss adversarial changes engineered to preserve the metadata it samples.
### Development supervisor
Loopback is host-local, not user-local. The development supervisor has no user
authentication boundary against another process/account on the workstation. It
builds and executes project code with the user's inherited environment and may
fetch dependencies according to the user's Go configuration. It is not a
production proxy, public preview host, TLS terminator, deployment system, or
safe runner for untrusted repositories.
The watcher scans configured trees and eligible HTML responses may be buffered
up to 16 MiB for reload injection. The application and operating system retain
responsibility for request, response, SSE, file-count, and process resource
limits.
Development CSP rewriting is reload convenience, not production CSP
validation. Process cleanup is best-effort; a deliberately detached descendant
may outlive the process tree the supervisor can identify. Human-readable
diagnostics may also contain filenames or child-tool output supplied by a local
repository, so terminals and log consumers remain part of the trusted
development environment.
## Non-goals
Templates are not a sandbox. A malicious template author can write malicious Go in a statement tag. Sandwich Hime does not validate business authorization, prevent unsafe application logic, make an arbitrary `io.Writer` transactional, or secure an application router/server. Trusted constructors are intentionally sharp tools and must remain conspicuous in review and `himesan check` reporting. A `TrustedHTML` fragment must be balanced and context-neutral; `TrustedJS` and `TrustedCSS` authors are responsible for excluding container-closing HTML sequences.
Sandwich Hime does not:
The v1 HTML state machine is deliberately smaller than a browser parser. Any construct it cannot prove safe is rejected rather than guessed. Differential testing against Go `html/template` is a baseline, not a claim of byte-identical output or universal parser equivalence.
- sandbox template authors or embedded Go;
- sanitize arbitrary trusted HTML, JavaScript, CSS, or URLs;
- provide application authentication, authorization, CSRF policy, CSP, routing,
database security, TLS, caching, or production process isolation;
- type-check all embedded Go during `himesan check` (the normal Go build/test
remains required);
- detect every dynamic or handwritten component cycle;
- guarantee safety under hostile concurrent mutation of the workspace; or
- replace independent review, browser testing, vulnerability response, or the
consuming application's threat model.
## Principal attack classes
## Open release work
Tests cover delimiter confusion, malformed HTML, quote/entity injection, event attributes, dangerous and obfuscated URLs, script/style termination, Unicode and NUL handling, component context breaks, import/source-map injection, CRLF and path behavior, symlinks, nested modules, stale outputs, interrupted/read-only writes, writer failures, component cycles, development-proxy exposure, CSP weakening, compression/content-length mistakes, and orphaned child processes.
- broaden semantic and browser-parser differential testing;
- execute the native Windows/macOS security and process-lifecycle matrix;
- complete signed release provenance, checksums, and SBOM evidence;
- test the confidential reporting and signing-key recovery procedures; and
- close or explicitly accept every finding listed in the evidence ledger before
assigning a supported release line.
+152
View File
@@ -0,0 +1,152 @@
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# v1.0.0 launch initiative
Sandwich Hime v1 is a compatibility and evidence milestone, not a reason to
accumulate features. The intended product is already visible: an HTML-first,
ahead-of-time template engine for Go, typed generated components, a small
HTTP-independent runtime, and an optional opinionated local development loop.
The private development initiative lives on `codex/v1-launch` in
`sandwich-hime-dev`. Public releases never originate from that private history.
## Repository and publication topology
| Surface | Purpose | History and tags |
| --- | --- | --- |
| Private Gitea `sandwich-hime-dev` | development, working branches, private review records, and historical context | normal private history; no public release tags |
| Public Gitea `sandwich-hime` | canonical sanitized source, contribution venue, module origin, releases, and signed tags | fresh reviewed publication history; authoritative `sando/vX.Y.Z` and `vX.Y.Z` tags |
| GitHub `gamertan/sandwich-hime` | discoverability and a convenient sanitized source snapshot | no private refs, force-mirrors, workflows, contribution authority, release artifacts, or semver tags |
Each public update is exported through the exact committed allowlist, inspected,
committed as a fresh public snapshot, and compared byte-for-byte with the
reviewed export. GitHub receives that public tree only. It never receives the
private repository or an indiscriminate Git mirror.
## Current readiness
At the 2026-08-12 v1 initiative baseline, the project is a strong engineering
preview, but not yet a release candidate. Exact private/public commit mappings
remain in the non-exported operational ledger.
### Demonstrated now
- race-enabled tests, vet, builds, deterministic generation, license checks,
and sanitized-export tests pass on Linux;
- the compiler module and nested `sando` runtime declare zero third-party Go
module requirements;
- generated/runtime compatibility uses a version-specific compile-time ABI
marker with an incompatible-runtime regression;
- owned-output, orphan, stale, symlink, nested-module, restrictive-permission,
last-good, writer-error, and enumerated contextual-output cases are tested;
- the development proxy is loopback-only, browser-origin hardened, and clears
a dead selected upstream immediately; and
- the security policy, threat model, and dated evidence ledger state both the
demonstrated controls and the unresolved limits.
### Not demonstrated yet
- native Windows and macOS execution of the complete supported matrix;
- a stable public API/CLI/schema snapshot and compatibility test;
- systematic browser-parser and `html/template` differential testing;
- a long semantic fuzz campaign beyond bounded no-panic smoke;
- committed, reproducible comparative benchmarks and a predefined regression
threshold;
- real-browser SSE/reload/overlay evidence for `himesan dev`;
- deterministic release artifacts, checksums, SBOMs, signatures, and tested
signing/recovery procedures; or
- clean direct and public-proxy installation of signed release tags.
## Milestone 1: contract freeze
Required before security/platform release-candidate work is declared complete:
- [ ] Decide and specify whether generic component function signatures are v1.
- [ ] Inventory and freeze every exported `sando` symbol, trusted type,
sentinel error, concrete error field, helper, and ABI marker.
- [ ] Freeze CLI commands, exit-code meanings, diagnostic codes, JSON schemas,
`himesan.json` schema, and generated provenance fields.
- [ ] Specify nil/stringification behavior, supported HTML-context matrix,
component trust boundary, URL semantics, and explicit unsupported cases.
- [ ] Add machine-checked public API, CLI, diagnostic, schema, and generated
output compatibility snapshots.
- [ ] Define the v1 deprecation and security-support policy.
## Milestone 2: security and native-platform evidence
- [ ] Run the minimum supported Go line and the latest two stable Go lines on
native Linux, macOS, and Windows hosts.
- [ ] Prove identical generated bytes across those hosts and exercise native
path, replacement, permission, race, process-tree, and watcher behavior.
- [ ] Build a systematic differential corpus against Go's documented
`html/template` safety baseline for overlapping supported contexts.
- [ ] Parse representative outputs in real browsers and test structure/code
invariants rather than only byte equality.
- [ ] Extend semantic fuzzing across delimiters, HTML transitions, imports,
paths, source maps, URL normalization, and filesystem operations.
- [ ] Resolve or explicitly accept every open item in
`SECURITY_EVIDENCE.md`; no accepted item may contradict a public guarantee.
- [ ] Test delivery and reply through `security@sandwichhime.com`.
- [ ] Define severity, advisory, retraction, and CVE-request handling.
## Milestone 3: measured performance and development UX
- [ ] Commit a synthetic, repository-owned benchmark corpus comparing
equivalent typed views and output with `html/template`.
- [ ] Define “no material regression” before measuring the release candidate;
publish hardware, OS, Go version, commands, samples, allocations, and output
equivalence with every result.
- [ ] Test SSE reconnect, reload, diagnostic overlays, CSP changes, fragment/API
exclusions, caching, and child cleanup in a real browser on supported hosts.
- [ ] Remove any v1 development-supervisor guarantee that cannot be evidenced
reliably instead of substituting prose for a test.
## Milestone 4: release rehearsal
- [ ] Make version validation identical in the CLI, generated headers, scripts,
and release artifacts; reject ambiguous build metadata.
- [ ] Build the candidate compiler at its candidate version and prove its
committed outputs are current under that exact binary.
- [ ] Produce deterministic archives/binaries, checksums, SBOMs, signatures,
and source/build provenance from a clean sanitized canonical checkout.
- [ ] Test release-key backup and two-person recovery for Gitea, domains,
signing material, and publication instructions.
- [ ] Make evidence gates validate content and commit identity rather than only
the presence of non-empty files.
- [ ] Rehearse runtime-first publication and rollback without creating public
semver tags.
## Milestone 5: release candidates and final launch
1. Export and review the sanitized canonical release tree.
2. Publish signed `sando/v1.0.0-rc.1`, then signed `v1.0.0-rc.1` from the same
reviewed public Gitea commit.
3. Verify documented installs through fresh `GOPROXY=direct` and
`proxy.golang.org` caches on supported Go versions and native platforms.
4. Run the complete evidence suite again from the exact public commit.
5. Operate the official Sandwich Hime website on the RC runtime for a 14-day
observation period with no unresolved Hime render, security, accessibility,
or rollback regression. This is product dogfooding, not a dependency on EQL
or another application's private repository.
6. Fix findings in a new RC; restart the observation period when the affected
boundary warrants it.
7. Finalize the changelog, supported-version table, migration notes, release
notes, legal/trademark review, checksums, SBOMs, and signatures.
8. Publish `sando/v1.0.0` first and `v1.0.0` second. Never move a tag.
9. Refresh the untagged GitHub discovery snapshot and point it to canonical
Gitea releases and contribution channels.
## Explicitly deferrable after v1
Unless testing finds a release-blocking consequence, v1 need not include every
possible context, hostile-local filesystem hardening, typed trust-flow analysis,
complete dynamic cycle detection, an encrypted reporting key, or an external
audit. Those limits must remain visible and must not be contradicted by
marketing. New features do not outrank a small stable contract.
## Definition of confidence
“Ready for v1” means a reviewer can trace each promise to a stable public
contract, executable evidence from supported native environments, and a signed
artifact built from the exact canonical source. It does not mean perfect,
invulnerable, or finished forever.
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestGeneratedCodeRequiresVersionedRuntimeABIMarker(t *testing.T) {
if testing.Short() {
t.Skip("skipping temporary-module ABI compilation in short mode")
}
t.Parallel()
directory := resolvedTempDir(t)
templatePath := filepath.Join(directory, "page.sando")
mustWrite(t, templatePath, "<?sando go\npackage generated\nfunc Page()\n?>")
result, err := Generate(context.Background(), []string{templatePath})
if err != nil {
t.Fatalf("Generate: %v (%v)", err, result.Diagnostics)
}
generated := string(mustRead(t, templatePath+".go"))
if !strings.Contains(generated, ".ABISandoV1") {
t.Fatalf("generated code does not require the sando.v1 marker:\n%s", generated)
}
mustWrite(t, filepath.Join(directory, "go.mod"), `module example.test/abi
go 1.25
require gamertan.com/sandwich-hime/sando v0.0.0
replace gamertan.com/sandwich-hime/sando => ./fake-sando
`)
mustWrite(t, filepath.Join(directory, "fake-sando", "go.mod"), `module gamertan.com/sandwich-hime/sando
go 1.25
`)
mustWrite(t, filepath.Join(directory, "fake-sando", "component.go"), `package sando
import (
"context"
"io"
)
const ABI = "sando.incompatible"
type Component interface { Render(context.Context, io.Writer) error }
type ComponentFunc func(context.Context, io.Writer) error
func (f ComponentFunc) Render(ctx context.Context, w io.Writer) error { return f(ctx, w) }
`)
command := exec.Command("go", "test", "./...")
command.Dir = directory
command.Env = append(os.Environ(), "GOWORK=off")
output, buildErr := command.CombinedOutput()
if buildErr == nil {
t.Fatalf("generated code compiled against an incompatible runtime:\n%s", output)
}
if !strings.Contains(string(output), "undefined: __himesan_sando.ABISandoV1") {
t.Fatalf("incompatible runtime failed for an unexpected reason: %v\n%s", buildErr, output)
}
}
+4 -1
View File
@@ -92,7 +92,10 @@ func generateGo(file *sourceFile) ([]byte, []Diagnostic) {
}
}
output.WriteString(")\n\n")
fmt.Fprintf(&output, "var _ = %s.ABI\n\n", imports.Sando)
// 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)
+62
View File
@@ -340,6 +340,68 @@ func TestGenerateRefusesUnownedOutput(t *testing.T) {
}
}
func TestDirectoryOperationsRejectOrphanedOwnedOutputBeforeWrites(t *testing.T) {
t.Parallel()
directory := resolvedTempDir(t)
orphanSource := filepath.Join(directory, "orphan.sando")
mustWrite(t, orphanSource, simpleSource("Orphan", "last good"))
if _, err := Generate(context.Background(), []string{directory}); err != nil {
t.Fatal(err)
}
orphanOutput := orphanSource + ".go"
lastGood := mustRead(t, orphanOutput)
if err := os.Remove(orphanSource); err != nil {
t.Fatal(err)
}
liveSource := filepath.Join(directory, "live.sando")
mustWrite(t, liveSource, simpleSource("Live", "must not be written"))
// A handwritten file whose name merely resembles an output is not owned by
// Hime-san and must not be treated as an orphan.
mustWrite(t, filepath.Join(directory, "handwritten.sando.go"), "package demo\n")
checked, err := Check(context.Background(), []string{directory})
if err == nil {
t.Fatalf("orphaned owned output unexpectedly passed check: %+v", checked)
}
assertDiagnosticCode(t, checked.Diagnostics, "HIM2014")
generated, err := Generate(context.Background(), []string{directory})
if err == nil {
t.Fatalf("orphaned owned output unexpectedly allowed generation: %+v", generated)
}
assertDiagnosticCode(t, generated.Diagnostics, "HIM2014")
if !bytes.Equal(lastGood, mustRead(t, orphanOutput)) {
t.Fatal("orphaned last-good output changed")
}
if _, statErr := os.Stat(liveSource + ".go"); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("batch wrote a live output despite the orphan diagnostic: %v", statErr)
}
}
func TestNewGeneratedOutputInheritsRestrictiveSourceMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX file mode test")
}
t.Parallel()
directory := resolvedTempDir(t)
path := filepath.Join(directory, "private.sando")
mustWrite(t, path, simpleSource("Private", "private"))
if err := os.Chmod(path, 0o600); err != nil {
t.Fatal(err)
}
if _, err := Generate(context.Background(), []string{path}); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path + ".go")
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("generated output mode = %04o, want 0600", got)
}
}
func TestDiscoveryBoundariesAndExplicitNestedFile(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation commonly requires additional Windows privileges")
+11 -9
View File
@@ -35,6 +35,7 @@ const (
type contextAnalyzer struct {
file *sourceFile
positions positionTable
state htmlState
currentTag string
@@ -79,7 +80,12 @@ var unsupportedDynamicAttributes = map[string]string{
}
func analyzeContexts(file *sourceFile) []Diagnostic {
analyzer := &contextAnalyzer{file: file, state: htmlData, attrFirstDynamic: -1}
analyzer := &contextAnalyzer{
file: file,
positions: newPositionTable(file.Source),
state: htmlData,
attrFirstDynamic: -1,
}
var diagnostics []Diagnostic
for nodeIndex := range file.Nodes {
node := &file.Nodes[nodeIndex]
@@ -136,20 +142,20 @@ func analyzeContexts(file *sourceFile) []Diagnostic {
}
}
end := analyzer.positions.at(len(file.Source))
if analyzer.state != htmlData {
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1310", "template ends in an incomplete or ambiguous HTML parser context"))
diagnostics = append(diagnostics, diagnostic(file.Path, end, "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])))
diagnostics = append(diagnostics, diagnostic(file.Path, end, "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)
position := a.positions.at(start.Offset + index)
if a.rawTag == "script" {
a.scriptTail += string(b)
if len(a.scriptTail) > len("<!--") {
@@ -614,7 +620,3 @@ func isAttributeNameChar(b byte) bool {
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))
}
+64 -1
View File
@@ -4,7 +4,9 @@ package compiler
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
@@ -115,7 +117,24 @@ func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
return filepath.SkipDir
}
}
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sando" {
if entry.IsDir() {
return nil
}
if strings.HasSuffix(entry.Name(), ".sando.go") {
entryInfo, statErr := entry.Info()
if statErr != nil {
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2013", "cannot inspect possible generated output: "+statErr.Error()))
return nil
}
if !entryInfo.Mode().IsRegular() {
return nil
}
if orphanDiagnostic := inspectOwnedGeneratedOutput(path); orphanDiagnostic != nil {
diagnostics = append(diagnostics, *orphanDiagnostic)
}
return nil
}
if filepath.Ext(entry.Name()) != ".sando" {
return nil
}
entryInfo, statErr := entry.Info()
@@ -157,6 +176,50 @@ func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
return discovered, diagnostics
}
func inspectOwnedGeneratedOutput(path string) *Diagnostic {
owned, err := hasGeneratedMarker(path)
if err != nil {
item := diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2013", "cannot inspect possible generated output: "+err.Error())
return &item
}
if !owned {
return nil
}
sourcePath := strings.TrimSuffix(path, ".go")
info, err := os.Lstat(sourcePath)
if err == nil && info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 {
return nil
}
message := "owned generated output is orphaned because its adjacent .sando source is missing; review and remove the output explicitly"
if err == nil {
message = "owned generated output is orphaned because its adjacent .sando source is not a regular file; review and remove the output explicitly"
} else if !os.IsNotExist(err) {
message = "cannot inspect the adjacent .sando source for an owned generated output: " + err.Error()
}
item := diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2014", message)
return &item
}
func hasGeneratedMarker(path string) (bool, error) {
file, err := os.Open(path)
if err != nil {
return false, err
}
defer file.Close()
marker := []byte(generatedPrefix + "\n")
prefix := make([]byte, len(marker))
if _, err := io.ReadFull(file, prefix); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return false, nil
}
return false, err
}
return string(prefix) == string(marker), nil
}
func firstSymlinkComponent(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
+1
View File
@@ -86,6 +86,7 @@ type CompiledFile struct {
Digest string
Code []byte
source *sourceFile
sourceMode uint32
}
// FileResult describes one source/output pair processed by Generate or Check.
+5 -1
View File
@@ -61,7 +61,10 @@ func Generate(ctx context.Context, paths []string) (Result, error) {
result.Unchanged++
continue
}
mode := os.FileMode(0o644)
// Generated Go can contain every literal present in its source. A new
// output therefore must not be more permissive than the source file.
// Execute bits are never meaningful for Go source and are stripped.
mode := os.FileMode(file.sourceMode) & 0o666
if info, statErr := os.Stat(file.OutputPath); statErr == nil {
mode = info.Mode().Perm()
}
@@ -151,6 +154,7 @@ func compileOperation(ctx context.Context, paths []string) ([]CompiledFile, Resu
output, diagnostics := compileWithMapping(sourcePath, source, moduleRelativeSourcePath(sourcePath))
result.Diagnostics = append(result.Diagnostics, diagnostics...)
if output.Code != nil {
output.sourceMode = uint32(info.Mode().Perm())
compiled = append(compiled, output)
result.Files = append(result.Files, FileResult{SourcePath: output.SourcePath, OutputPath: output.OutputPath})
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
__himesan_io "io"
)
var _ = __himesan_sando.ABI
var _ = __himesan_sando.ABISandoV1
func Greeting(name string) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
+16
View File
@@ -104,6 +104,22 @@ func (d *developmentProxy) setTarget(address string) error {
return nil
}
// clearTarget forgets address only when it is still the selected upstream.
// The compare-and-swap prevents a concurrently replaced target from being
// cleared between the load and store. The supervisor serializes activation
// and exit handling and calls this only for its current candidate.
func (d *developmentProxy) clearTarget(address string) bool {
target := d.target.Load()
if target == nil || target.Host != address {
return false
}
if !d.target.CompareAndSwap(target, nil) {
return false
}
d.closeIdleConnections()
return true
}
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")
+20
View File
@@ -250,6 +250,26 @@ func TestWaitingPageConnectsToEvents(t *testing.T) {
}
}
func TestClearTargetOnlyClearsSelectedUpstream(t *testing.T) {
t.Parallel()
proxy := newDevelopmentProxy(newEventHub())
if err := proxy.setTarget("127.0.0.1:7001"); err != nil {
t.Fatal(err)
}
if proxy.clearTarget("127.0.0.1:7002") {
t.Fatal("clearTarget cleared a different selected upstream")
}
if target := proxy.target.Load(); target == nil || target.Host != "127.0.0.1:7001" {
t.Fatalf("selected upstream changed unexpectedly: %v", target)
}
if !proxy.clearTarget("127.0.0.1:7001") {
t.Fatal("clearTarget did not clear the selected upstream")
}
if target := proxy.target.Load(); target != nil {
t.Fatalf("selected upstream remains after clear: %v", target)
}
}
func TestDevelopmentProxyRequiresLocalAuthorityAndSameOrigin(t *testing.T) {
t.Parallel()
var upstreamRequests atomic.Int32
+28 -12
View File
@@ -208,6 +208,7 @@ func (s *Supervisor) Run(ctx context.Context) error {
}()
var current *candidateProcess
var currentExited <-chan struct{}
defer func() {
s.hub.close()
s.proxy.closeIdleConnections()
@@ -224,6 +225,9 @@ func (s *Supervisor) Run(ctx context.Context) error {
s.emit(Event{Type: "ready", Phase: "proxy", Message: "http://" + listener.Addr().String()})
if candidate := s.buildHealthyCandidate(ctx); candidate != nil {
current = s.activateCandidate(candidate, current)
if current != nil {
currentExited = current.exited
}
}
roots := makeWatchRoots(s.rootDir, s.options.Config)
@@ -242,6 +246,25 @@ func (s *Supervisor) Run(ctx context.Context) error {
select {
case <-ctx.Done():
return nil
case <-currentExited:
exited := current
current = nil
currentExited = nil
if exited == nil {
continue
}
// Forget the selected upstream before reporting or cleanup. Future
// requests receive the waiting page and cannot follow a reused port.
s.proxy.clearTarget(exited.address)
exitErr := exited.result()
if exitErr == nil {
exitErr = errors.New("application exited")
} else {
exitErr = fmt.Errorf("application exited: %w", exitErr)
}
_ = exited.cleanupProcessTree()
_ = os.Remove(exited.binaryPath)
s.report("run", exitErr)
case err := <-serverErrors:
if err != nil {
return fmt.Errorf("development proxy: %w", err)
@@ -262,22 +285,15 @@ func (s *Supervisor) Run(ctx context.Context) error {
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)
if current != nil {
currentExited = current.exited
} else {
currentExited = nil
}
}
}
}
+99
View File
@@ -96,6 +96,73 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
waitForConnectionRefused(t, secondUpstream, 3*time.Second)
}
func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
if testing.Short() {
t.Skip("integration test builds a temporary Go application")
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-exit-test\n\ngo 1.25\n"), 0o600); err != nil {
t.Fatal(err)
}
mainPath := filepath.Join(root, "main.go")
writeExitingTestApplication(t, mainPath, "short lived", 1500*time.Millisecond)
cfg := DefaultConfig()
cfg.ProxyAddress = "127.0.0.1:0"
cfg.HealthPath = "/healthz"
events := make(chan Event, 16)
supervisor, err := New(Options{
RootDir: root,
Config: cfg,
Generate: func(context.Context) error { return nil },
OnEvent: func(event Event) { events <- event },
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 30 * time.Second,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
StartupTimeout: time.Second,
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) }()
t.Cleanup(cancel)
proxyAddress := waitForProxyAddress(t, supervisor)
waitForBody(t, "http://"+proxyAddress+"/", "short lived")
waitForPhase(t, events, "run")
if target := supervisor.proxy.target.Load(); target != nil {
t.Fatalf("proxy retained exited upstream %v", target)
}
client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: time.Second}
response, err := client.Get("http://" + proxyAddress + "/")
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(response.Body)
_ = response.Body.Close()
if readErr != nil {
t.Fatal(readErr)
}
if response.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), "waiting for a healthy application") {
t.Fatalf("dead upstream response = %d %q", response.StatusCode, body)
}
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")
}
}
func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) {
t.Parallel()
upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -173,6 +240,38 @@ func main() {
}
}
func writeExitingTestApplication(t *testing.T, path, message string, lifetime time.Duration) {
t.Helper()
contents := fmt.Sprintf(`package main
import (
"fmt"
"net/http"
"os"
"time"
)
func main() {
go func() {
time.Sleep(%d * time.Millisecond)
os.Exit(0)
}()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
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)
}
}
`, lifetime.Milliseconds(), 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)
+13 -2
View File
@@ -13,10 +13,16 @@ import (
)
// ABI identifies the generated-code contract implemented by this version of
// the runtime. Generated files should reference this constant so that their
// expected ABI is visible to readers and tooling.
// the runtime. It is descriptive metadata for people and tooling.
const ABI = "sando.v1"
// ABISandoV1 is the compile-time compatibility marker for generated code that
// requires the sando.v1 contract. A future runtime may retain this symbol while
// it remains backward compatible; an incompatible runtime must remove it so
// affected generated packages fail at build time instead of failing subtly at
// render time.
const ABISandoV1 = ABI
// RuntimeABI is a descriptive alias for ABI.
const RuntimeABI = ABI
@@ -34,6 +40,11 @@ var (
// Component is the complete production rendering contract. Components are
// values rather than HTTP handlers so applications retain ownership of
// buffering, routing, headers, status codes, and error policy.
//
// A Component implemented by handwritten Go is a trusted output capability: it
// can write arbitrary bytes, block, panic, recurse, or change the surrounding
// HTML parser context. Hime-generated components are separately checked for a
// balanced, context-neutral HTML boundary before they implement this contract.
type Component interface {
Render(context.Context, io.Writer) error
}
+7
View File
@@ -12,6 +12,13 @@ import (
"gamertan.com/sandwich-hime/sando"
)
func TestRuntimeABIMarker(t *testing.T) {
t.Parallel()
if sando.ABISandoV1 != "sando.v1" || sando.ABI != sando.ABISandoV1 {
t.Fatalf("runtime ABI=%q marker=%q", sando.ABI, sando.ABISandoV1)
}
}
func TestRender(t *testing.T) {
t.Parallel()