7 Commits
Author SHA1 Message Date
gamertan e730dd1b56 Prepare Sandwich Hime v1.0.0-rc.1 source 2026-08-24 13:21:14 -04:00
gamertan efd5b997ed Prepare Sandwich Hime v1 release candidate source 2026-08-23 16:25:02 -04:00
gamertan c11552b87a policy: maintain Linux as the release target
Publishes the exact allowlisted snapshot from the private Beta 2 development line. Historical platform evidence remains truthful; native Windows and macOS are no longer release gates or support promises. Material AI assistance was reviewed by the maintainer.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-16 17:50:00 -04:00
gamertan 4fef65f9b0 docs: publish the Beta 2 evidence addendum
AI-assisted drafting and verification; maintainer review and responsibility retained.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-12 21:27:09 -04:00
gamertan 1082d9d61e feat: publish the Hime-san Beta 2 LSP
Add the standard-library language server, editor-neutral protocol contract, additive version feature discovery, and bounded security regressions while leaving the Sando runtime unchanged. Material design and implementation assistance was provided by OpenAI Codex.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-12 20:31:07 -04:00
gamertan e3a94fd16b docs: add the Beta module-cache recovery
Publish the tested non-destructive recovery for users who install the parent compiler before the nested sando runtime.

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

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-12 15:21:42 -04:00
gamertan 532724baf7 docs: publish the v1 beta evidence
Publish the exact-candidate Windows and Linux results, signed-tag and clean-install status, provisional macOS boundary, and reliable runtime-first Beta 1 installation order. The post-publication verifier now cleans read-only module caches safely.

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

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-12 15:09:32 -04:00
81 changed files with 8019 additions and 545 deletions
+83 -9
View File
@@ -6,6 +6,75 @@ Sandwich Hime follows semantic versioning after final v1. Compiler and nested
runtime releases are versioned independently and listed together when they form
one coordinated release.
## Unreleased
No changes have been accepted after the first v1 release candidate.
## v1.0.0-rc.1 — 2026-08-24
The first release candidate coordinates compiler `v1.0.0-rc.1` and runtime
`sando/v1.0.0-rc.1` at runtime ABI `sando.v1`. The intended v1 language,
generated API, runtime API, CLI, diagnostics, and schemas are frozen except for
release-blocking corrections.
### Added
- Maintained native Linux/amd64 and Apple Silicon macOS/arm64 verification on
pinned Go 1.26.7 and Go 1.27.0 toolchains.
- Reproducible Linux and Darwin archives with checksums, SPDX SBOMs, manifests,
build provenance, and exact public-source identities.
- A Developer ID-signed, notarized, and stapled macOS disk image for the native
arm64 compiler.
- Exact contract snapshots, contextual-escaping differentials, real-browser
compiler and development-supervisor evidence, long fuzz evidence, and
repository-owned benchmark evidence.
- Standard-library-only LSP diagnostics, hover, document symbols, component
completion, and go-to-definition retained from Beta 2 as part of the frozen
v1 command surface.
### Changed
- Linux/amd64 and Apple Silicon macOS/arm64 are the maintained execution,
verification, artifact, and release targets. WSL, native Windows, Intel
macOS, Linux/arm64, and other targets remain useful portability surfaces but
are not v1 compatibility promises.
- The module language directive remains Go 1.25 for consumer compatibility;
maintained release builds use the pinned patched Go 1.26 and Go 1.27 lines.
- Release evidence is bound to the exact sanitized public commit and tree.
GitHub remains an untagged discovery snapshot; Gitea remains the source,
contribution, module, tag, and artifact authority.
### Removed
- The native Windows PowerShell verifier and private multi-OS release-gate
workflow. Historical Windows evidence remains recorded without creating a
current support obligation.
## v1.0.0-beta.2 — 2026-08-12
Compiler-only release; the unchanged Apache runtime remains
`sando/v1.0.0-beta.1` with ABI `sando.v1`.
### Added
- Standard-library-only `himesan lsp --stdio` with one workspace per process,
full-document overlays, 200 ms edit debounce, cancellation, and bounded
indexing through the compiler's existing filesystem boundaries.
- Live diagnostics, trust warnings, duplicate/cycle reporting, UTF-16 LSP
positions, tag/context/component hover, document symbols, typed component
completion, and component go-to-definition.
- Additive `features: ["lsp-stdio"]` in `himesan version --json`.
- Protocol framing and malformed-input fuzzing; overlay, Unicode, CRLF, NUL,
deletion, nested-module, symlink, completion, definition, cancellation,
shutdown, no-write, and resource-limit tests.
### Boundaries
The server does not generate, run Go or project code, fetch dependencies,
access the network, start the dev supervisor, format, rename, add imports, or
delegate general Go completion to `gopls`. Generated-file freshness remains an
explicit `himesan check --json` workflow.
## v1.0.0-beta.1 — 2026-08-12
This is the first installable public beta: `sando/v1.0.0-beta.1` for the
@@ -27,24 +96,29 @@ not a production-stability promise.
- Public beta support policy for evaluation and classroom use, including a
provisional macOS lane and a community compatibility-reporting path.
### Pre-beta verification baseline
### Release verification
Maintainer-run Linux and native Windows matrices passed on public commit
`113c95c21e57227b4675c9fda015ada59cc9e9a6` (tree
`a2aeb4dac22853cb3894e3e487b94bbeff5051e5`) with Go 1.25.12 and Go
1.26.5. The tested golden output had the same SHA-256 on each tested host:
The exact public Beta 1 commit
`b7a84054d755e42285e50298e41e47f06a8325a5` (tree
`be9e118e38dfebed19f60403ededdadabe07d2aa`) passed maintainer-run
executed Linux and native Windows matrices with Go 1.25.12 and Go 1.26.5. The
tested golden output had the same SHA-256 on each tested host:
`63fa75a3049a3a8a12d769d7f9b6b510dfe763baacf706775b75cef2c57a984f`.
That commit is a pre-beta baseline, not evidence for the later Beta 1 commit.
The required matrix must be rerun from the exact candidate before its tags are
published. Native macOS execution remains pending and is explicitly provisional
for this beta.
The complete release preflight passed, including race tests, bounded parser
fuzz smoke, known-vulnerability analysis of both zero-third-party-dependency
modules, candidate-version provenance, and six cross-builds. The signed runtime
and compiler tags were published in that order. Fresh direct and public-proxy
runtime-first installs passed after normal proxy propagation. Native macOS
execution remains pending and is explicitly provisional for this beta.
### Known limitations
- Source syntax, generated format, CLI details, and runtime API may change
before final v1.
- Native macOS behavior has not yet been maintainer-validated.
- In a shared fresh Go module cache, add the nested `sando` runtime before
installing the parent compiler module at the same Beta 1 version.
- Prebuilt binary artifacts, checksums, SBOMs, reproducible archives,
key-recovery rehearsal, systematic browser differential, long fuzz,
benchmark, and final compatibility gates remain work toward the release
+4
View File
@@ -11,4 +11,8 @@ Output must be deterministic for identical source, compiler version, runtime ABI
The template/application author chooses the generated file's license to the extent they hold the necessary rights. A project-wide license may cover generated files because inline headers would be overwritten. Sandwich Hime adds provenance metadata, not an AGPL license identifier or a compiler copyright claim.
The ordered v1 marker fields, compile-time ABI assertion, and source-map form
are machine-checked against
[`contracts/generated-provenance-v1.txt`](contracts/generated-provenance-v1.txt).
[OUTPUT_EXCEPTION.md](OUTPUT_EXCEPTION.md) is an additional permission for Cole Speelman-owned generator scaffolding copied into output. It is intended to remove licensing ambiguity without claiming that every generated file is or is not a derivative work. It does not cover third-party inputs, code copied manually from the compiler, other contributors' additions unless they grant the same permission, or the Apache-licensed runtime.
+4 -1
View File
@@ -7,9 +7,12 @@ Sandwich Hime deliberately separates the development tool from application runti
| Path or material | License |
| --- | --- |
| Project-authored files in the repository root, `cmd/**`, `internal/**`, `docs/**`, and `scripts/**`, except the legal texts listed below | AGPL-3.0-only |
| Project-authored files in the repository root, `cmd/**`, `contracts/**`, `internal/**`, `docs/**`, and `scripts/**`, except the legal texts listed below | AGPL-3.0-only |
| Nested `sando/**` runtime module, except its verbatim license text | Apache-2.0 |
| `LICENSE`, `sando/LICENSE`, and `DCO.txt` | Their own stated copying terms and notices |
| `contracts/himesan-config-v1.schema.json` | AGPL-3.0-only |
| `contracts/himesan-operation-output-v1.schema.json` | AGPL-3.0-only |
| `contracts/himesan-version-output-v1.schema.json` | AGPL-3.0-only |
| User-authored `.sando` templates | Chosen by their author, subject to rights in their inputs |
| Generated application `.sando.go` files | Chosen by the template/application author, subject to rights in their inputs and dependencies |
+1 -1
View File
@@ -1 +1 @@
{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"release","file_count":80,"allowlist_sha256":"e40a56b3431efdd99b3a78c38c387722de347216640383fc849569a817edf5c6","manifest_sha256":"93870a8c1e91602754de257c30249892821356ea9bf8d89e1b4502b62d769b06"}
{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"release","file_count":125,"allowlist_sha256":"b320a95ca85f48941ee956fde1eda46e0d9bc664da21d13c5f65a8f623063ed5","manifest_sha256":"6f9f21ff9ff641de9765f0491edd7e9f173d718454fe68f32c6379f5331d9c13"}
+78 -33
View File
@@ -2,55 +2,69 @@
658ba4b4645426f8c3249337f47669074ae9249a31703dcd9ea4c1afec45e20b ./.gitattributes
d5ae411fb422b2388cac220f9655900eecbc49ece961b2bb2a6610347733b756 ./.gitignore
98f663ab0f376b4550094465ec2e06192d1e0b0707604ec6794f20b0d10952c1 ./AI_CONTRIBUTIONS.md
8bc6c7586a58bdc648c7d3df2db33fa6db6381a111da9d8fc2d5817b5e0517ad ./CHANGELOG.md
954fb577484d5ca95f3fedb80d967ec6012ed4ed24fb7d999174f1b163697c80 ./CHANGELOG.md
b696cab3cf482ff5737501371cca749369b119351383e698ced42bcdbcbfc8ae ./CLA.md
797e884105738fc931b585b695424f43ec5f296d8ab9bba5191b096e87a9e2c2 ./CONTRIBUTING.md
86d7e49d5d90e0f98a4ad0f14b5d8b9f11ed09a1e29ecdf27388316b28e195e8 ./COPYRIGHT
f7ac75b443f4ca16b503241344b41aeff9503b0c30bedc2b119551d83cb0fa90 ./DCO.txt
73cb1b092a40c56e522c5a0ebddf2b44f347cdb57bf6994cdb305d0e5697b55e ./GENERATED_CODE.md
8ac46ef4e559f0bb43a8acb8cf6013052e09d41aa591a25c5e6fd462179eb067 ./GENERATED_CODE.md
a4570d054f072d33b8f17b0c8b162a6ee0ca37d7df2b1aee7e4b728ab350a892 ./GOVERNANCE.md
0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0 ./LICENSE
47d857e49f89596bac9b09fc8ca57a668a33d01e2b51508acfc92ed321cdc27f ./LICENSES.md
9957e4aea02effb4d4b8012a3cc0e2d6ee1ce8be3761555c2c3e8aee27a5ee08 ./LICENSES.md
b6aa08e5ccaec3c5dccdc19d7cd7f54a70adae4d57966263c7aa353c7ba70e08 ./MAINTAINERS.md
6638db2f1fba831c79de835ce95c847a5b36c5b5c693b99a28655b2d096cc440 ./OUTPUT_EXCEPTION.md
dda0ffee17bc1586ac16cc9707999cde2909a116c98cd43aeab85e0d3da7b636 ./README.md
9895823f9f7ba88ddf048c53c23ed32298b151ab049a9c4470366389d9498ff4 ./RELEASE.md
6d1e49dd72ba9592a3fd0562fcca1857803a3dd86dc097b36043e20f5d5cd591 ./ROADMAP.md
50a24995b39a957e47e0e181f8771a87141302b4d308fa8d8bfdc2e70121c2b9 ./SECURITY.md
53bd6eda804d6b782bdb07115ec197c890813cf2d5d0125dfe8f47f5f92f75b0 ./SPEC.md
80de7a9ede0fbe2f364456e72b08c5a4b965ea656eaab63f3e1d246abeb9c01e ./README.md
14fbed2a6de1496d7ad3783bac1b79f3f340de72545fe0dcf39de829622d7d32 ./RELEASE.md
d4ae7927633d42851581d2bcbc94777aef9853b4880883cc50bfc94ce0c78842 ./ROADMAP.md
b9ee0e394494a66ba6291a8e06a14fd159ba9fb3f46997e1b2de03e1cc2ef77c ./SECURITY.md
d566b8b27777cb33209602df81d654a94072ad875440c089ac41764a349b6c17 ./SPEC.md
3d9e680cdfe147df7cc9ff29ecf1d3e566e9cd559ae84db4880e559b9c7c7205 ./TRADEMARKS.md
136a6d82db842547b342f8b0c9ffdc7c04f7c9b473b4ef6dca9dbc940cb24b54 ./cmd/himesan/main.go
3011a435aaeb572d34e2d2b582865fd63268c92a9ae3f0ae1a56d1a59d83cd43 ./cmd/himesan/main_test.go
e2ce8cbc43e4575984839b61332207f7c90e2848fa270d4c68dbc5fc394f28e7 ./cmd/himesan-release/main.go
9dda7402f76b38d417981f9e7658880b592c282c00163a3fdbfb0ab55e1edcba ./cmd/himesan/contract_test.go
8cd8db68e1300f9b78cc7235855853cbc7aeb499a921419e23a22e4d22826fcb ./cmd/himesan/main.go
8c53127437cd8a77676fdbcdf62f6dd9346e533665bf7d5ac32f103839a24974 ./cmd/himesan/main_test.go
de004164324a640555256a91ca21a02be163bd3d231b38f08f8539c9a77ef665 ./contracts/diagnostic-codes-v1.txt
60de7d2cb9e8626136a0d197cbf55d2eed545ebab184cca66d83d5593a45b8d7 ./contracts/generated-provenance-v1.txt
e6fb36b274bf44143497b2af454e9ee0fabe6122f935cbf97e785a003277e211 ./contracts/himesan-cli-help-v1.txt
6cad9bb55238c268b25937d02ac1708bb2302c8614556648fb6c63b4479d053b ./contracts/himesan-config-v1.schema.json
09e574ed55e3a48fa117936465d3de887686cf3f2a5c4231cf598ff30e4b1a75 ./contracts/himesan-operation-output-v1.schema.json
7679c9908266811a4336be9698982e58ffedecedf2c340b082bf5dcdef1554dd ./contracts/himesan-version-output-v1.schema.json
1ecbba46f8b1b2d548a01d7e98afae17b2dd17a814338ff1f88db885655d1c07 ./docs/ARCHITECTURE.md
9c598559a89fa4a9bdd2311bd1ed8330992d0a0f74ec8b29ac151fc0ff8fef16 ./docs/BENCHMARKS.md
9e4f5a80afbffe51dda3e7f9fa5221e3ef4ca516c4b7f7ca33b61ac97ff213bb ./docs/BENCHMARKS.md
5c3a62fed80ca28d56558b8c75e8b5be8ba7d2554127adf4609d96da314e85b0 ./docs/BRAND.md
4f7b04b3f74a2e90fc69019cad78a1287f4806ae84e7207cfdf000971702395d ./docs/COMPATIBILITY.md
5f4ac209a16ab110baeaa64a40c19d9239c903e17550c3f05e1e1473ddcc33a3 ./docs/DEVELOPMENT_SERVER.md
51aa57a81131b64f76c45552122de842f22be92d81c8bba8f6fd38a18a7670d6 ./docs/DIAGNOSTICS.md
9bd43853d91b841c4879dac94dda5aafefa25bcdf4a02763445e2506fc618b03 ./docs/SECURITY_EVIDENCE.md
d969c7b5486ee93e54232fd69d9db06f3b4dc1bba63001596ec48545073c2680 ./docs/THREAT_MODEL.md
82107c57043af40b9e3ec03f4ed9efddcf3bcdda1765b99ec413f835be4a46e3 ./docs/V1_RELEASE_PLAN.md
60f0c1257cfce8388a252a105042be57c2978820d37ef81e46c6d3a49474fde3 ./docs/COMPATIBILITY.md
d1b4ef192e02b59a9d8d0eb3b1072392e633116433fa308f6ca9bbe17b75e213 ./docs/DEVELOPMENT_SERVER.md
4d92dc88ef33d4f7d952de039f842e54a3f3bfb4dbe56debf7ddb6e6c4a428f6 ./docs/DIAGNOSTICS.md
a62cc7174f3c92d8ef77e4bd9607fbf5d4b80bc514ff05bd433c02a9b0578f18 ./docs/LANGUAGE_SERVER.md
32253dbe59d9b0da40354b48c2e54feb8c38d441b41efba046125571d3dabb50 ./docs/SECURITY_EVIDENCE.md
e600caf17a87997de330ca787bfc5f16c1b20e07f467f8d1ad450e96e2421910 ./docs/THREAT_MODEL.md
978c6fdb8ed82aa2b118bbfb3d6217adf8edf425370ecdffdfc96ac24a9cbdce ./docs/V1_RELEASE_PLAN.md
f27c46ca63707bb8cc570eab1ea521824e94bc59b1d153998a5e91c2c7340d16 ./go.mod
ca0bf5051d356d2602f46201fb1637ce48b629ad42161877eec13f743f215dc5 ./internal/compiler/abi_test.go
d891b9b075617050471b2ca34de73d926aaebde4ec638a5039b0d5001d3172f4 ./internal/compiler/analysis.go
c123bf8c8fd847962e147041172b71fef7e852819ccd7b27d4648748e02d522f ./internal/compiler/analysis_test.go
6ef6a0f15a5aca1c8708cbf24218372e1fca9c6fead1a5a75d261faa69651af7 ./internal/compiler/backend.go
4d309392a7739b26e41cd9d9db71b52077bb8198573634ddf793ad531acc81cf ./internal/compiler/compiler_test.go
dbba23e360bd6dd1e8f42953a49a7cfcc241aa3ac76f5ce505ec8f8558833c84 ./internal/compiler/context.go
1e0f83c3e9eea34a23e8d62f8da36bb697268531e8edd9d76a1f3752abfeb563 ./internal/compiler/contract_test.go
b2a96ef1ad572ad9cd0e9247328ca261de6f9f3689da41e3f3e111d405a6dee6 ./internal/compiler/diagnostic.go
45562a41ef9ab1116746e4962ce8f93d4d8651e1e468a38122c626f8a34a2874 ./internal/compiler/discover.go
50a8bad11deea99faf0c7c9de1266bc086b7a1247ff9dd275ffedfc69a3be81d ./internal/compiler/e2e_test.go
eefb05a35bd07660a293c8af97949cd6f69a22709728f3fe2cc9132b863b7d5a ./internal/compiler/fuzz_test.go
d166096f185d76b2698aa3ab3251f00e58f84cdedc3af667e88ddd528ca0cb76 ./internal/compiler/model.go
6a3dccd0c7caf92fe2690b350f1db6f34037609b1fff6d3e68e0e229241950e5 ./internal/compiler/discover.go
13fb6843b4e43fa766f8fbb7dc7f04fa7328edf159d40c5a51cd87b1a0b2dc6c ./internal/compiler/e2e_test.go
3cf1b6c26b2551ad2e8f1e06e45adfdf8b72e6f9ce7f9baf6b73209b7d788022 ./internal/compiler/fuzz_test.go
440580fcd844b2c80b1f4dddc4d83db82974e203cca298387a190e53e6a524f3 ./internal/compiler/model.go
01516c0330b14a1970019690d8efcc9004d2dcfa59e82fef1b67f98005b67532 ./internal/compiler/operation.go
d7d8181455d5f37ef9bcc6bdbf86e0630f20e8a5b3b81688d12742687b434c99 ./internal/compiler/parse.go
1f633caa019d8de9ae5bbad27e29679fcc01b3cb9a33d7d616be07140e06f38b ./internal/compiler/parse.go
80cf170514a3b955d24440cb086d34e19f3a305510e3c5db95cb897be91f922a ./internal/compiler/replace_unix.go
0fff1c67447bf5353ed1df6e7dfc4b14581b67adc1bf02f7a4a7c1f2680c392f ./internal/compiler/replace_windows.go
f4ba01010ed5f5ba1e979702d82e95312bc0a4b13cc205c098926839be4ecb73 ./internal/compiler/testdata/golden/basic.sando
63fa75a3049a3a8a12d769d7f9b6b510dfe763baacf706775b75cef2c57a984f ./internal/compiler/testdata/golden/basic.sando.go
3b9755803bb66a08ce74a973d275234a593b6a944e7fe325de35bdbe52e62c6b ./internal/devserver/browser_evidence_test.go
eafbe9f7d8abb8fa792ec9e01f56655f9ec9d67279ffaac66d6035f9b2bfc404 ./internal/devserver/config.go
99807040a870dd38ad1e04ae179243316778f94a41feb5d2c3076d463f52f9fe ./internal/devserver/config_test.go
53cdeb4131976a4191b43ae3537b7883c18b69be9468aaae6b2cfed86c13bed2 ./internal/devserver/contract_test.go
eddac51aecaac99bd11cfcf98f8a47cec5d51672efedad75d6f2a862c5d57fc1 ./internal/devserver/events.go
36c4a415ee8201064b2365647f24b3cc7497bf74fefbc30db8e3d4dadcc2997b ./internal/devserver/process.go
72074ddb02d2eae3cabe5a79c648fe0debbae3ffaa1b0800b897259eca74f209 ./internal/devserver/process_test.go
1449c00e9f25a0a2cd2ecda99ee5b11d6b30a48ff7ba0eb2f00572f917ac332a ./internal/devserver/process.go
c1a487deaca6ab22b4d45b9fa7cd1918834443f258945a9472967852ee55daff ./internal/devserver/process_test.go
5286e7ba5f59114e37265ab614d9dfc14bfb3003eccc93278e819e52d12f5e7a ./internal/devserver/process_unix.go
c0f76ef5c14b0a28ed1e68d8d518102ffcbf067285b087eed4d13cd3c87b0e00 ./internal/devserver/process_windows.go
6fbbe08813385ed43a9377b3772260e577dbd9f742f7a9ed5140a02f4c7991a4 ./internal/devserver/process_windows_32.go
@@ -58,23 +72,54 @@ c0f76ef5c14b0a28ed1e68d8d518102ffcbf067285b087eed4d13cd3c87b0e00 ./internal/dev
7f1efbefea3a277f0f4d96a29219293efd78d9dc44823c09b9667b19d5042047 ./internal/devserver/proxy.go
aebf8388576d7bc9b047ceedf8a893acb3ace5fe16f44cb883efe63eef072ef9 ./internal/devserver/proxy_test.go
e6561e693138a3b77be06c1a98999e71494bbca0d0c72ccb9bff57b8e8575c0f ./internal/devserver/supervisor.go
b94103cd4b582968cdb0b61b0164f57ade006fa4e5187fcaa05944274192526a ./internal/devserver/supervisor_test.go
40872123cec152daad73eaaabdba465033a4de57fade62790c29a8655444e67c ./internal/devserver/supervisor_test.go
e0a682c0153bf4f2a1f26cc6095d7893ad96e6199cbe76d0150785fc996f1141 ./internal/devserver/watch.go
b7a7fabf9a6c497f7ac2262628c5fb37a6bd00da676e1b7d5088d5f649c9f14c ./internal/devserver/watch_test.go
76609ee5c57b1c174076689d4953e6ec5ef914ca114c27f1ca1d6e7fcabbddcf ./internal/lsp/features.go
edd377bca36a04ae76620a146f986e25c954fd137bab0b0e49084dbcde216ce7 ./internal/lsp/protocol.go
73b2ff8d6fd8fe44abec287384f0051520fe9564a50b4764538474f336624eee ./internal/lsp/protocol_test.go
2a2151a0211a5048c3bc89f9a2901a3c208609f483e99144f3837f87941efff9 ./internal/lsp/security_test.go
3be838b4bb0b618f46c0eab40788a1e03121da25644dedfa7542bd0fa64571e1 ./internal/lsp/server.go
99f7ccc8768bd92efe6aec9039d4a1f8ba5a2d072da50c65ab3ed887e8ad9cf5 ./internal/lsp/server_test.go
4c5ade5f649dffdd0a1b0a350c15488fc601b7eba582386f39e64412387b5c91 ./internal/lsp/workspace.go
21ec36dd353b237cd6e6705d09b2ad8a1b7dade8ad18024976e4c6fc140294d2 ./internal/releaseartifact/artifact.go
0c482e0c0ff2659ee6c650c56a7a267239cf8149283dfe5b3e680a5e3ab47121 ./internal/releaseartifact/artifact_test.go
83dd9a9d683fc443e69449bc0ac5cdc5c764f4404718c41b3a05676f7abef6d0 ./internal/releaseartifact/evidence.go
40faefc3a8ab0a6e1e28972fc515e118989da068186307133ae83c676604d741 ./internal/releaseartifact/evidence_test.go
77e7dc75b48765bb4ce926c634b0eaaded08b4d59d0951ee814001d23aebcf4c ./internal/releaseartifact/macos.go
8e31a8c4654e8a1bc8a025b350494f509b5979b61e500cc937dcbc916ee87d39 ./internal/releaseartifact/macos_test.go
f2e1873c4beb131266d6b4b8ee27acc12b7cb1baca734cc0d9586fb14484b7b0 ./internal/releaseartifact/native.go
c07cd08a7875f851981a11a4d570a7784cf453faafe0c991aa43881cd4f2e39a ./internal/releaseartifact/native_test.go
fd1babf093a791d7e50a1faa3d4b00e42a1e487ad64eece7841a3f4d81cb4072 ./internal/testpath/tempdir.go
5a8ffb091328464b84403ea54a8911e41173881a4909a334ba2e4c44dd1c0de4 ./internal/testpath/tempdir_test.go
d8c6f37c94ef426fc2d95c82331265f7d700d2e2a23100ad78c92849280ff6d8 ./internal/version/version.go
9b615038a1178eedbcab299d90548e259ac34d2f5c51deadd79ed16f51ef6911 ./internal/version/version_test.go
a1ee0ec44dc45f01a3357cf93e13a81587af7f061497c25df374e2a88231f3b7 ./internal/version/version_test.go
e8a3026ec920d7312f843e2001e50ae4e34fd1ba5f9b2ae25a6113de1fa88385 ./sando/COPYRIGHT
c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 ./sando/LICENSE
b4a7bffe678a97209881e07989563a5085aa0ead9e1b67306087dac6b97bad70 ./sando/README.md
9b9b09b595658cf65aef8f7ace9cc955f026cdd71ece0f79671bfada8bd9c260 ./sando/README.md
acfbbc860417aeabba486ca4fea530e711c3e488bc3f5f6588db102b8a58bec2 ./sando/api_contract_test.go
7e862661f19fc9fcd694ef64e65fda898186c51836335c27c21397e8dcf09c05 ./sando/benchmark_test.go
7ec3fe73755a385e0950b9fbf833dd4b6a753a769ab743e97b9d94e77370a32c ./sando/component.go
a242fd3bebb9cb8786c92651950999c6a2575d0be9602bac562e0b63c9ded015 ./sando/component_test.go
e12e2b27396ba4447d27cb1f80ef44100f7295135c26a1bdad5e13e1312a3a5f ./sando/differential_test.go
ff76daee5b642ad84af31701833246d68b54d09580192312d750a7a2e893a692 ./sando/go.mod
ed2b8d8040689c608623d30085a55678ae3c9d70a8a40a42435e04bff47a31ab ./sando/testdata/public-api-v1.txt
80ff53787919e809b8085d6ad9c3e183c9c7c1d74cfeda73369ac5c4607c236f ./sando/trust.go
85621a44c730582f4410ac2c70418b739fb55e916f7e6b73a1a619982c459572 ./sando/write.go
b188917e258890e6b6e4840a6fd946fc9a77cabc2068da3764f221e4a6a5df97 ./sando/write_test.go
504897b29686e0ea7adff8beb8ec91612df3ee169397309c3b6b69eb0393491b ./scripts/README.md
0bc796f71c863aa898674a26c56f055e3d81cf20629ca7b32fbae87d8841e0a8 ./scripts/check-licenses.sh
6c73ad46beb642836ae4d462f40e7ecc8d86a3cc194e71a5d0859dac73af0410 ./scripts/release-check.sh
9cd43005a7d0f3659b11c5c14e4e0b9e7f675b695da185f4ee97c54edebf0dc6 ./scripts/verify-public-install.sh
24ed3c9a1d37e46a856cbbd68e5c58ae04c6c9852902b99ed675e1f428339a9f ./scripts/verify.ps1
f0cbd86759fa729064cb1c69991db2ac291792dadb6b1e1ba83794f2e390404d ./scripts/verify.sh
6ea995e9d7d22e09a13065a6e340df4efa0f1860b0b42409f84835c93f29962c ./scripts/PUBLIC_SNAPSHOT.md
2d152851bc77f6eb5ca842a8021cde386a3cae91db819cd99cedc31b3b62518f ./scripts/README.md
81ab4c10a436bc9b345828a55c2fe446f297fa6cfd1debfe95e1082b6e28c969 ./scripts/check-licenses.sh
3adba1428df83d6180d4fa3c683f0604dc7cdc0c4358f3dae7595189cc40f949 ./scripts/export-public-snapshot.sh
47dceef111d1da4345b473ff9092df7fcdab655fa1ec660e58012f75c5135d83 ./scripts/native-gate.sh
a038a002f0c895fef38a7265bcb7df01efdda57de5c4d36d0dc7c8e377798a95 ./scripts/package-macos.sh
7d1e3606f3159e0d5d222bb5b02171ad90bb2c42b0e041042dcf8e6b7692c40a ./scripts/package-native.sh
1fddbdf81e86f4310a603cedd0f24a39d6c62d810c7e1e2e6c5f7bff1d1d4198 ./scripts/public-snapshot-lib.sh
b320a95ca85f48941ee956fde1eda46e0d9bc664da21d13c5f65a8f623063ed5 ./scripts/public-snapshot.allow
183b76febe506a8e0dfaa70ddf6269749ab2c475e6c345c8adfb2ad6e7e1019b ./scripts/release-check.sh
494cf5573cc6a891d4243ec01a98c43263de75090cb3b55c9c398e545b1ec1a8 ./scripts/sign-notarize-macos.sh
7c65325664e95df1a63262bc440a599fb799b36123a68c5cc26cfb81ddd08efa ./scripts/test-public-snapshot.sh
39a80708b1c1a64c837032c6570e77dea28b2c6e6058eea40efac63ad5827431 ./scripts/verify-consumer.sh
ddcb5602a51a515ee6649e26f27d919addb0590abdcc095c374c90f5b0c2f8a3 ./scripts/verify-public-install.sh
1673257efa50b906363ccab45f4ebcf7cb377185861a1c37221c98292c2c7cf6 ./scripts/verify-real-browser.sh
d67b73b716fa795bf166df12ad91eea74225b6c76e659d0e8b088dad221865aa ./scripts/verify.sh
+48 -22
View File
@@ -42,44 +42,62 @@ request object, or production server.
## Status
`v1.0.0-beta.1` is the first installable public beta. It is supported for
learning, classroom projects, evaluation, and compatibility feedback. It is a
semantic-version prerelease: source syntax, generated output, the runtime API,
and CLI behavior may change before final v1, and this beta is not recommended
for production deployment.
`v1.0.0-rc.1` is the current release candidate for both the compiler and the
independently tagged runtime. The intended v1 source syntax, generated API,
runtime API, CLI, diagnostics, and schemas are frozen except for
release-blocking corrections. It remains a semantic-version prerelease while
the project completes its public observation period; a finding is fixed in a
new RC rather than by moving either tag.
Maintainer-run testing has established a pre-beta baseline on native Windows
and on Linux with Go 1.25 and Go 1.26. That matrix must pass again on the exact
Beta 1 commit before its tags are published. Native macOS validation is still
pending, so macOS support is provisional in this beta. Mac learners and Go
developers are warmly invited to try it and share their macOS version,
architecture, Go version, command, and smallest useful reproduction. Community
reports broaden the evidence; maintainers remain responsible for security
review, triage, fixes, and release decisions.
Linux/amd64 and Apple Silicon macOS/arm64 are the maintained v1 execution and
release targets. Native release evidence runs with pinned Go 1.26.7 and Go
1.27.0 toolchains on both platforms; the module language directive remains Go
1.25 for consumer compatibility. WSL, native Windows, Intel macOS, and other
targets may be useful development or portability environments but are not v1
compatibility promises.
Install the beta compiler:
The evidence ledger retains the exact Beta 1 Windows and Linux observations as
historical facts. Those past results do not expand the current support policy.
Maintainers remain responsible for security review, triage, fixes, and release
decisions on both supported native targets.
Inside an application module, add the small runtime first:
```sh
go install gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.1
go get gamertan.com/sandwich-hime/sando@v1.0.0-rc.1
```
Add the small runtime to an application module:
Then install the matching release-candidate compiler:
```sh
go get gamertan.com/sandwich-hime/sando@v1.0.0-beta.1
go install gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-rc.1
```
Keep that runtime-first order. It avoids path-selection ambiguity between the
parent compiler module and its independently tagged nested runtime.
If the compiler was installed first and `go get` reports that the parent module
does not contain `sando`, seed the exact nested module without clearing the
global cache, then retry:
```sh
go mod download gamertan.com/sandwich-hime/sando@v1.0.0-rc.1
go get gamertan.com/sandwich-hime/sando@v1.0.0-rc.1
```
For a reproducible one-off or classroom invocation that does not depend on the
learner's `PATH`:
```sh
go run gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.1 --help
go run gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-rc.1 --help
```
The runtime is released first as `sando/v1.0.0-beta.1`; the compiler follows as
`v1.0.0-beta.1`. If a newly announced version is not immediately available
through a module proxy, retry after the proxy has discovered the immutable tag
or use the canonical Gitea release instructions.
The runtime implementation retains ABI `sando.v1` and zero third-party module
requirements. The coordinated RC tags make the intended v1 pair explicit even
though compiler and runtime versions remain independently addressable. Signed
tags, direct fetching, the public Go proxy, and the checksum database are
verified after publication. A newly announced version may still need a short
propagation interval before every proxy sees its immutable tag.
For repository development:
@@ -97,6 +115,14 @@ and serves it through `http://127.0.0.1:7331` with local-only reload
diagnostics. That is a Cole-shaped convenience, not a production server or a
requirement. Take the paved path—or don't.
Hime-san also provides a standard, editor-neutral language server. It analyzes
unsaved overlays with the compiler's real parser and context model, but never
generates, runs Go, executes a project, fetches a module, accesses the network,
or starts the dev supervisor. See
[the language-server contract](docs/LANGUAGE_SERVER.md). The portable Agent
Skill and VS Code preview live in the separate
[tooling repository](https://gitea.speelman.ca/gamertan/sandwich-hime-tooling).
Final-v1 installs will use the same paths with `@v1.0.0`. A version is
advertised as available only after its immutable tags, `gamertan.com`
metadata, and clean direct-fetch installation have been verified.
+97 -24
View File
@@ -21,11 +21,10 @@ while it is the current prerelease, but it is not recommended or supported as a
production-stable dependency. Syntax, generated output, runtime APIs, CLI
behavior, and diagnostics may change in a later prerelease.
Beta 1 may publish with native macOS validation pending when Windows and Linux
have passed the exact-candidate matrix and macOS is clearly marked provisional.
Community Mac results are valuable compatibility input; they do not transfer
security review, triage, remediation, or release responsibility away from the
maintainers.
Beta 1 and Beta 2 retain their historical platform evidence. The v1 RC line
raises the maintained release gate to native Linux/amd64 and Apple Silicon
macOS/arm64. WSL, native Windows, Intel macOS, and other targets remain useful
portability input but are not v1 release blockers or maintained promises.
Beta tags are signed, annotated, and immutable. Beta 1 is a source/module
release installed through the Go toolchain; it does not promise downloadable
@@ -37,9 +36,9 @@ final v1.
An RC means the intended v1 source, runtime, CLI, diagnostics, schemas, and
generated contract are frozen except for release-blocking fixes. An RC requires
maintainer-run native Linux, macOS, and Windows evidence, complete release
artifacts and provenance, signed tags, clean direct/proxy installs, and every RC
gate in this repository. Findings produce a new RC rather than a moved tag.
maintainer-run Linux/amd64 and native macOS/arm64 evidence, complete release
artifacts and provenance, signed tags, clean direct/proxy installs, and every
RC gate in this repository. Findings produce a new RC rather than a moved tag.
### Final v1
@@ -49,7 +48,11 @@ published assurance gap, and the documented RC observation period. A
deployment, example, classroom project, or case study in another repository is
neither imported nor required as release evidence.
## Beta 1 publication gates
## Beta 1 publication gates (historical)
The first beta used a broader one-time platform campaign. The completed items
below are retained as publication history; they do not define future platform
support.
Before `sando/v1.0.0-beta.1` and `v1.0.0-beta.1` are created:
@@ -68,28 +71,97 @@ Before `sando/v1.0.0-beta.1` and `v1.0.0-beta.1` are created:
generated fixtures are current under that exact binary.
7. Create signed annotated tags and publish the runtime tag first, then the
compiler tag, from the same reviewed commit.
8. Verify both documented installs from fresh `GOPROXY=direct` and public-proxy
caches. Record propagation delays as delays, not test passes.
8. Verify both documented runtime-first installs from fresh `GOPROXY=direct`
and public-proxy caches. Record propagation delays as delays, not test
passes.
The passing public commit
`113c95c21e57227b4675c9fda015ada59cc9e9a6` (tree
`a2aeb4dac22853cb3894e3e487b94bbeff5051e5`) is only the pre-beta
platform baseline. Any documentation, versioning, or code change produces a new
candidate and requires the candidate matrix to run again before tagging.
Beta 1 was published from public commit
`b7a84054d755e42285e50298e41e47f06a8325a5` (tree
`be9e118e38dfebed19f60403ededdadabe07d2aa`) after its exact-candidate
matrix passed. Future prereleases require their own candidate evidence; this
result cannot be relabeled for another commit.
## Beta 2 compiler publication gates
`v1.0.0-beta.2` is compiler-only. Do not create or move a runtime tag when the
`sando` subtree is byte-identical to `sando/v1.0.0-beta.1`.
Run its technical preflight with the retained runtime made explicit:
```sh
./scripts/release-check.sh \
--version v1.0.0-beta.2 \
--runtime-tag sando/v1.0.0-beta.1
```
In addition to every Beta 1 compiler/security/determinism gate:
1. verify protocol framing, malformed messages, shutdown, request
cancellation, UTF-16 positions, CRLF/NUL handling, bounded fuzz smoke,
overlays, source appearance/deletion, duplicates, cycles, symlink and
nested-module boundaries, completion scope, and component definitions;
2. prove the language-server package does not write, execute project code,
invoke Go, fetch, access the network, or start the development supervisor;
3. run the exact candidate on supported Go lines under executed Linux/amd64;
4. build an exact version-stamped candidate and assert the additive
`features: ["lsp-stdio"]` JSON identity;
5. publish a signed annotated compiler tag only after the reviewed sanitized
public commit is current; and
6. verify direct, public-proxy, and checksum-database installation without
altering `v1.0.0-beta.1` or `sando/v1.0.0-beta.1`.
## RC and final gates
No release candidate or v1.0.0 release occurs until every applicable gate in
this repository is evidenced, including cross-platform deterministic
generation, temporary-module compilation, fuzz/adversarial suites,
this repository is evidenced, including deterministic generation on supported
Linux and Go lanes, temporary-module compilation, fuzz/adversarial suites,
race/vet/vulnerability/license checks on the latest two supported Go lines,
development-supervisor failure tests, and reproducible repository-owned
benchmark and security results.
Release candidates require a clean canonical checkout, reviewed changelog,
compatible vanity-import metadata, reproducible binaries, signed annotated
tags, checksums, SBOMs, vulnerability results, and verification on Linux,
macOS, and Windows.
compatible vanity-import metadata, reproducible Linux/amd64 and Darwin/arm64
binaries, signed annotated tags, checksums, SBOMs, vulnerability results, and
verification on both maintained native targets. Darwin artifacts additionally
require manual Developer ID signing, notarization, stapling, and Gatekeeper
validation outside runner authority.
Human-reviewed RC evidence stays outside the source tree. After completing the
fixed document set reported by `himesan-release verify-evidence`, the release
operator seals its exact bytes and source identity once:
```sh
go run ./cmd/himesan-release evidence-manifest \
--directory "$HIMESAN_RELEASE_EVIDENCE_DIR" \
--repository gamertan/sandwich-hime \
--version v1.0.0-rc.1 \
--commit "$(git rev-parse HEAD)" \
--tree "$(git rev-parse 'HEAD^{tree}')" \
--reviewed-by "REVIEWER" \
--reviewed-at "YYYY-MM-DDTHH:MM:SSZ"
```
The manifest is created without overwrite. Changing any document or candidate
identity requires a fresh review directory and manifest; deleting a manifest
is not an approval shortcut. `release-check.sh --public` verifies the sealed
digests and identities but never substitutes for the human review itself.
Native receipts use an equally strict, extraction-scratch-free layout. Download
runner ZIPs outside this directory, then copy only each checksummed receipt pair
into the exact four lanes:
```text
$HIMESAN_NATIVE_EVIDENCE_DIR/
├── darwin-arm64-go1.26.7/TEND-CI-VERIFICATION.json{,.sha256}
├── darwin-arm64-go1.27.0/TEND-CI-VERIFICATION.json{,.sha256}
├── linux-amd64-go1.26.7/TEND-CI-VERIFICATION.json{,.sha256}
└── linux-amd64-go1.27.0/TEND-CI-VERIFICATION.json{,.sha256}
```
ZIPs, additional files, renamed lanes, development-repository identities, and
receipts for a public commit other than current canonical `main` are rejected.
The strict layout prevents extraction debris or a nearby historical run from
being mistaken for the reviewed native receipt set.
## Public source and artifacts
@@ -110,9 +182,10 @@ metadata exist, run:
scripts/verify-public-install.sh --version vX.Y.Z
```
That check exercises the documented `go install` and `go get` commands from
fresh direct-fetch and public-proxy caches. It is separate from the pre-tag,
read-only `scripts/release-check.sh`.
That check adds the nested runtime before installing the parent compiler, then
exercises both commands from fresh direct-fetch and public-proxy caches. The
order avoids the Go module-cache ambiguity documented for Beta 1. It is
separate from the pre-tag, read-only `scripts/release-check.sh`.
Release notes report hardware, commit, datasets, commands, `ns/op`,
allocations, response latency, and methodology for any performance claim.
+29 -29
View File
@@ -7,61 +7,61 @@ necessarily blockers for an earlier prerelease. 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).
## Beta 1: public learning and evaluation
## Beta 1: public learning and evaluation (historical)
Beta 1 deliberately ships before the final-v1 compatibility and artifact gates.
Its scope is classroom use, learning, prototypes, and compatibility feedback;
it is not a production-stability promise.
- [x] Define beta versus RC/final support and compatibility policy.
- [x] Establish a public pre-beta Linux/Windows matrix on Go 1.25 and Go 1.26.
- [x] Document macOS as provisional and invite useful community reports while
retaining maintainer responsibility for security and releases.
- [ ] Rerun all required Windows/Linux checks and deterministic generation on
the exact Beta 1 candidate.
- [ ] Publish immutable `sando/v1.0.0-beta.1`, then
- [x] Establish a one-time public pre-beta Linux/Windows evidence matrix on Go
1.25 and Go 1.26.
- [x] Record the untested macOS boundary without presenting it as evidence.
- [x] Rerun the historical Windows/Linux campaign and deterministic generation
on the exact Beta 1 candidate.
- [x] Publish immutable `sando/v1.0.0-beta.1`, then
`v1.0.0-beta.1`, from the reviewed public commit.
- [ ] Verify clean direct and public-proxy installs after publication.
- [ ] Complete native macOS maintainer validation. This is an RC/final gate,
not a Beta 1 gate.
- [x] Verify clean runtime-first direct and public-proxy installs after
publication.
## Compiler and runtime for RC/final
- [ ] Freeze and machine-check the compiler, CLI, diagnostic, schema, generated,
- [x] Freeze and machine-check the compiler, CLI, diagnostic, schema, generated,
and runtime compatibility contracts.
- [ ] Repeat compiler-owned deterministic golden output across Linux, macOS,
and Windows on the exact candidate.
- [ ] Compile temporary consumer modules using committed Go and only the Apache
- [x] Repeat compiler-owned deterministic golden output across the supported
Linux and macOS Go lanes on the exact candidate.
- [x] Compile temporary consumer modules using committed Go and only the Apache
runtime.
- [ ] Run the parser, delimiter, context, path, and source-map release fuzz
- [x] Run the parser, delimiter, context, path, and source-map release fuzz
campaign.
- [ ] Evidence adversarial escaping and filesystem cases.
- [ ] Pass test, race, vet, vulnerability, and license gates on the latest two
- [x] Evidence adversarial escaping and filesystem cases.
- [x] Pass test, race, vet, vulnerability, and license gates on the latest two
supported Go lines.
- [ ] Reproduce signed compiler/runtime release artifacts, checksums, and SBOMs.
- [x] Reproduce compiler/runtime release artifacts, checksums, and SBOMs; sign
and notarize the macOS distribution outside runner authority.
## Development supervisor for RC/final
- [ ] Generation/build/start/health failures keep the previous healthy server
- [x] Generation/build/start/health failures keep the previous healthy server
live.
- [ ] SSE reconnect/reload and mapped overlay diagnostics pass browser-level
- [x] SSE reconnect/reload and mapped overlay diagnostics pass browser-level
tests.
- [ ] CSP hash injection, fragment/API/download exclusion, and cache disabling
- [x] CSP hash injection, fragment/API/download exclusion, and cache disabling
pass.
- [ ] Replaced and interrupted child processes leave no descendants on
- [x] Replaced and interrupted child processes leave no descendants on
supported systems.
## Repository-owned release evidence
- [ ] Differentially test contextual escaping against Go's documented
- [x] Differentially test contextual escaping against Go's documented
`html/template` safety baseline.
- [ ] Reproduce repository-owned synthetic benchmark cases and methodology from
- [x] Reproduce repository-owned synthetic benchmark cases and methodology from
a clean checkout.
- [ ] Review generated output for stable provenance, source mappings, and
- [x] Review generated output for stable provenance, source mappings, and
absence of compiler-license headers.
- [ ] Document the production boundary: committed generated Go plus the Apache
- [x] Document the production boundary: committed generated Go plus the Apache
runtime, with no compiler or development supervisor in the deployed binary.
- [ ] Keep unsupported or unmeasured performance and production claims out of
- [x] Keep unsupported or unmeasured performance and production claims out of
release materials.
## Final public launch
@@ -70,9 +70,9 @@ it is not a production-stability promise.
contribution process, and pre-registration trademark terms.
- [ ] Complete name clearance, security-mailbox recovery, release signing, and
two-person credential recovery.
- [ ] Verify `gamertan.com` vanity metadata and documented installs from clean
- [ ] Verify `gamertan.com` vanity metadata and documented RC installs from clean
machines.
- [ ] Confirm the sanitized public Gitea source contains no private paths,
identifiers, history, or unsupported claims.
- [ ] Publish and observe a signed RC on every supported native platform.
- [ ] Publish and observe signed RC artifacts on Linux/amd64 and Darwin/arm64.
- [ ] Publish `sando/v1.0.0`, then `v1.0.0`, without moving either tag.
+47 -15
View File
@@ -2,13 +2,17 @@
# Security policy
Sandwich Hime `v1.0.0-beta.1` is supported within a deliberately narrow
scope: learning, classroom projects, evaluation, and compatibility testing.
Security reports are welcome and receive best-effort maintainer assessment and
fixes while Beta 1 is the current prerelease. This is not production support,
an SLA, a fitness guarantee, or a promise that a fix will preserve beta APIs.
Sandwich Hime compiler `v1.0.0-rc.1` and runtime
`sando/v1.0.0-rc.1` are the current release-candidate pair. The intended v1
interfaces are frozen except for release-blocking corrections while the RC
completes its public observation period. Security reports are welcome and
receive best-effort maintainer assessment and fixes while this pair is current.
This is not an SLA, certification, fitness guarantee, or final-v1 support
promise.
The community is invited to help find compatibility gaps, especially on macOS.
The community is invited to help find portability gaps outside the maintained
Linux/amd64 and Darwin/arm64 targets, but those reports do not create a support
or release commitment.
That invitation does not outsource security assurance. Maintainers retain
responsibility for vulnerability review, triage, remediation decisions,
advisories, and release decisions.
@@ -17,11 +21,12 @@ advisories, and release decisions.
| Version | Security status |
| --- | --- |
| `v1.0.0-beta.1` and `sando/v1.0.0-beta.1` | Current evaluation/classroom prerelease once published; best-effort security assessment and fixes; interfaces may change |
| `v1.0.0-rc.1` compiler and `sando/v1.0.0-rc.1` runtime | Current release-candidate pair; intended v1 interfaces frozen except for release-blocking corrections; best-effort security assessment and fixes during the RC observation period |
| `v1.0.0-beta.2` compiler and `sando/v1.0.0-beta.1` runtime | Superseded evaluation/classroom pair after RC publication |
| Public `main` | Development source; reports welcome, but no compatibility or production-support promise |
| Older prereleases | Superseded when a newer prerelease or final version is published; reports are still triaged to determine affected versions |
Beta support lasts until it is superseded or explicitly withdrawn in the
Prerelease support lasts until it is superseded or explicitly withdrawn in the
supported-version table and release notes. Published tags are immutable. A
security fix is issued as a new version, never by silently replacing a tag.
@@ -34,7 +39,7 @@ public issue.
If that new mailbox rejects or bounces a message, retain the report and open a
canonical Gitea issue containing only the fact that the private security contact
failed. Do not include technical details or sensitive data. The maintainer will
publish a corrected private route. Ordinary usage, classroom, and macOS
publish a corrected private route. Ordinary usage, classroom, and portability
compatibility reports that do not reveal a vulnerability may use a public issue.
Helpful reports include:
@@ -74,6 +79,28 @@ 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.
## Severity, advisories, retractions, and CVEs
Triage considers attacker prerequisites, affected trust boundary, data or code
impact, availability impact, default reachability, and whether exploitation
crosses the documented application-owned capability boundary. The project uses
plain-language critical, high, moderate, and low labels; it does not publish a
CVSS score unless the vector and calculation have been reviewed.
An accepted vulnerability affecting a published version receives a canonical
advisory or release security note identifying affected and fixed versions,
mitigations, credit, and disclosure chronology as safely available. The
maintainer requests a CVE when a disclosed vulnerability materially affects a
published release and a stable public advisory exists. A CVE is an identifier,
not evidence of severity or independent validation.
Published source and tags are never deleted or moved to hide a faulty release.
When Go tooling supports the boundary, a later immutable module version may
use a `retract` directive with a concise rationale. Otherwise the project
publishes a superseding version and marks the affected release in canonical
release notes. Retraction does not erase source, evidence, or the disclosure
record.
## Scope and trust boundary
The most useful reports concern:
@@ -133,11 +160,16 @@ 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.
Beta 1 publication requires signed annotated source tags, but may precede the
complete prebuilt-artifact and key-recovery system. Signed binaries, checksums,
an SBOM, reproducible archives, and complete source/build provenance are
release-candidate and final-v1 gates. Their absence from a source-only beta must
not be read as evidence of artifact authenticity beyond the verified tag and
canonical source.
The language server is read-only and standard-library-only. Its tests
exercise bounded framing, malformed messages, cancellation, UTF-16 positions,
unsaved overlays, workspace re-indexing, and no generated writes. Source review
forbids process execution and network-client imports inside the server. Editor
workspace trust and executable resolution remain editor-client responsibilities.
Beta 1 uses signed annotated source tags but predates the complete artifact
system. The RC adds signed or checksummed binaries, SPDX SBOMs, reproducible
archives, and source/build provenance. Those controls establish artifact
identity and provenance; they do not constitute an independent security audit
or prove the absence of vulnerabilities.
This policy is practical project guidance, not legal advice.
+52 -1
View File
@@ -21,7 +21,23 @@ func Card(card model.Card)
The v1 target is `go`. Other target names are rejected. The explicit target is an architectural seam for a possible future San backend; it is not a promise that such a backend exists.
The header permits one package clause, ordinary Go imports, and one bodyless, receiverless function declaration. The component name is the function name and its parameters form the generated typed API. Multiple components, methods, global declarations, and executable initialization in the header are errors.
The header permits one package clause, ordinary Go imports, and one bodyless,
receiverless function declaration. The component name is the function name and
its parameters form the generated typed API. A Go type-parameter list is part
of the v1 grammar and is preserved after `go/format` normalization:
```sando
<?sando go
package views
func List[T ~string](values []T)
?>
<ul><? for _, value := range values { ?><li><?= value ?></li><? } ?></ul>
```
Constraints, inference, and instantiation use ordinary Go rules; Sandwich Hime
does not add a second generic type system. Multiple components, methods, global
declarations, and executable initialization in the header are errors.
## Template tags
@@ -42,6 +58,12 @@ For `func Card(card model.Card)`, generation emits:
func Card(card model.Card) sando.Component
```
For the generic example above, generation emits:
```go
func List[T ~string](values []T) sando.Component
```
The component captures its typed parameters and renders later with a context and writer. All static writes, escaping operations, nested component renders, and application-provided writers propagate errors.
Generated files are adjacent to their source (`card.sando.go`), formatted with `go/format`, and contain the compiler version, runtime ABI, source digest, and source mappings. Hime-san does not inject the compiler's AGPL license identifier or copyright claim. An application rightsholder remains free to select AGPL intentionally through the application's own license policy.
@@ -62,6 +84,35 @@ V1 recognizes:
Ordinary URL values are attribute-escaped and rejected at render time when their normalized scheme is dangerous. Only `sando.TrustedURL`, made by an explicit `sando.TrustURL` call in trusted Go code, may bypass that scheme policy. The analogous trusted HTML, JavaScript, and CSS types are opaque and have conspicuous constructors.
### V1 output matrix
| Template position | Ordinary value | Explicit trusted value | Unsupported or rejected |
| --- | --- | --- | --- |
| HTML text | HTML-escaped by `WriteText` | `TrustedHTML` is written verbatim | Dynamic markup structure remains the caller's capability boundary |
| `title`/`textarea` RCDATA | HTML-escaped by `WriteRCDATA` | Trusted wrappers are still escaped | Closing the element through a value |
| Quoted ordinary attribute | HTML-escaped by `WriteAttr` | Trusted wrappers stringify, then escape | Unquoted values, dynamic names, and event-handler attributes |
| Quoted URL attribute | Scheme-checked, normalized, then attribute-escaped by `WriteURL` | `TrustedURL` bypasses only the scheme check | Ambiguous schemes, controls, and non-allowlisted schemes |
| `script` data | Not accepted | `TrustedJS` is written verbatim | Plain strings and ambiguous escaped-script parser states |
| `style` data | Not accepted | `TrustedCSS` is written verbatim | Plain strings and dynamic style attributes |
| Ordinary HTML content | `<?~` renders a `Component` | Handwritten components are explicit trusted-output capabilities | Component rendering in attributes, tags, comments, RCDATA, script, or style |
Ordinary values use `fmt.Sprint` semantics before contextual normalization.
Invalid UTF-8 and NUL bytes become U+FFFD in text, RCDATA, attribute, and URL
helpers. A nil render context, writer, component, typed-nil component, or
typed-nil writer produces the corresponding stable sentinel error rather than
a panic. All writer errors and short writes propagate.
Relative URLs and the `http`, `https`, `mailto`, and `tel` schemes are accepted.
Leading and trailing Unicode whitespace is removed before classification;
ASCII controls, ambiguous scheme syntax, and every other ordinary scheme are
rejected before bytes are written. `TrustedURL` does not bypass quoted-attribute
escaping.
Handwritten Go statements, handwritten components, and every `Trust*` call are
application-owned capabilities. Sandwich Hime does not sanitize or sandbox
trusted source, prevent panics or blocking inside application code, provide
HTTP routing, or infer that a string became safe elsewhere in the program.
## Compatibility
V1 is a clean break from the 2025 prototype. `.go.hime`, injected `himesan` helper directories, `SandoName(io.Writer)` functions, Go plugins, and nested demonstration modules are not accepted or generated. `.san` is not and will never be a Sandwich Hime extension.
+213
View File
@@ -0,0 +1,213 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Command himesan-release creates deterministic unsigned artifacts and native
// verification receipts. Signing and notarization intentionally remain outside
// this command and outside unattended runner authority.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"gamertan.com/sandwich-hime/internal/releaseartifact"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "himesan-release: %v\n", err)
os.Exit(1)
}
}
func run(arguments []string) error {
if len(arguments) == 0 {
return errors.New("usage: himesan-release <package|receipt|evidence-manifest|verify-evidence|verify-native|extract-macos|finalize-macos> [options]")
}
switch arguments[0] {
case "package":
return runPackage(arguments[1:])
case "receipt":
return runReceipt(arguments[1:])
case "evidence-manifest":
return runEvidenceManifest(arguments[1:])
case "verify-evidence":
return runVerifyEvidence(arguments[1:])
case "verify-native":
return runVerifyNative(arguments[1:])
case "extract-macos":
return runExtractMacOS(arguments[1:])
case "finalize-macos":
return runFinalizeMacOS(arguments[1:])
default:
return fmt.Errorf("unknown command %q", arguments[0])
}
}
func runVerifyNative(arguments []string) error {
flags := flag.NewFlagSet("verify-native", flag.ContinueOnError)
var directory string
var expected releaseartifact.NativeReceiptExpectation
flags.StringVar(&directory, "directory", "", "four-lane native receipt directory")
flags.StringVar(&expected.Repository, "repository", "", "repository identity")
flags.StringVar(&expected.Commit, "commit", "", "source commit")
flags.StringVar(&expected.Tree, "tree", "", "source tree")
if err := flags.Parse(arguments); err != nil {
return err
}
summary, err := releaseartifact.VerifyNativeReceiptSet(directory, expected)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(summary)
}
func runExtractMacOS(arguments []string) error {
flags := flag.NewFlagSet("extract-macos", flag.ContinueOnError)
var archive, checksum, output string
flags.StringVar(&archive, "archive", "", "unsigned Darwin/arm64 archive")
flags.StringVar(&checksum, "sha256", "", "approved archive SHA-256")
flags.StringVar(&output, "output", "", "empty extraction parent directory")
if err := flags.Parse(arguments); err != nil {
return err
}
root, err := releaseartifact.ExtractVerifiedMacOSPackage(archive, checksum, output)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]string{"root": root, "unsigned_archive_sha256": checksum})
}
func runFinalizeMacOS(arguments []string) error {
flags := flag.NewFlagSet("finalize-macos", flag.ContinueOnError)
var options releaseartifact.MacOSSigningOptions
flags.StringVar(&options.Directory, "directory", "", "extracted signed distribution directory")
flags.StringVar(&options.UnsignedArchiveSHA256, "unsigned-archive-sha256", "", "approved unsigned archive SHA-256")
flags.StringVar(&options.Identity, "identity", "", "Developer ID identity")
flags.StringVar(&options.Identifier, "identifier", "", "signed binary identifier")
flags.StringVar(&options.FinalizedAt, "finalized-at", "", "RFC3339 finalization time")
if err := flags.Parse(arguments); err != nil {
return err
}
if err := releaseartifact.FinalizeSignedMacOSDistribution(options); err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"valid": true, "directory": options.Directory})
}
func runEvidenceManifest(arguments []string) error {
flags := flag.NewFlagSet("evidence-manifest", flag.ContinueOnError)
var directory string
var identity releaseartifact.EvidenceIdentity
flags.StringVar(&directory, "directory", "", "reviewed evidence directory")
bindEvidenceIdentityFlags(flags, &identity, true)
if err := flags.Parse(arguments); err != nil {
return err
}
checksum, err := releaseartifact.WriteEvidenceManifest(directory, identity)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]string{"manifest": filepath.Join(directory, "RELEASE-EVIDENCE.json"), "sha256": checksum})
}
func runVerifyEvidence(arguments []string) error {
flags := flag.NewFlagSet("verify-evidence", flag.ContinueOnError)
var directory string
var identity releaseartifact.EvidenceIdentity
flags.StringVar(&directory, "directory", "", "sealed evidence directory")
bindEvidenceIdentityFlags(flags, &identity, false)
if err := flags.Parse(arguments); err != nil {
return err
}
if err := releaseartifact.VerifyEvidenceManifest(directory, identity); err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"valid": true, "files": releaseartifact.RequiredEvidenceFiles()})
}
func bindEvidenceIdentityFlags(flags *flag.FlagSet, identity *releaseartifact.EvidenceIdentity, review bool) {
flags.StringVar(&identity.Repository, "repository", "", "canonical repository identity")
flags.StringVar(&identity.Version, "version", "", "candidate semantic version")
flags.StringVar(&identity.Commit, "commit", "", "source commit")
flags.StringVar(&identity.Tree, "tree", "", "source tree")
if review {
flags.StringVar(&identity.ReviewedBy, "reviewed-by", "", "human reviewer identity")
flags.StringVar(&identity.ReviewedAt, "reviewed-at", "", "RFC3339 review time")
}
}
func runPackage(arguments []string) error {
flags := flag.NewFlagSet("package", flag.ContinueOnError)
var options releaseartifact.PackageOptions
flags.StringVar(&options.Version, "version", "", "candidate semantic version")
flags.StringVar(&options.Commit, "commit", "", "source commit")
flags.StringVar(&options.Tree, "tree", "", "source tree")
flags.StringVar(&options.GoVersion, "go-version", "", "Go toolchain identity")
flags.StringVar(&options.GOOS, "goos", "", "target operating system")
flags.StringVar(&options.GOARCH, "goarch", "", "target architecture")
flags.StringVar(&options.BinaryPath, "binary", "", "unsigned native binary")
flags.StringVar(&options.LicensePath, "license", "LICENSE", "license text")
flags.StringVar(&options.ReleaseNotes, "release-notes", "RELEASE.md", "release notes")
flags.StringVar(&options.OutputDirectory, "output", "", "output directory")
flags.Int64Var(&options.SourceDateEpoch, "source-date-epoch", 0, "fixed Unix timestamp")
if err := flags.Parse(arguments); err != nil {
return err
}
result, err := releaseartifact.Package(options)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(result)
}
func runReceipt(arguments []string) error {
flags := flag.NewFlagSet("receipt", flag.ContinueOnError)
var receipt releaseartifact.Receipt
var output, gates, generatedFiles string
flags.StringVar(&output, "output", "", "receipt output path")
flags.StringVar(&receipt.Repository, "repository", "", "repository identity")
flags.StringVar(&receipt.Commit, "commit", "", "source commit")
flags.StringVar(&receipt.Tree, "tree", "", "source tree")
flags.StringVar(&receipt.GOOS, "goos", "", "native operating system")
flags.StringVar(&receipt.GOARCH, "goarch", "", "native architecture")
flags.StringVar(&receipt.GoVersion, "go-version", "", "Go toolchain identity")
flags.StringVar(&receipt.RunnerVersion, "runner-version", "", "Gitea Runner version")
flags.StringVar(&receipt.RunnerName, "runner-name", "", "runner identity")
flags.StringVar(&receipt.UnsignedArtifactSHA, "artifact-sha256", "", "optional unsigned artifact digest")
flags.StringVar(&receipt.CompletedAt, "completed-at", "", "RFC3339 completion time")
flags.StringVar(&gates, "gates", "", "comma-separated successful gates")
flags.StringVar(&generatedFiles, "generated-files", "", "comma-separated generated output paths")
if err := flags.Parse(arguments); err != nil {
return err
}
if output == "" {
return errors.New("output is required")
}
receipt.SuccessfulGates = splitList(gates)
digest, err := releaseartifact.DigestFiles(splitList(generatedFiles))
if err != nil {
return err
}
receipt.GeneratedDigest = digest
checksum, err := releaseartifact.WriteReceipt(output, receipt)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]string{"receipt": output, "sha256": checksum})
}
func splitList(value string) []string {
var values []string
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" {
values = append(values, item)
}
}
return values
}
+145
View File
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: AGPL-3.0-only
package main
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"testing"
"gamertan.com/sandwich-hime/internal/testpath"
)
type contractSchema struct {
AdditionalProperties bool `json:"additionalProperties"`
Required []string `json:"required"`
Properties map[string]json.RawMessage `json:"properties"`
}
func TestV1CLIHelpContract(t *testing.T) {
t.Parallel()
want, err := os.ReadFile(filepath.Join("..", "..", "contracts", "himesan-cli-help-v1.txt"))
if err != nil {
t.Fatal(err)
}
want = bytes.TrimPrefix(want, []byte("# SPDX-License-Identifier: AGPL-3.0-only\n\n"))
var output bytes.Buffer
printHelp(&output)
if !bytes.Equal(output.Bytes(), want) {
t.Fatalf("CLI help contract drifted\n--- want ---\n%s--- got ---\n%s", want, output.Bytes())
}
}
func TestV1VersionJSONSchemaMatchesOutput(t *testing.T) {
t.Parallel()
schema := readContractSchema(t, "himesan-version-output-v1.schema.json")
var stdout, stderr bytes.Buffer
if code := run(context.Background(), []string{"version", "--json"}, &stdout, &stderr); code != 0 {
t.Fatalf("version exit code = %d: %s", code, stderr.String())
}
var output map[string]any
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatal(err)
}
assertObjectShape(t, output, schema, "version output")
}
func TestV1OperationJSONSchemaMatchesSuccessAndDiagnosticOutput(t *testing.T) {
t.Parallel()
schema := readContractSchema(t, "himesan-operation-output-v1.schema.json")
directory := testpath.TempDir(t)
source := filepath.Join(directory, "page.sando")
if err := os.WriteFile(source, []byte("<?sando go\npackage views\nfunc Page()\n?>\n<p>page</p>\n"), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
if code := run(context.Background(), []string{"check", "--json", source}, &stdout, &stderr); code != 1 {
t.Fatalf("missing-output check exit code = %d, want 1: %s", code, stderr.String())
}
var output map[string]any
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatal(err)
}
assertObjectShape(t, output, schema, "operation output")
resultSchema := nestedSchema(t, schema.Properties["result"])
result, ok := output["result"].(map[string]any)
if !ok {
t.Fatalf("result = %T, want object", output["result"])
}
assertObjectShape(t, result, resultSchema, "operation result")
files, ok := result["files"].([]any)
if !ok || len(files) != 1 {
t.Fatalf("files = %#v, want one item", result["files"])
}
filesProperty := rawObject(t, resultSchema.Properties["files"])
fileSchema := nestedSchema(t, filesProperty["items"])
assertObjectShape(t, files[0].(map[string]any), fileSchema, "file result")
diagnostics, ok := result["diagnostics"].([]any)
if !ok || len(diagnostics) == 0 {
t.Fatalf("diagnostics = %#v, want at least one item", result["diagnostics"])
}
diagnosticsProperty := rawObject(t, resultSchema.Properties["diagnostics"])
diagnosticSchema := nestedSchema(t, diagnosticsProperty["items"])
assertObjectShape(t, diagnostics[0].(map[string]any), diagnosticSchema, "diagnostic")
}
func readContractSchema(t *testing.T, name string) contractSchema {
t.Helper()
contents, err := os.ReadFile(filepath.Join("..", "..", "contracts", name))
if err != nil {
t.Fatal(err)
}
var schema contractSchema
if err := json.Unmarshal(contents, &schema); err != nil {
t.Fatalf("decode %s: %v", name, err)
}
if schema.AdditionalProperties || len(schema.Properties) == 0 {
t.Fatalf("%s is not a closed object schema", name)
}
return schema
}
func nestedSchema(t *testing.T, raw json.RawMessage) contractSchema {
t.Helper()
var schema contractSchema
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatal(err)
}
return schema
}
func rawObject(t *testing.T, raw json.RawMessage) map[string]json.RawMessage {
t.Helper()
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
return object
}
func assertObjectShape(t *testing.T, actual map[string]any, schema contractSchema, label string) {
t.Helper()
actualKeys := make([]string, 0, len(actual))
for key := range actual {
actualKeys = append(actualKeys, key)
if _, declared := schema.Properties[key]; !declared {
t.Fatalf("%s emitted undeclared property %q", label, key)
}
}
sort.Strings(actualKeys)
for _, required := range schema.Required {
if _, present := actual[required]; !present {
t.Fatalf("%s omitted required property %q (got %v)", label, required, actualKeys)
}
}
if len(actual) == 0 || reflect.ValueOf(actual).IsNil() {
t.Fatalf("%s is empty", label)
}
}
+30 -4
View File
@@ -18,6 +18,7 @@ import (
"gamertan.com/sandwich-hime/internal/compiler"
"gamertan.com/sandwich-hime/internal/devserver"
"gamertan.com/sandwich-hime/internal/lsp"
"gamertan.com/sandwich-hime/internal/version"
)
@@ -38,6 +39,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return runCompilerCommand(ctx, args[0], args[1:], stdout, stderr, compiler.Check)
case "dev":
return runDev(ctx, args[1:], stdout, stderr)
case "lsp":
return runLSP(ctx, args[1:], os.Stdin, stdout, stderr)
case "version":
return runVersion(args[1:], stdout, stderr)
case "help", "-h", "--help":
@@ -116,10 +119,11 @@ func runVersion(args []string, stdout, stderr io.Writer) int {
return 2
}
information := struct {
Compiler string `json:"compiler"`
RuntimeABI string `json:"runtime_abi"`
Go string `json:"go"`
}{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version()}
Compiler string `json:"compiler"`
RuntimeABI string `json:"runtime_abi"`
Go string `json:"go"`
Features []string `json:"features"`
}{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version(), Features: []string{"lsp-stdio"}}
if *jsonOutput {
if err := json.NewEncoder(stdout).Encode(information); err != nil {
fmt.Fprintf(stderr, "himesan: encode version: %v\n", err)
@@ -131,6 +135,27 @@ func runVersion(args []string, stdout, stderr io.Writer) int {
return 0
}
func runLSP(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
flags := flag.NewFlagSet("lsp", flag.ContinueOnError)
flags.SetOutput(stderr)
stdio := flags.Bool("stdio", false, "serve Language Server Protocol JSON-RPC over stdin/stdout")
if err := flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if !*stdio || flags.NArg() != 0 {
fmt.Fprintln(stderr, "himesan lsp requires exactly --stdio")
return 2
}
if err := lsp.Run(ctx, lsp.Options{Input: stdin, Output: stdout, LogOutput: stderr}); err != nil {
fmt.Fprintf(stderr, "himesan lsp: %v\n", err)
return 1
}
return 0
}
type stringList []string
func (values *stringList) String() string { return strings.Join(*values, ",") }
@@ -312,6 +337,7 @@ func printHelp(output io.Writer) {
fmt.Fprintln(output, " himesan check [--json] [paths...] validate sources and committed output without writes")
fmt.Fprintln(output, " himesan bless [--json] [paths...] friendly read-only alias for check")
fmt.Fprintln(output, " himesan dev [flags] [package] [-- app-args...] run the loopback last-good supervisor")
fmt.Fprintln(output, " himesan lsp --stdio run the read-only language server")
fmt.Fprintln(output, " himesan version [--json] print compiler and runtime ABI versions")
fmt.Fprintln(output)
fmt.Fprintln(output, "Templates use .sando; .san remains exclusively San language source.")
+14 -3
View File
@@ -12,6 +12,7 @@ import (
"testing"
"gamertan.com/sandwich-hime/internal/compiler"
"gamertan.com/sandwich-hime/internal/testpath"
)
func TestRunHelpVersionAndUnknownCommand(t *testing.T) {
@@ -30,14 +31,24 @@ func TestRunHelpVersionAndUnknownCommand(t *testing.T) {
if code := run(context.Background(), []string{"version", "--json"}, &stdout, &stderr); code != 0 {
t.Fatalf("version exit code = %d: %s", code, stderr.String())
}
var versionResult map[string]string
var versionResult struct {
Compiler string `json:"compiler"`
RuntimeABI string `json:"runtime_abi"`
Features []string `json:"features"`
}
if err := json.Unmarshal(stdout.Bytes(), &versionResult); err != nil {
t.Fatalf("version JSON: %v", err)
}
if versionResult["compiler"] == "" || versionResult["runtime_abi"] != compiler.RuntimeABI {
if versionResult.Compiler == "" || versionResult.RuntimeABI != compiler.RuntimeABI || len(versionResult.Features) != 1 || versionResult.Features[0] != "lsp-stdio" {
t.Fatalf("version result = %#v", versionResult)
}
stdout.Reset()
stderr.Reset()
if code := run(context.Background(), []string{"lsp"}, &stdout, &stderr); code != 2 {
t.Fatalf("lsp without --stdio exit code = %d, want 2", code)
}
stdout.Reset()
stderr.Reset()
if code := run(context.Background(), []string{"rebuke"}, &stdout, &stderr); code != 2 {
@@ -57,7 +68,7 @@ func TestRunHelpVersionAndUnknownCommand(t *testing.T) {
func TestGenerateCheckBlessAndJSONDiagnostics(t *testing.T) {
t.Parallel()
directory := t.TempDir()
directory := testpath.TempDir(t)
sourcePath := filepath.Join(directory, "hello.sando")
source := "<?sando go\npackage views\nfunc Hello(name string)\n?>\n<p><?= name ?></p>\n"
if err := os.WriteFile(sourcePath, []byte(source), 0o600); err != nil {
+94
View File
@@ -0,0 +1,94 @@
# SPDX-License-Identifier: AGPL-3.0-only
HIM1001
HIM1002
HIM1101
HIM1102
HIM1103
HIM1104
HIM1105
HIM1110
HIM1111
HIM1112
HIM1113
HIM1114
HIM1115
HIM1116
HIM1117
HIM1118
HIM1119
HIM1120
HIM1121
HIM1122
HIM1123
HIM1201
HIM1202
HIM1203
HIM1210
HIM1301
HIM1302
HIM1303
HIM1310
HIM1311
HIM1320
HIM1321
HIM1322
HIM1323
HIM1324
HIM1325
HIM1326
HIM1327
HIM1328
HIM1329
HIM1330
HIM1331
HIM1332
HIM1333
HIM1340
HIM1341
HIM1342
HIM1343
HIM1344
HIM1345
HIM1346
HIM1347
HIM1350
HIM1351
HIM1352
HIM1353
HIM1354
HIM1355
HIM1356
HIM1357
HIM1401
HIM1410
HIM1500
HIM1501
HIM1901
HIM1902
HIM1903
HIM2001
HIM2002
HIM2003
HIM2004
HIM2005
HIM2006
HIM2007
HIM2008
HIM2009
HIM2010
HIM2011
HIM2012
HIM2013
HIM2014
HIM2101
HIM2102
HIM2103
HIM2104
HIM2110
HIM2201
HIM2202
HIM2203
HIM2204
HIM2205
HIM2901
+8
View File
@@ -0,0 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-only
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler <compiler-version>
// himesan:runtime-abi <runtime-abi>
// himesan:source-sha256 <lowercase-sha256>
var _ = <sando-import>.ABISandoV1
//line <source-path>:<line>:<column>
+14
View File
@@ -0,0 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-only
Sandwich Hime / Hime-san — HTML-first typed components for Go
Usage:
himesan generate [--json] [paths...] generate adjacent .sando.go files
himesan gen [--json] [paths...] alias for generate
himesan check [--json] [paths...] validate sources and committed output without writes
himesan bless [--json] [paths...] friendly read-only alias for check
himesan dev [flags] [package] [-- app-args...] run the loopback last-good supervisor
himesan lsp --stdio run the read-only language server
himesan version [--json] print compiler and runtime ABI versions
Templates use .sando; .san remains exclusively San language source.
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sandwichhime.com/schema/himesan-config-v1.schema.json",
"title": "Hime-san local development configuration v1",
"type": "object",
"additionalProperties": false,
"required": ["version"],
"properties": {
"version": {"const": 1},
"sourceRoots": {
"type": "array",
"items": {"type": "string", "minLength": 1, "pattern": "^[^\\u0000]+$"},
"minItems": 1,
"default": ["."]
},
"goPackage": {"type": "string", "minLength": 1, "pattern": "^[^\\u0000\\r\\n]+$", "default": "."},
"appArgs": {"type": "array", "items": {"type": "string", "pattern": "^[^\\u0000]*$"}, "default": []},
"listenAddressEnv": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"default": "HIMESAN_LISTEN_ADDR"
},
"healthPath": {"type": "string", "pattern": "^/(?!/)[^?#\\u0000\\r\\n]*$", "default": "/"},
"proxyAddress": {
"type": "string",
"anyOf": [
{"pattern": "^127(?:\\.[0-9]{1,3}){3}:[0-9]{1,5}$"},
{"pattern": "^\\[::1\\]:[0-9]{1,5}$"}
],
"default": "127.0.0.1:7331"
},
"additionalWatchRoots": {
"type": "array",
"items": {"type": "string", "minLength": 1, "pattern": "^[^\\u0000]+$"},
"default": []
}
}
}
@@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sandwichhime.com/schema/himesan-operation-output-v1.schema.json",
"title": "Hime-san generate/check JSON result v1",
"type": "object",
"additionalProperties": false,
"required": ["command", "ok", "result"],
"properties": {
"command": {"enum": ["generate", "check", "bless"]},
"ok": {"type": "boolean"},
"result": {
"type": "object",
"additionalProperties": false,
"required": ["files", "discovered", "changed", "unchanged", "stale", "missing"],
"properties": {
"files": {
"type": ["array", "null"],
"items": {
"type": "object",
"additionalProperties": false,
"required": ["source_path", "output_path", "changed", "stale", "missing"],
"properties": {
"source_path": {"type": "string"},
"output_path": {"type": "string"},
"changed": {"type": "boolean"},
"stale": {"type": "boolean"},
"missing": {"type": "boolean"}
}
}
},
"diagnostics": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["path", "line", "column", "code", "severity", "message"],
"properties": {
"path": {"type": "string"},
"line": {"type": "integer", "minimum": 1},
"column": {"type": "integer", "minimum": 1},
"code": {"type": "string", "pattern": "^HIM[0-9]{4}$"},
"severity": {"enum": ["error", "warning"]},
"message": {"type": "string"}
}
}
},
"discovered": {"type": "integer", "minimum": 0},
"changed": {"type": "integer", "minimum": 0},
"unchanged": {"type": "integer", "minimum": 0},
"stale": {"type": "integer", "minimum": 0},
"missing": {"type": "integer", "minimum": 0}
}
}
}
}
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sandwichhime.com/schema/himesan-version-output-v1.schema.json",
"title": "Hime-san version JSON result v1",
"type": "object",
"additionalProperties": false,
"required": ["compiler", "runtime_abi", "go", "features"],
"properties": {
"compiler": {"type": "string", "minLength": 1},
"runtime_abi": {"const": "sando.v1"},
"go": {"type": "string", "minLength": 1},
"features": {
"type": "array",
"prefixItems": [{"const": "lsp-stdio"}],
"minItems": 1,
"maxItems": 1
}
}
}
+22
View File
@@ -9,6 +9,28 @@ synthetic cases under the published method. Only reproduced improvements become
marketing claims. Microbenchmarks do not justify claims about request
throughput, database-heavy pages, or whole-application latency.
The threshold was fixed before measuring the RC. On each maintained native
platform and toolchain, ten benchmark samples use the exact output-equivalent
`BenchmarkV1Corpus*` pair. Sandwich Hime passes when its median `ns/op` and
`B/op` are each no more than 125% of `html/template`, and its median
allocations/op are no more than two allocations above `html/template`.
Any failed platform/toolchain pair is a material regression. Timing is reviewed
from raw samples rather than enforced in ordinary CI, where host contention
would turn a performance policy into a flaky correctness gate.
Run:
```sh
cd sando
go test -run '^TestBenchmarkCorpusEquivalent$' \
-bench '^BenchmarkV1Corpus' -benchmem -benchtime=2s -count=10
```
`benchmarkSandoComponent` intentionally mirrors generated writer calls and
captures the same typed view used by the parsed standard template. This is a
runtime renderer microbenchmark; it excludes parsing, compiler execution,
HTTP, routing, logging, databases, and deployment.
Benchmark fixtures must be self-contained, synthetic, reviewable, and committed
to this repository. Application-specific datasets and deployment measurements
belong with their applications and are neither copied here nor treated as core
+54 -33
View File
@@ -2,19 +2,18 @@
# Compatibility policy
## Beta 1 contract
## Current release-candidate contract
`v1.0.0-beta.1` and `sando/v1.0.0-beta.1` are semantic-version
prereleases. They are supported for learning, classroom projects, evaluation,
and compatibility testing. Before final v1, source syntax, generated output,
the runtime API, CLI behavior, diagnostics, and configuration may change
without compatibility shims. Every public change must still be documented and
generation must remain deterministic.
Compiler `v1.0.0-rc.1` and runtime `sando/v1.0.0-rc.1` are the current
semantic-version prereleases. The intended v1 source syntax, generated API,
runtime API, CLI behavior, diagnostics, and configuration schemas are frozen
except for release-blocking corrections. Every correction receives a new
immutable RC, documentation, and deterministic generation evidence.
The beta is not a production-stability commitment. Maintainers accept and
The RC is not yet the final-v1 support commitment. Maintainers accept and
triage security reports within the boundary described in
[SECURITY.md](../SECURITY.md), but cannot promise that a beta fix preserves its
public API.
[SECURITY.md](../SECURITY.md). A security correction may intentionally fail
closed when retaining behavior would contradict a published safety guarantee.
## Final-v1 contract
@@ -30,41 +29,63 @@ compiler versions; `himesan check` defines whether they are current. The
project makes no compatibility promise for internal packages, development SSE
payloads before final v1, or hand-edited generated files.
The v1 compatibility snapshots cover the exported `sando` API and values, CLI
help and exit-code classes, structured operation/version output, diagnostic
codes, `himesan.json`, and generated provenance. English diagnostic wording,
internal packages, temporary paths, and compiler implementation details are not
stable API.
An API deprecated after final v1 remains available for the rest of the v1
major line and may be removed in v2. A security correction may fail closed in
a patch release when retaining old behavior would contradict a published safety
guarantee; that exception receives an advisory and migration note rather than a
silent compatibility claim. Until a broader maintenance policy is announced,
only the latest stable v1 patch and the current prerelease receive fixes.
## Go and platform support
Beta 1 targets Go 1.25 and Go 1.26. Support is based on point-in-time,
maintainer-run release matrices, not an implication of continuous CI coverage.
A Go support change is announced in release notes before it takes effect.
The modules retain a `go 1.25` language directive for consumer compatibility.
The maintained v1 build and verification targets are Linux/amd64 and Apple
Silicon macOS/arm64 using the pinned patched Go 1.26.7 and Go 1.27.0 toolchains.
Both native targets are release blockers. A sleeping or unavailable Mac delays
the release gate rather than silently converting it into Linux or
cross-compilation evidence. Native Windows, Intel macOS, Linux/arm64, and other
targets may work but are not v1 compatibility promises. A Go or platform
support change is announced in release notes before it takes effect.
The current public evidence is a **pre-beta baseline** on commit
`113c95c21e57227b4675c9fda015ada59cc9e9a6` (tree
`a2aeb4dac22853cb3894e3e487b94bbeff5051e5`):
### Historical Beta 1 observations
The following table is retained because the tests genuinely ran. It records a
point-in-time Beta 1 campaign and does not define the current support matrix.
The current public evidence is the exact Beta 1 source at commit
`b7a84054d755e42285e50298e41e47f06a8325a5` (tree
`be9e118e38dfebed19f60403ededdadabe07d2aa`):
| Platform | Go lanes | Maintainer-run result |
| --- | --- | --- |
| Windows 11/amd64 on NTFS | 1.25.12, 1.26.5 | Native tests, race, vet, builds, generation, process cleanup, watcher boundaries, and temporary consumer compilation passed; privileged symlink and POSIX-only permission cases were not exercised |
| Linux/amd64 on WSL2 with an ext4 checkout | 1.25.12, 1.26.5 | Tests, race, vet, builds, generation, focused filesystem/development cases, and license checks passed |
| Linux/amd64 in isolated containers on a Linux server | 1.25.12, 1.26.5 | Tests, race, vet, builds, deterministic generation, and license checks passed |
| macOS | — | Native maintainer validation pending; provisional for Beta 1 |
| Linux/amd64 in isolated containers on a Linux server | 1.25.12, 1.26.5 | The earlier pre-beta baseline passed tests, race, vet, builds, deterministic generation, and license checks; this was not rerun on the exact Beta 1 commit |
| macOS | — | Not executed during the Beta 1 campaign |
The golden generated file had SHA-256
`63fa75a3049a3a8a12d769d7f9b6b510dfe763baacf706775b75cef2c57a984f`
on every tested Windows and Linux lane.
on every tested Windows and Linux lane in that historical campaign.
Because the Beta 1 candidate contains changes after that public baseline, the
complete Windows/Linux matrix must be rerun on the exact candidate before the
tags are published. The baseline does not become beta evidence merely because
its code is nearby in history.
The signed Beta tags and fresh direct/public-proxy installation were verified
after publication. For Beta 1, add the nested runtime to an application module
before installing the parent compiler at the same version; this avoids a Go
module-cache path-selection ambiguity observed in the reverse order.
## macOS feedback
## Portability feedback
Mac learners, teachers, and Go developers are warmly invited to try the beta.
A useful compatibility report includes the macOS version, Intel or Apple
Silicon architecture, `go version`, the exact command, and a minimal
reproduction or diagnostic output. Ordinary compatibility reports belong on
the canonical Gitea project. Suspected vulnerabilities must use the private
route in [SECURITY.md](../SECURITY.md).
Developers may try the beta on an unsupported target and report useful gaps. A
good report includes the operating system and architecture, `go version`, the
exact command, and a minimal reproduction or diagnostic output. Ordinary
portability reports belong on the canonical Gitea project. Suspected
vulnerabilities must use the private route in [SECURITY.md](../SECURITY.md).
Community reports can reveal gaps and help prioritize maintainer testing. They
do not constitute an independent audit or shift responsibility for security
review, triage, fixes, and release decisions to the community.
Community reports can reveal gaps and help prioritize future work. They do not
constitute an independent audit, create a support promise, or shift
responsibility for security review, triage, fixes, and release decisions.
+3
View File
@@ -8,6 +8,9 @@ The application must read its listen address from the configured environment var
## `himesan.json` schema version 1
The machine-readable contract is
[`contracts/himesan-config-v1.schema.json`](../contracts/himesan-config-v1.schema.json).
```json
{
"version": 1,
+24
View File
@@ -21,3 +21,27 @@ Code families are intentionally coarse compatibility surfaces:
| `HIM29xx` | Boundary warnings |
Scripts should consume the JSON `code`, `severity`, and location fields, not parse English messages. Message wording may improve within a compatible release.
The exact v1 code inventory is machine-checked against
[`contracts/diagnostic-codes-v1.txt`](../contracts/diagnostic-codes-v1.txt).
Adding, removing, or renumbering a code requires an explicit compatibility
review and snapshot update.
## CLI and structured-output contract
Command exit codes use three classes: `0` for success (including help and
warning-only results), `1` for a completed operation that failed validation or
runtime service, and `2` for invalid command usage or failure to encode the
requested CLI result. `gen` normalizes to `generate`; `bless` remains a named
read-only alias of `check` in structured output.
The v1 JSON shapes are published as closed schemas:
- [`himesan-operation-output-v1.schema.json`](../contracts/himesan-operation-output-v1.schema.json)
for `generate`, `check`, and `bless`;
- [`himesan-version-output-v1.schema.json`](../contracts/himesan-version-output-v1.schema.json)
for `version --json`.
Unknown output fields are not introduced in a compatible v1 patch without an
explicit schema/version decision. Consumers should still ignore English
message wording.
+51
View File
@@ -0,0 +1,51 @@
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# Hime-san language server
Hime-san `v1.0.0-beta.2` adds a reusable, read-only Language Server Protocol
surface:
```sh
himesan lsp --stdio
```
The server accepts one local workspace root per process. Editors with multiple
workspace folders start one process for each folder. Standard output contains
only framed JSON-RPC; bounded operational messages go to standard error and do
not include template source, environment values, or secrets.
## Beta 2 capabilities
- full-document synchronization and unsaved in-memory overlays;
- live compiler diagnostics, trust warnings, duplicate components, and
statically knowable component cycles;
- UTF-16 protocol positions without changing compiler CLI byte coordinates;
- hover help for tags, inferred output contexts, component signatures, and
trusted-output boundaries;
- document symbols for the declared component and template regions;
- delimiter/tag completion plus same-package and already-imported component
completion; and
- component go-to-definition.
The index honors the compiler's symlink, nested-module, VCS, vendor, and
filesystem boundaries. Open/save is analyzed immediately; ordinary edits are
debounced for 200 ms and superseded analyses are canceled. Appearance,
deletion, rename, and save notifications rebuild the bounded source index.
## Deliberate exclusions
The language server does not generate files, report generated-file freshness,
invoke Go or `gopls`, execute project code, fetch dependencies, access the
network, or start `himesan dev`. It provides no general Go or HTML completion,
formatting, rename, references, automatic imports, or live browser preview.
Use explicit `himesan check --json` for committed-output freshness and normal
Go tests/builds for type checking. Editors remain responsible for workspace
trust, process startup, and user-visible command policy.
## Resource limits
Protocol frames and individual documents are limited to 16 MiB, the indexed
workspace source set to 64 MiB and 10,000 `.sando` files. These are denial-of-
service guardrails for trusted local workspaces, not a sandbox for hostile
template authors.
+116 -45
View File
@@ -11,23 +11,58 @@ verification, or guarantee that no vulnerability exists.
| Field | Value |
| --- | --- |
| Assessment date | 2026-08-12 |
| Evidence sets | Clean security self-assessment plus an exact-commit pre-beta platform baseline; neither is evidence for the later Beta 1 candidate |
| Public commit | `113c95c21e57227b4675c9fda015ada59cc9e9a6` |
| Public tree | `a2aeb4dac22853cb3894e3e487b94bbeff5051e5` |
| Maintainer-run environments | Windows 11/amd64 on NTFS; Ubuntu 20.04/amd64 under WSL2 on ext4; Linux/amd64 server containers |
| Evidence sets | Clean security self-assessment plus exact-commit Beta 1 platform, release, signing, and installation checks |
| Public commit | `b7a84054d755e42285e50298e41e47f06a8325a5` |
| Public tree | `be9e118e38dfebed19f60403ededdadabe07d2aa` |
| Maintainer-run environments | Windows 11/amd64 on NTFS; Ubuntu 20.04/amd64 under WSL2 on ext4; supplementary pre-beta Linux/amd64 server containers |
| Supported Go lanes exercised | Go 1.25.12 and Go 1.26.5 |
| Declared minimum Go | Go 1.25 |
| Assessor | Project maintainer with AI-assisted code review; human responsibility retained |
The named platform runs used the exact public commit and tree above. Hostnames,
network addresses, account names, private paths, private repository identities,
and private commit mappings are intentionally absent from this public ledger.
The named Windows and WSL2 platform runs used the exact public commit and tree
above. The isolated server-container matrix preceded the final candidate and
is retained only as supplementary Linux evidence. Hostnames, network addresses,
account names, private paths, private repository identities, and private commit
mappings are intentionally absent from this public ledger. These platform
observations are historical evidence, not the current support matrix.
Linux/amd64 and Darwin/arm64 are now the maintained v1 release targets. This
section retains historical Beta 1 evidence; the exact RC must supply new native
evidence on both targets. WSL and native Windows are not v1 release blockers.
Beta 1 necessarily changes the tree through versioning, provenance,
documentation, or source fixes. Therefore this baseline cannot be relabeled as
Beta 1 evidence. The required Windows/Linux campaign must pass again on the
exact Beta 1 candidate before either tag is published. Native macOS execution
remains pending and is provisional for the beta.
## Beta 2 compiler publication addendum
This addendum records the additive language-server release without replacing
the Beta 1 assessment identity above. Compiler tag `v1.0.0-beta.2` is a signed
annotated tag whose peeled public commit is
`1082d9d61eb84e67ca4012ff9ee3898ee37ac6fd` and whose public tree is
`01d5702928f3d9c9fb0e3d2213530add7ff94745`. The tag object is
`f091cd67f688ba5ee784b18f5a407a9326df7ab2`.
Beta 2 changed only the development compiler. No `sando/v1.0.0-beta.2` tag was
created. The release preflight compared the retained runtime tag
`sando/v1.0.0-beta.1` with the Beta 2 commit and verified that their `sando`
subtrees were byte-identical at tree
`3035e948f77f160d399089be3ae80c88bab3fed2`.
The exact public Beta 2 source passed the full race-enabled verifier and the
compiler-only release preflight on executed Linux with Go 1.26.5. Fresh native
Windows checkouts on NTFS passed the full race-enabled PowerShell verifier,
focused process-tree/watcher/consumer tests, candidate-stamped version checks,
and deterministic generation on Go 1.25.12 and Go 1.26.5. Clean isolated
`GOPROXY=direct` and public-proxy-only installs produced
`features:["lsp-stdio"]`; the public-proxy path also verified the retained
runtime through `sum.golang.org`. The Windows result is retained as historical
portability evidence and does not create an ongoing support promise.
The Beta 2 language server is additive development tooling. Its tested
security boundary includes protocol-only stdout; bounded header and message
framing; integer/string JSON-RPC identifiers; full-document in-memory overlays;
UTF-16 conversion at the protocol boundary; cancellation and shutdown;
workspace, nested-module, VCS, symlink, and file-count boundaries; and explicit
no-write/no-network/no-Go-tool execution tests. Fuzz targets exercise bounded
JSON-RPC framing and document changes. These checks do not make an untrusted
workspace safe to execute: `himesan dev` and project commands remain trusted
local-code operations, while `himesan lsp --stdio` performs analysis only.
## Observed security self-assessment evidence
@@ -48,11 +83,11 @@ baseline commit.
| 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 |
| HTML text/attribute/RCDATA escaping | compiler/runtime adversarial cases plus the committed `html/template` overlap corpus | Pass for the committed corpus; documented stricter invalid-UTF-8 handling remains intentional |
| URL scheme handling | ordinary/trusted URL matrices plus safe, unsafe, and intentionally divergent `html/template` cases | Pass for the committed corpus; control rejection and the explicit `tel` allowlist are documented policy differences |
| 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 |
| Platform behavior | Native Windows and executed Linux matrices; macOS cross-compilation | Windows/Linux pass for tested lanes; native macOS pending |
| Platform behavior | Historical exact-candidate native Windows and executed Linux matrices | Windows/Linux passed for the tested lanes; the v1 RC requires fresh Linux/amd64 and Darwin/arm64 evidence |
Coverage measures statements executed by tests. It is not branch completeness
and is not evidence that the executed behavior is secure.
@@ -61,7 +96,7 @@ and is not evidence that the executed behavior is secure.
and reachable through its analysis. A clean result cannot detect unknown flaws,
design errors, or vulnerabilities outside its model.
## Pre-beta native compatibility matrix
## Historical Beta 1 compatibility matrix
These are maintainer-run, point-in-time results, not continuous CI and not an
independent audit.
@@ -70,8 +105,8 @@ independent audit.
| --- | --- | --- | --- |
| Windows 11/amd64, NTFS | 1.25.12, 1.26.5 | Native PowerShell verifier with race; root/runtime tests, vet, trimpath build, freshness, two generation passes, process-tree cleanup, watcher boundaries, and temporary consumer compilation | Pass. Symlink-output rejection skipped because the test account lacked symlink privilege; the read-only-directory case is POSIX-only |
| Ubuntu 20.04/amd64 under WSL2, native ext4 checkout | 1.25.12, 1.26.5 | Race-enabled verifier; root/runtime tests, vet, build, two generation passes, ten focused filesystem cases, five focused development-process/watcher cases, and license check | Pass. This is Linux execution under WSL2, not bare-metal or Linux/arm64 evidence |
| Linux/amd64 server containers | 1.25.12, 1.26.5 | Root/runtime tests, vet, builds, race, licensing, and deterministic generation in sequential isolated official Go containers | Pass. Container resources were capped at 1 CPU and 2 GiB; this is not Linux/arm64 evidence |
| macOS | — | Cross-compilation only | Native maintainer execution pending; provisional for Beta 1 |
| Linux/amd64 server containers | 1.25.12, 1.26.5 | Earlier pre-beta root/runtime tests, vet, builds, race, licensing, and deterministic generation in sequential isolated official Go containers | Pass on the earlier baseline only. Container resources were capped at 1 CPU and 2 GiB; this is supplementary evidence, not an exact Beta 1 lane or Linux/arm64 evidence |
| macOS | — | Cross-compilation only | No native Beta 1 evidence; Darwin/arm64 becomes a maintained target at the v1 RC and requires fresh evidence |
The generated golden `basic.sando.go` was 1,399 bytes and had SHA-256
`63fa75a3049a3a8a12d769d7f9b6b510dfe763baacf706775b75cef2c57a984f`
@@ -79,12 +114,12 @@ on every tested Windows and Linux lane. Repeated generation also preserved its
timestamp. This demonstrates cross-host agreement for one compiler-owned
fixture, not equivalence for every possible template.
Mac learners and Go developers are warmly invited to report ordinary
compatibility results with macOS version, architecture, `go version`, exact
command, and a minimal reproduction. Suspected vulnerabilities use the private
route in [SECURITY.md](../SECURITY.md). Community reports help find gaps;
maintainers remain responsible for reproducing security-relevant behavior,
triage, remediation, and release decisions.
Portability reports for unsupported targets may include the operating system,
architecture, `go version`, exact command, and a minimal reproduction.
Suspected vulnerabilities use the private route in
[SECURITY.md](../SECURITY.md). Such reports help find gaps but do not create a
support promise; maintainers remain responsible for security triage and fixes
on both supported native targets.
## Security-relevant design evidence
@@ -144,15 +179,23 @@ go test ./internal/compiler -run '^$' \
-fuzz '^FuzzCompileNeverPanics$' -fuzztime=20s
go test ./internal/compiler -run '^$' \
-fuzz '^FuzzGoDelimiterNeverPanics$' -fuzztime=20s
./scripts/release-check.sh --version v1.0.0-beta.1
./scripts/verify-public-install.sh --version v1.0.0-beta.1
```
The fuzz targets currently assert process robustness and result bounds. They do
not yet prove semantic HTML safety.
Those historical Beta 1 fuzz targets asserted process robustness and result
bounds; they did not prove semantic HTML safety. The v1 compiler target now
also asserts deterministic diagnostics and generated Go, valid formatted Go,
source-digest binding, bounded public diagnostic shape, and sanitized source
map directives. A separate runtime target asserts deterministic, fail-closed
URL handling with no partial output. These properties still do not replace the
committed differential corpus or real-browser testing.
## Assessment findings and remediation status
The 2026-08-12 assessment identified six concrete gaps. Their status in the
named public pre-beta baseline is recorded here:
named public Beta 1 source is recorded here:
| Finding | Current remediation | Executable evidence |
| --- | --- | --- |
@@ -163,27 +206,28 @@ named public pre-beta baseline is recorded here:
| 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 clean remediated assessment 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. Separately, the exact public pre-beta commit passed the native
Windows and executed Linux matrices recorded above. These results still do not
become Beta 1 evidence: both sets of required checks must run on the exact
candidate after all candidate changes. Native macOS and the other gaps below
remain separate release decisions.
The exact public Beta 1 source passed the race-enabled repository verifier,
sanitized-snapshot tests, both bounded fuzz-smoke targets, compiler/runtime
known-vulnerability scans, candidate-version provenance checks, native Windows
and executed Linux matrices, and Windows/macOS cross-compilation on 2026-08-12.
Signed annotated runtime and compiler tags were then published from that commit
in that order. Fresh runtime-first installation passed through both direct Git
resolution and the public Go proxy after normal proxy propagation. Future
release decisions require fresh evidence for the maintained Linux/amd64 and
Darwin/arm64 targets rather than reusing this historical campaign.
## Open assurance gaps
- the exact Beta 1 candidate Windows/Linux matrix and post-tag install checks
must still run;
- confidential mailbox delivery and response/recovery procedure must be tested;
- SSH tag-signing rehearsal passed, but the candidate tags still require
post-publication verification; prebuilt-artifact signing, checksums, SBOM,
reproducible provenance, and key recovery remain incomplete;
- native macOS, Linux/arm64, and Windows/arm64 execution remain outstanding;
- Windows symlink rejection was not natively exercised because the test account
lacked symlink privilege;
- browser-parser differential and semantic property testing need expansion;
- delivery to `security@sandwichhime.com` is owner-confirmed through a
controlled domain catch-all; encrypted reporting, documented backup, and
recovery rehearsal remain incomplete;
- the signed annotated Beta tags and their common peeled commit were verified;
prebuilt-artifact signing, checksums, SBOM, reproducible provenance, and key
recovery remain incomplete;
- Linux/arm64, Darwin/amd64, Windows, and other targets are outside the current
maintained release set;
- the exact public candidate still needs the committed real-browser generated
document and development-supervisor campaign on both maintained hosts;
- 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;
@@ -197,6 +241,33 @@ remain separate release decisions.
- static cycle detection and trust-use warnings are best-effort analyses; and
- the project has no independent security audit or bug-bounty program.
## v1 disposition of open gaps
The list above intentionally mixes incomplete release evidence with boundaries
that are not promised by this product. The RC may not convert either category
into vague assurance. The following disposition is explicit and remains
subject to exact-public-candidate review:
| Gap | v1 disposition |
| --- | --- |
| Security mailbox delivery, backup, and recovery | Release blocker. Complete the delivery/reply and recovery drill before RC publication. Encrypted reporting may remain optional if the supported confidential channel and its limit are stated accurately. |
| Artifact signing, provenance, and key recovery | Release blocker. Complete deterministic native artifacts, Developer ID notarization, signed-tag rehearsal, and recovery evidence. |
| Maintained native matrix | Release blocker for Linux/amd64 and Darwin/arm64 only. Other architectures and operating systems are explicitly unsupported, not silently untested promises. |
| Real-browser parser and supervisor evidence | Release blocker. The repository-owned gate covers a generated typed document, parsed structure, hostile-value inertness, and supervisor behavior. Execute it against the exact public candidate on both maintained hosts before publication. |
| Compiler resource budgets | Accepted v1 boundary. The compiler is a trusted local build tool; operating-system and runner limits own CPU, memory, and input quotas. No hostile-input resource guarantee is made. |
| Hostile local filesystem races | Accepted v1 boundary. Symlinks and ownership are checked, but an actor able to mutate the workspace concurrently is outside the trust model. |
| Watcher integrity | Accepted v1 boundary. Watching is development convenience; explicit `check`, generation, Go tests, and builds remain release/deployment authority. |
| Human-readable child diagnostics | Accepted v1 boundary. They are bounded for resources but remain trusted local terminal output, not a sanitized telemetry format. |
| Development CSP rewriting | Accepted v1 boundary. It enables reload on trusted loopback pages and is not production CSP validation. |
| Deliberately detached descendants | Accepted v1 boundary. Ordinary process groups are terminated and waited for; adversarial detachment is outside the trusted-project development model. |
| Render recursion, output, panic, allocation, CPU, and deadlines | Accepted v1 boundary. Components are ordinary trusted Go; applications own recovery, deadlines, and resource policy. |
| Static cycles and trust warnings | Accepted v1 boundary. They are documented best-effort audit hints and never replace Go review/tests or explicit trust decisions. |
| Independent audit and bug bounty | Accepted disclosure, not a security claim. Neither exists for RC. Public tests, threat model, reporting, and correction policy must not be described as an independent audit. |
An accepted boundary is permitted only because matching compatibility, threat
model, and release copy already avoid the stronger promise. Any conflicting
marketing or documentation reopens the item as a release blocker.
## Interpreting this ledger
“Pass” means the named command or case produced its expected result in the named
+2 -1
View File
@@ -177,7 +177,8 @@ Sandwich Hime does not:
## Open release work
- broaden semantic and browser-parser differential testing;
- execute the native Windows/macOS security and process-lifecycle matrix;
- execute the Linux/amd64 and Darwin/arm64 security and process-lifecycle
release matrices;
- 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
+66 -59
View File
@@ -28,20 +28,18 @@ private history or an indiscriminate Git mirror.
Beta 1 is deliberately earlier than a release candidate. It creates a real,
repeatable install for learners and evaluators without claiming that the final
v1 compatibility, native-platform, artifact, signing, or soak gates are
complete.
v1 compatibility, native release, artifact, signing, or soak gates are complete.
### Demonstrated in the pre-beta public baseline
### Demonstrated for Beta 1
Public commit `113c95c21e57227b4675c9fda015ada59cc9e9a6` (tree
`a2aeb4dac22853cb3894e3e487b94bbeff5051e5`) passed maintainer-run Go
1.25.12 and Go 1.26.5 matrices on native Windows/amd64, Linux/amd64 under WSL2,
and isolated Linux/amd64 server containers. The same generated golden SHA-256
was observed across those lanes.
That result is a pre-beta baseline only. The exact Beta 1 candidate must rerun
the required Windows/Linux matrix after all version, documentation, and source
changes and before tags are created.
Public commit `b7a84054d755e42285e50298e41e47f06a8325a5` (tree
`be9e118e38dfebed19f60403ededdadabe07d2aa`) passed maintainer-run Go
1.25.12 and Go 1.26.5 matrices on native Windows/amd64 and Linux/amd64 under
WSL2, with the earlier pre-beta server-container run retained only as
supplementary Linux evidence. The same generated golden SHA-256 was observed
across the exact Beta Windows and Linux lanes. This is historical evidence,
not the current support definition; Linux/amd64 and Darwin/arm64 are the
maintained v1 release targets.
Other demonstrated controls include:
@@ -53,18 +51,23 @@ Other demonstrated controls include:
- loopback-only, browser-origin-hardened development proxy behavior; and
- a public threat model, security policy, and dated evidence ledger.
### Not demonstrated yet
### Demonstrated for the first RC candidate
- native maintainer-run macOS execution; macOS is provisional for Beta 1;
- stable final-v1 API, CLI, schema, diagnostic, and generated snapshots;
- systematic browser-parser and `html/template` differential testing;
- native Linux/amd64 and Darwin/arm64 release-candidate lanes on pinned Go
1.26.7 and Go 1.27.0 toolchains;
- exact-public-candidate execution of the committed real-browser generated
document and development-supervisor gates;
- a long semantic fuzz campaign beyond bounded no-panic smoke;
- committed comparative benchmarks and predefined regression thresholds;
- complete real-browser development-supervisor evidence;
- deterministic prebuilt archives, checksums, SBOMs, signed binaries, and
tested signing/recovery procedures; or
- clean direct and public-proxy installation of the not-yet-published Beta 1
tags.
- exact-candidate comparative benchmark results;
- deterministic prebuilt archives, checksums, SPDX SBOMs, and provenance;
- Developer ID signing, notarization, stapling, and Gatekeeper validation for
the Darwin/arm64 distribution; and
- byte-identical generated output and independently reproduced release
archives across the maintained native evidence lanes.
The final immutable tags, clean direct/proxy installation, credential-recovery
drills, and public observation period remain publication or post-publication
gates rather than properties that source code can prove by itself.
## Beta 1 publication lane
@@ -72,74 +75,77 @@ Beta 1 is supported for learning, classroom projects, evaluation, prototypes,
and compatibility feedback. It is not recommended as a production-stable
dependency, and its interfaces may change.
- [x] Define beta support, security, compatibility, and macOS-provisional
language.
- [x] Establish the named public pre-beta Linux/Windows baseline.
- [ ] Rerun the supported Go matrix and deterministic generation on the exact
- [x] Define beta support, security, and compatibility language.
- [x] Establish the historical public pre-beta Linux/Windows evidence baseline.
- [x] Rerun the supported Go matrix and deterministic generation on the exact
Beta 1 candidate.
- [ ] Run the candidate-version freshness, bounded fuzz, vulnerability, and
- [x] Run the candidate-version freshness, bounded fuzz, vulnerability, and
license gates.
- [ ] Publish immutable `sando/v1.0.0-beta.1`, then
- [x] Publish immutable `sando/v1.0.0-beta.1`, then
`v1.0.0-beta.1`, from the same reviewed public commit.
- [ ] Verify clean direct and public-proxy installs and record the result.
- [ ] Add native macOS maintainer evidence before RC; community reports inform
that work but do not replace maintainer responsibility.
- [x] Verify clean runtime-first direct and public-proxy installs and record the
result.
## Milestone 1: contract freeze
Required before security/platform release-candidate work is declared complete:
Required before security/native 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,
- [x] Specify generic component function signatures as ordinary Go-backed v1
APIs and compile them in a temporary consumer module.
- [x] 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,
- [x] 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,
- [x] 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
- [x] Add machine-checked public API, CLI, diagnostic, schema, and generated
output compatibility snapshots.
- [ ] Define the v1 deprecation and security-support policy.
- [x] Define the v1 deprecation and security-support policy.
## Milestone 2: security and native-platform evidence
## Milestone 2: security and native release 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
- [x] Run the pinned patched Go 1.26 and Go 1.27 lines on Linux/amd64 and
native Darwin/arm64 runners, plus a Linux deployment-class host.
- [x] Prove identical generated bytes across both native platforms and exercise
path, replacement, permission, race, process-tree, and watcher behavior.
- [ ] Build a systematic differential corpus against Go's documented
- [x] 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,
- [x] Commit a real-browser gate that generates and builds a representative
typed document, then tests parsed structure and hostile-value inertness in
Chrome rather than relying only on byte equality.
- [x] 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.
- [x] Define severity, advisory, retraction, and CVE-request handling.
## Milestone 3: measured performance and development UX
- [ ] Commit a synthetic, repository-owned benchmark corpus comparing
- [x] 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.
- [x] Define “no material regression” before measuring the release candidate.
- [x] Measure the exact release candidate and preserve hardware, OS, Go version,
commands, samples, allocations, and output equivalence with every result.
- [x] Commit a real-browser development-supervisor gate covering SSE
diagnostics, reload, CSP behavior, fragment/API exclusions, caching, and
child cleanup.
- [x] Execute the committed browser gate on the exact public candidate on both
maintained hosts and preserve the browser identities and results.
- [ ] 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,
- [x] 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
- [x] 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,
- [x] 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
- [x] 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.
@@ -150,7 +156,8 @@ Required before security/platform release-candidate work is declared complete:
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.
`proxy.golang.org` caches on supported Go versions under Linux/amd64 and
Darwin/arm64.
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,
@@ -175,6 +182,6 @@ 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
contract, executable evidence from supported Linux environments, and a signed
artifact built from the exact canonical source. It does not mean perfect,
invulnerable, or finished forever.
+163
View File
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"context"
"fmt"
"path/filepath"
"sort"
)
// SourceInput supplies one in-memory .sando document for editor analysis.
type SourceInput struct {
Path string
Source []byte
}
// AnalysisImport describes one import already present in a .sando header.
type AnalysisImport struct {
Alias string `json:"alias,omitempty"`
Path string `json:"path"`
}
// AnalysisRegionKind identifies an author-visible template region.
type AnalysisRegionKind string
const (
AnalysisStatement AnalysisRegionKind = "statement"
AnalysisExpression AnalysisRegionKind = "expression"
AnalysisComponent AnalysisRegionKind = "component"
AnalysisComment AnalysisRegionKind = "comment"
)
// AnalysisRegion describes a Hime-san tag body using zero-based byte offsets.
// Line and Column retain the compiler's one-based byte-coordinate convention;
// protocol adapters convert them to UTF-16 where required.
type AnalysisRegion struct {
Kind AnalysisRegionKind `json:"kind"`
Text string `json:"text,omitempty"`
Context Context `json:"context,omitempty"`
Offset int `json:"offset"`
Length int `json:"length"`
Line int `json:"line"`
Column int `json:"column"`
}
// DocumentAnalysis is the compiler-owned semantic description consumed by
// read-only tools such as the language server.
type DocumentAnalysis struct {
Path string `json:"path"`
Package string `json:"package,omitempty"`
Component string `json:"component,omitempty"`
TypeParams string `json:"type_params,omitempty"`
Params string `json:"params,omitempty"`
Signature string `json:"signature,omitempty"`
ComponentOffset int `json:"component_offset,omitempty"`
ComponentLine int `json:"component_line,omitempty"`
ComponentColumn int `json:"component_column,omitempty"`
Imports []AnalysisImport `json:"imports,omitempty"`
Regions []AnalysisRegion `json:"regions,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
// AnalyzeSources applies the normal parser, HTML-context analyzer, trust
// audit, backend validation, duplicate-component checks, and statically
// knowable cycle checks to an in-memory source set. It performs no I/O.
func AnalyzeSources(ctx context.Context, inputs []SourceInput) []DocumentAnalysis {
ordered := append([]SourceInput(nil), inputs...)
sort.SliceStable(ordered, func(i, j int) bool {
return filepath.Clean(ordered[i].Path) < filepath.Clean(ordered[j].Path)
})
analyses := make([]DocumentAnalysis, 0, len(ordered))
compiled := make([]CompiledFile, 0, len(ordered))
for _, input := range ordered {
if err := ctx.Err(); err != nil {
analyses = append(analyses, DocumentAnalysis{
Path: filepath.Clean(input.Path),
Diagnostics: []Diagnostic{diagnostic(input.Path, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error())},
})
continue
}
analysis, output := analyzeSource(input.Path, input.Source)
analyses = append(analyses, analysis)
if output.Code != nil {
compiled = append(compiled, output)
}
}
byPath := make(map[string][]Diagnostic)
for _, item := range detectComponentCycles(compiled) {
path := filepath.Clean(item.Path)
byPath[path] = append(byPath[path], item)
}
for index := range analyses {
path := filepath.Clean(analyses[index].Path)
analyses[index].Diagnostics = append(analyses[index].Diagnostics, byPath[path]...)
sortDiagnostics(analyses[index].Diagnostics)
}
return analyses
}
func analyzeSource(path string, source []byte) (DocumentAnalysis, CompiledFile) {
cleanPath := filepath.Clean(path)
analysis := DocumentAnalysis{Path: cleanPath}
file, diagnostics := parseSource(cleanPath, source)
if file == nil {
sortDiagnostics(diagnostics)
analysis.Diagnostics = diagnostics
return analysis, CompiledFile{}
}
analysis.Package = file.Package
analysis.Component = file.Name
analysis.TypeParams = file.TypeParams
analysis.Params = file.Params
analysis.Signature = fmt.Sprintf("func %s%s%s", file.Name, file.TypeParams, file.Params)
analysis.ComponentOffset = file.FunctionPos.Offset
analysis.ComponentLine = file.FunctionPos.Line
analysis.ComponentColumn = file.FunctionPos.Column
for _, imported := range file.Imports {
analysis.Imports = append(analysis.Imports, AnalysisImport{Alias: imported.Alias, Path: imported.Path})
}
diagnostics = append(diagnostics, analyzeContexts(file)...)
diagnostics = append(diagnostics, auditTrustCalls(file)...)
for _, node := range file.Nodes {
kind := AnalysisRegionKind("")
switch node.Kind {
case nodeStatement:
kind = AnalysisStatement
case nodeExpression:
kind = AnalysisExpression
case nodeComponent:
kind = AnalysisComponent
case nodeComment:
kind = AnalysisComment
default:
continue
}
analysis.Regions = append(analysis.Regions, AnalysisRegion{
Kind: kind, Text: node.Text, Context: node.Context,
Offset: node.Pos.Offset, Length: len(node.Text),
Line: node.Pos.Line, Column: node.Pos.Column,
})
}
if hasErrors(diagnostics) {
sortDiagnostics(diagnostics)
analysis.Diagnostics = diagnostics
return analysis, CompiledFile{}
}
code, backendDiagnostics := generateGo(file)
diagnostics = append(diagnostics, backendDiagnostics...)
sortDiagnostics(diagnostics)
analysis.Diagnostics = diagnostics
if hasErrors(diagnostics) {
return analysis, CompiledFile{}
}
return analysis, CompiledFile{
SourcePath: cleanPath,
OutputPath: cleanPath + ".go",
Package: file.Package,
Component: file.Name,
Code: code,
source: file,
}
}
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAnalyzeSourcesUsesCompilerSemanticsWithoutIO(t *testing.T) {
t.Parallel()
directory := t.TempDir()
firstPath := filepath.Join(directory, "first.sando")
secondPath := filepath.Join(directory, "second.sando")
first := []byte("<?sando go\npackage views\nfunc First(name string)\n?>\n<div><?= name ?><?~ Second() ?></div>\n")
second := []byte("<?sando go\npackage views\nfunc Second()\n?>\n<section><?~ First(\"again\") ?></section>\n")
analyses := AnalyzeSources(context.Background(), []SourceInput{{Path: firstPath, Source: first}, {Path: secondPath, Source: second}})
if len(analyses) != 2 {
t.Fatalf("analysis count = %d, want 2", len(analyses))
}
for _, analysis := range analyses {
if analysis.Component == "" || analysis.Signature == "" || analysis.ComponentLine < 1 {
t.Fatalf("missing component metadata: %#v", analysis)
}
if !hasDiagnosticCode(analysis.Diagnostics, "HIM1501") {
t.Fatalf("cycle diagnostic missing for %s: %#v", analysis.Component, analysis.Diagnostics)
}
if _, err := os.Stat(analysis.Path + ".go"); !os.IsNotExist(err) {
t.Fatalf("analysis wrote generated output: %v", err)
}
}
if analyses[0].Regions[0].Context != ContextHTMLText {
t.Fatalf("expression context = %q, want %q", analyses[0].Regions[0].Context, ContextHTMLText)
}
}
func TestAnalyzeSourcesDuplicateAndMalformedDocuments(t *testing.T) {
t.Parallel()
directory := t.TempDir()
duplicate := "<?sando go\npackage views\nfunc Card()\n?>\n<p>card</p>\n"
malformed := "<?sando go\npackage views\nfunc Broken()\n?>\n<div>\x00"
analyses := AnalyzeSources(context.Background(), []SourceInput{
{Path: filepath.Join(directory, "a.sando"), Source: []byte(duplicate)},
{Path: filepath.Join(directory, "b.sando"), Source: []byte(duplicate)},
{Path: filepath.Join(directory, "broken.sando"), Source: []byte(malformed)},
})
if !hasDiagnosticCode(analyses[0].Diagnostics, "HIM1500") || !hasDiagnosticCode(analyses[1].Diagnostics, "HIM1500") {
t.Fatalf("duplicate diagnostics missing: %#v", analyses)
}
if !hasDiagnosticCode(analyses[2].Diagnostics, "HIM1002") {
t.Fatalf("NUL diagnostic missing: %#v", analyses[2].Diagnostics)
}
}
func TestDiscoverSourcesOmitsGeneratedFreshnessButKeepsBoundaries(t *testing.T) {
t.Parallel()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/root\n\ngo 1.25\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "orphan.sando.go"), []byte(generatedPrefix+"\npackage root\n"), 0o600); err != nil {
t.Fatal(err)
}
nested := filepath.Join(root, "nested")
if err := os.Mkdir(nested, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "go.mod"), []byte("module example.test/nested\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "hidden.sando"), []byte("ignored"), 0o600); err != nil {
t.Fatal(err)
}
paths, diagnostics := DiscoverSources(context.Background(), []string{root})
if len(paths) != 0 {
t.Fatalf("discovered nested source: %v", paths)
}
for _, item := range diagnostics {
if item.Code == "HIM2014" {
t.Fatalf("editor discovery reported generated freshness: %#v", diagnostics)
}
}
}
func hasDiagnosticCode(diagnostics []Diagnostic, code string) bool {
for _, item := range diagnostics {
if item.Code == code || strings.HasPrefix(item.Code, code) {
return true
}
}
return false
}
+138
View File
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"bytes"
"context"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"testing"
)
var diagnosticCodePattern = regexp.MustCompile(`^HIM[0-9]{4}$`)
func TestV1DiagnosticCodeContract(t *testing.T) {
t.Parallel()
directory := packageDirectory(t)
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
codes := make(map[string]struct{})
fileSet := token.NewFileSet()
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
parsed, err := parser.ParseFile(fileSet, filepath.Join(directory, entry.Name()), nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", entry.Name(), err)
}
ast.Inspect(parsed, func(node ast.Node) bool {
literal, ok := node.(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
return true
}
value, err := strconv.Unquote(literal.Value)
if err == nil && diagnosticCodePattern.MatchString(value) {
codes[value] = struct{}{}
}
return true
})
}
actual := make([]string, 0, len(codes))
for code := range codes {
actual = append(actual, code)
}
sort.Strings(actual)
assertContractFile(t, filepath.Join(directory, "..", "..", "contracts", "diagnostic-codes-v1.txt"), strings.Join(actual, "\n")+"\n")
}
func TestV1GeneratedProvenanceContract(t *testing.T) {
t.Parallel()
compiled, diagnostics := Compile("views/generic.sando", []byte(`<?sando go
package views
func List[T ~string](values []T)
?>
<ul><? for _, value := range values { ?><li><?= value ?></li><? } ?></ul>`))
assertNoErrorDiagnostics(t, diagnostics)
lines := strings.Split(string(compiled.Code), "\n")
if len(lines) < 4 {
t.Fatalf("generated header has %d lines", len(lines))
}
actual := []string{lines[0]}
if !strings.HasPrefix(lines[1], "// himesan:compiler ") {
t.Fatalf("compiler provenance line = %q", lines[1])
}
actual = append(actual, "// himesan:compiler <compiler-version>")
if !strings.HasPrefix(lines[2], "// himesan:runtime-abi ") {
t.Fatalf("runtime provenance line = %q", lines[2])
}
actual = append(actual, "// himesan:runtime-abi <runtime-abi>")
if !regexp.MustCompile(`^// himesan:source-sha256 [0-9a-f]{64}$`).MatchString(lines[3]) {
t.Fatalf("source provenance line = %q", lines[3])
}
actual = append(actual, "// himesan:source-sha256 <lowercase-sha256>")
generated := string(compiled.Code)
if !regexp.MustCompile(`(?m)^var _ = [A-Za-z_][A-Za-z0-9_]*\.ABISandoV1$`).MatchString(generated) {
t.Fatal("generated output is missing the compile-time ABI marker")
}
actual = append(actual, "var _ = <sando-import>.ABISandoV1")
if !regexp.MustCompile(`(?m)^//line [^\r\n]+:[1-9][0-9]*:[1-9][0-9]*$`).MatchString(generated) {
t.Fatal("generated output is missing source mappings")
}
actual = append(actual, "//line <source-path>:<line>:<column>")
assertContractFile(t, filepath.Join(packageDirectory(t), "..", "..", "contracts", "generated-provenance-v1.txt"), strings.Join(actual, "\n")+"\n")
}
func TestV1GenericComponentSignature(t *testing.T) {
t.Parallel()
source := []byte(`<?sando go
package views
func List[T ~string](values []T)
?>
<ul><? for _, value := range values { ?><li><?= value ?></li><? } ?></ul>`)
compiled, diagnostics := Compile("views/list.sando", source)
assertNoErrorDiagnostics(t, diagnostics)
if !bytes.Contains(compiled.Code, []byte("func List[T ~string](values []T)")) {
t.Fatalf("generic signature was not preserved:\n%s", compiled.Code)
}
analyses := AnalyzeSources(context.Background(), []SourceInput{{Path: "views/list.sando", Source: source}})
if len(analyses) != 1 {
t.Fatalf("analysis count = %d, want 1", len(analyses))
}
analysis := analyses[0]
if analysis.TypeParams != "[T ~string]" || analysis.Params != "(values []T)" || analysis.Signature != "func List[T ~string](values []T)" {
t.Fatalf("generic analysis contract = %#v", analysis)
}
}
func packageDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Dir(file)
}
func assertContractFile(t *testing.T, path, actual string) {
t.Helper()
expected, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
expected = bytes.TrimPrefix(expected, []byte("# SPDX-License-Identifier: AGPL-3.0-only\n\n"))
if string(expected) != actual {
t.Fatalf("contract drift in %s\n--- expected ---\n%s--- actual ---\n%s", path, expected, actual)
}
}
+14
View File
@@ -23,6 +23,17 @@ var excludedDirectories = map[string]bool{
}
func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
return discoverWithOptions(ctx, paths, true)
}
// DiscoverSources finds .sando sources using the same filesystem, symlink,
// nested-module, VCS, and vendor boundaries as Generate and Check. It omits
// generated-output inspection because editor analysis does not own freshness.
func DiscoverSources(ctx context.Context, paths []string) ([]string, []Diagnostic) {
return discoverWithOptions(ctx, paths, false)
}
func discoverWithOptions(ctx context.Context, paths []string, inspectGenerated bool) ([]string, []Diagnostic) {
if len(paths) == 0 {
paths = []string{"."}
}
@@ -121,6 +132,9 @@ func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
return nil
}
if strings.HasSuffix(entry.Name(), ".sando.go") {
if !inspectGenerated {
return nil
}
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()))
+10 -1
View File
@@ -128,6 +128,9 @@ func TestRCDATACannotBeBypassedByTrustedHTML(t *testing.T) {
view := View{Name: "title", URL: "/", JS: sando.TrustJS(""), HTML: sando.TrustHTML("</textarea><script>bad()</script>")}
if err := sando.Render(context.Background(), &output, Page(view)); err != nil { t.Fatal(err) }
if strings.Contains(output.String(), "</textarea><script>") { t.Fatalf("RCDATA boundary escaped: %s", output.String()) }
output.Reset()
if err := sando.Render(context.Background(), &output, List([]string{"one", "two"})); err != nil { t.Fatal(err) }
if output.String() != "\n<ul><li>one</li><li>two</li></ul>" { t.Fatalf("generic component output: %q", output.String()) }
}
`)
templatePath := filepath.Join(directory, "page.sando")
@@ -141,7 +144,13 @@ func Page(view View)
<script><?= view.JS ?></script>
<textarea><?= view.HTML ?></textarea>
</body></html>`)
result, err := Generate(context.Background(), []string{templatePath})
genericPath := filepath.Join(directory, "list.sando")
mustWrite(t, genericPath, `<?sando go
package generated
func List[T ~string](values []T)
?>
<ul><? for _, value := range values { ?><li><?= value ?></li><? } ?></ul>`)
result, err := Generate(context.Background(), []string{templatePath, genericPath})
if err != nil {
t.Fatalf("Generate failed: %v (%v)", err, result.Diagnostics)
}
+69 -4
View File
@@ -2,7 +2,16 @@
package compiler
import "testing"
import (
"bytes"
"crypto/sha256"
"fmt"
"go/parser"
"go/token"
"reflect"
"strings"
"testing"
)
func FuzzCompileNeverPanics(f *testing.F) {
for _, seed := range []string{
@@ -14,13 +23,69 @@ func FuzzCompileNeverPanics(f *testing.F) {
"<?sando go\npackage p\nfunc F(v string)\n?>\n<script><!--<script></script>\n<?= v ?>\n<!--\n</script>\n-->",
"\xef\xbb\xbf\r\n<?sando go\r\npackage p\r\nfunc F()\r\n?>\r\n<p>x</p>",
} {
f.Add(seed)
f.Add(seed, "fuzz.sando")
}
f.Fuzz(func(t *testing.T, source string) {
_, _ = Compile("fuzz.sando", []byte(source))
f.Add("<?sando go\npackage p\nfunc F()\n?>\n<p>x</p>", "path%with\ncontrols\x00.sando")
f.Fuzz(func(t *testing.T, source, mapping string) {
if len(source) > 64<<10 || len(mapping) > 4<<10 {
t.Skip()
}
input := []byte(source)
before := append([]byte(nil), input...)
first, firstDiagnostics := compileWithMapping("fuzz.sando", input, mapping)
second, secondDiagnostics := compileWithMapping("fuzz.sando", input, mapping)
if !bytes.Equal(input, before) {
t.Fatal("compiler modified its source input")
}
if !reflect.DeepEqual(firstDiagnostics, secondDiagnostics) ||
first.SourcePath != second.SourcePath || first.OutputPath != second.OutputPath ||
first.Package != second.Package || first.Component != second.Component ||
first.Digest != second.Digest || !bytes.Equal(first.Code, second.Code) {
t.Fatal("repeated in-memory compilation was not deterministic")
}
for _, diagnostic := range firstDiagnostics {
if diagnostic.Path != "fuzz.sando" || diagnostic.Line < 1 || diagnostic.Column < 1 {
t.Fatalf("diagnostic has an invalid location: %#v", diagnostic)
}
if !validDiagnosticCode(diagnostic.Code) || strings.TrimSpace(diagnostic.Message) != diagnostic.Message || diagnostic.Message == "" {
t.Fatalf("diagnostic violates the public shape: %#v", diagnostic)
}
if diagnostic.Severity != SeverityError && diagnostic.Severity != SeverityWarning {
t.Fatalf("diagnostic has an invalid severity: %#v", diagnostic)
}
}
if len(first.Code) == 0 {
return
}
if first.SourcePath != "fuzz.sando" || first.OutputPath != "fuzz.sando.go" {
t.Fatalf("compiled paths are invalid: %#v", first)
}
if first.Digest != fmt.Sprintf("%x", sha256.Sum256(input)) {
t.Fatalf("source digest is not bound to the exact input: %s", first.Digest)
}
if _, err := parser.ParseFile(token.NewFileSet(), "fuzz.sando.go", first.Code, parser.AllErrors); err != nil {
t.Fatalf("successful compilation produced invalid Go: %v\n%s", err, first.Code)
}
for _, line := range bytes.Split(first.Code, []byte{'\n'}) {
if bytes.HasPrefix(line, []byte("//line ")) && (bytes.ContainsAny(line, "\r\x00") || bytes.Count(line, []byte(":")) < 2) {
t.Fatalf("source-map directive was not safely encoded: %q", line)
}
}
})
}
func validDiagnosticCode(code string) bool {
if len(code) != 7 || !strings.HasPrefix(code, "HIM") {
return false
}
for _, digit := range code[3:] {
if digit < '0' || digit > '9' {
return false
}
}
return true
}
func FuzzGoDelimiterNeverPanics(f *testing.F) {
for _, seed := range []string{`?>`, `"?>" ?>`, "`?>` ?>", `/* ?> */ ?>`, "// ?>\n?>", `'?' ?>`} {
f.Add(seed, uint8(0))
+12 -11
View File
@@ -59,17 +59,18 @@ type sourcePosition struct {
}
type sourceFile struct {
Path string
Mapping string
Package string
Name string
TypeParams string
Params string
Imports []sourceImport
Nodes []rendererNode
Source []byte
HeaderEnd int
AST *ast.File
Path string
Mapping string
Package string
Name string
TypeParams string
Params string
Imports []sourceImport
Nodes []rendererNode
Source []byte
HeaderEnd int
FunctionPos sourcePosition
AST *ast.File
}
type sourceImport struct {
+25 -22
View File
@@ -114,16 +114,17 @@ func parseSource(path string, source []byte) (*sourceFile, []Diagnostic) {
}
file := &sourceFile{
Path: path,
Mapping: filepath.ToSlash(filepath.Base(path)),
Package: parsedHeader.Package,
Name: parsedHeader.Name,
TypeParams: parsedHeader.TypeParams,
Params: parsedHeader.Params,
Imports: parsedHeader.Imports,
Source: source,
HeaderEnd: headerClose + 2,
AST: parsedHeader.AST,
Path: path,
Mapping: filepath.ToSlash(filepath.Base(path)),
Package: parsedHeader.Package,
Name: parsedHeader.Name,
TypeParams: parsedHeader.TypeParams,
Params: parsedHeader.Params,
Imports: parsedHeader.Imports,
Source: source,
HeaderEnd: headerClose + 2,
FunctionPos: parsedHeader.FunctionPos,
AST: parsedHeader.AST,
}
templateDiagnostics := tokenizeTemplate(file, source[headerClose+2:], headerClose+2, table)
@@ -139,12 +140,13 @@ func isSpace(b byte) bool {
}
type parsedHeader struct {
Package string
Name string
TypeParams string
Params string
Imports []sourceImport
AST *ast.File
Package string
Name string
TypeParams string
Params string
Imports []sourceImport
FunctionPos sourcePosition
AST *ast.File
}
func parseHeader(path string, declarations []byte, sourceOffset int, table positionTable) (*parsedHeader, []Diagnostic) {
@@ -254,12 +256,13 @@ func parseHeader(path string, declarations []byte, sourceOffset int, table posit
}
return &parsedHeader{
Package: parsed.Name.Name,
Name: function.Name.Name,
TypeParams: typeParams,
Params: params,
Imports: imports,
AST: parsed,
Package: parsed.Name.Name,
Name: function.Name.Name,
TypeParams: typeParams,
Params: params,
Imports: imports,
FunctionPos: table.at(sourceOffset + functionPosition.Offset),
AST: parsed,
}, diagnostics
}
+467
View File
@@ -0,0 +1,467 @@
// SPDX-License-Identifier: AGPL-3.0-only
//go:build himesan_browser_evidence
package devserver
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"gamertan.com/sandwich-hime/internal/compiler"
)
const realBrowserTimeout = 20 * time.Second
func TestRealBrowserDevelopmentClient(t *testing.T) {
chrome := os.Getenv("HIMESAN_CHROME")
if chrome == "" {
t.Fatal("HIMESAN_CHROME must name the reviewed Chrome or Chromium executable")
}
if info, err := os.Stat(chrome); err != nil || info.IsDir() || info.Mode()&0o111 == 0 {
t.Fatalf("HIMESAN_CHROME is not an executable regular file: %q", chrome)
}
t.Run("diagnostic overlay and CSP", func(t *testing.T) {
harness := newRealBrowserHarness(t)
document := runRealBrowser(t, chrome, harness.url("/"), "browser evidence diagnostic", func() {
waitForRealBrowserSubscriber(t, harness.hub)
harness.hub.publish(Event{
Type: "diagnostic", Phase: "generate", Message: "browser evidence diagnostic",
Diagnostics: []Diagnostic{{Path: "views/home.sando", Line: 7, Column: 3, Code: "HIM1300", Message: "deliberate evidence fixture"}},
})
})
for _, want := range []string{"id=\"__himesan_overlay\"", "browser evidence diagnostic", "views/home.sando:7:3 [HIM1300]"} {
if !strings.Contains(document, want) {
t.Fatalf("browser DOM lacks %q:\n%s", want, document)
}
}
})
t.Run("reload", func(t *testing.T) {
harness := newRealBrowserHarness(t)
document := runRealBrowser(t, chrome, harness.url("/"), "version two", func() {
waitForRealBrowserSubscriber(t, harness.hub)
harness.version.Store(2)
harness.hub.publish(Event{Type: "reload", Phase: "serve", Message: "healthy application activated"})
})
if !strings.Contains(document, `<main id="page-version">version two</main>`) {
t.Fatalf("browser did not reload the selected document:\n%s", document)
}
})
t.Run("fragment and API exclusions", func(t *testing.T) {
harness := newRealBrowserHarness(t)
for _, path := range []string{"/fragment", "/api"} {
document := runRealBrowser(t, chrome, harness.url(path), "", nil)
if strings.Contains(document, "data-himesan-reload") || strings.Contains(document, "__himesan_overlay") {
t.Fatalf("development client leaked into %s:\n%s", path, document)
}
}
})
t.Run("generated document parsing", func(t *testing.T) {
root := writeGeneratedBrowserApplication(t)
cfg := DefaultConfig()
cfg.ProxyAddress = "127.0.0.1:0"
cfg.HealthPath = "/healthz"
supervisor, err := New(Options{
RootDir: root,
Config: cfg,
Generate: func(ctx context.Context) error {
_, generateErr := compiler.Generate(ctx, []string{root})
return generateErr
},
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 30 * time.Second,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
StartupTimeout: integrationCandidateStartupTimeout,
ShutdownTimeout: 2 * time.Second,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
runResult := make(chan error, 1)
go func() { runResult <- supervisor.Run(ctx) }()
proxyAddress := waitForProxyAddress(t, supervisor)
waitForBody(t, "http://"+proxyAddress+"/", "generated-browser-document")
document := runRealBrowser(t, chrome, "http://"+proxyAddress+"/", "generated-browser-document", nil)
for _, want := range []string{
`<main id="generated-browser-document"`,
`<h1 id="title">&lt;unsafe&gt; &amp; "quoted"</h1>`,
`<textarea id="note">&lt;/textarea&gt;&lt;script id="attacker"&gt;window.evidenceFailed=true&lt;/script&gt;</textarea>`,
`<a id="destination" href="/safe?q=a&amp;b=c">Open</a>`,
`<table id="table"><tbody><tr><td id="cell">&lt;unsafe&gt; &amp; "quoted"</td></tr></tbody></table>`,
} {
if !strings.Contains(document, want) {
t.Fatalf("generated browser DOM lacks %q:\n%s", want, document)
}
}
if strings.Contains(document, `<script id="attacker">`) {
t.Fatalf("hostile RCDATA became executable structure:\n%s", document)
}
cancel()
select {
case err := <-runResult:
if err != nil {
t.Fatalf("stop generated-document supervisor: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("generated-document supervisor did not stop")
}
})
}
func writeGeneratedBrowserApplication(t *testing.T) string {
t.Helper()
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("resolve test-owned browser fixture: %v", err)
}
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate browser evidence source")
}
repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", ".."))
files := map[string]string{
"go.mod": fmt.Sprintf("module example.test/himesan-browser-evidence\n\ngo 1.25\n\nrequire gamertan.com/sandwich-hime/sando v0.0.0\nreplace gamertan.com/sandwich-hime/sando => %s/sando\n", filepath.ToSlash(repositoryRoot)),
"page.sando": `<?sando go
package main
func Page(title string, note string, destination string)
?>
<!doctype html>
<html><head><title><?= title ?></title></head><body>
<main id="generated-browser-document" data-note="<?= note ?>">
<h1 id="title"><?= title ?></h1>
<textarea id="note"><?= note ?></textarea>
<a id="destination" href="<?= destination ?>">Open</a>
<table id="table"><tbody><tr><td id="cell"><?= title ?></td></tr></tbody></table>
</main>
</body></html>
`,
"main.go": `package main
import (
"net/http"
"os"
"gamertan.com/sandwich-hime/sando"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
mux.HandleFunc("/", func(w http.ResponseWriter, request *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := sando.Render(request.Context(), w, Page(
` + "`<unsafe> & \"quoted\"`" + `,
` + "`</textarea><script id=\"attacker\">window.evidenceFailed=true</script>`" + `,
"/safe?q=a&b=c",
)); err != nil {
panic(err)
}
})
if err := http.ListenAndServe(os.Getenv("HIMESAN_LISTEN_ADDR"), mux); err != nil {
panic(err)
}
}
`,
}
for name, contents := range files {
if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
if _, err := compiler.Generate(context.Background(), []string{root}); err != nil {
t.Fatalf("generate browser evidence fixture: %v", err)
}
return root
}
type realBrowserHarness struct {
hub *eventHub
version atomic.Int32
upstream *httptest.Server
proxy *httptest.Server
}
func newRealBrowserHarness(t *testing.T) *realBrowserHarness {
t.Helper()
harness := &realBrowserHarness{hub: newEventHub()}
harness.version.Store(1)
harness.upstream = httptest.NewServer(http.HandlerFunc(harness.serveApplication))
development := newDevelopmentProxy(harness.hub)
upstreamURL, err := url.Parse(harness.upstream.URL)
if err != nil {
t.Fatal(err)
}
if err := development.setTarget(upstreamURL.Host); err != nil {
t.Fatal(err)
}
harness.proxy = httptest.NewServer(development)
proxyURL, err := url.Parse(harness.proxy.URL)
if err != nil {
t.Fatal(err)
}
if err := development.setAuthority(proxyURL.Host); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
harness.hub.close()
harness.proxy.Close()
harness.upstream.Close()
})
return harness
}
func (h *realBrowserHarness) url(path string) string {
return h.proxy.URL + path
}
func (h *realBrowserHarness) serveApplication(w http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/":
version := "one"
if h.version.Load() == 2 {
version = "two"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'none'; script-src-elem 'none'; connect-src 'none'")
fmt.Fprintf(w, `<!doctype html><html><body><main id="page-version">version %s</main></body></html>`, version)
case "/fragment":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<p id="fragment">fragment only</p>`)
case "/api":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"kind":"api","ok":true}`)
default:
http.NotFound(w, request)
}
}
func runRealBrowser(t *testing.T, chrome, target, want string, afterStart func()) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), realBrowserTimeout)
defer cancel()
toBrowserRead, toBrowserWrite, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
fromBrowserRead, fromBrowserWrite, err := os.Pipe()
if err != nil {
_ = toBrowserRead.Close()
_ = toBrowserWrite.Close()
t.Fatal(err)
}
defer toBrowserWrite.Close()
defer fromBrowserRead.Close()
stderrPath := filepath.Join(t.TempDir(), "chrome.stderr")
stderr, err := os.OpenFile(stderrPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
t.Fatal(err)
}
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
_ = stderr.Close()
t.Fatal(err)
}
command := exec.CommandContext(ctx, chrome,
"--headless=new",
"--disable-background-networking",
"--disable-component-update",
"--disable-default-apps",
"--disable-sync",
"--metrics-recording-only",
"--no-first-run",
"--no-default-browser-check",
"--user-data-dir="+t.TempDir(),
"--remote-debugging-pipe",
"about:blank",
)
command.ExtraFiles = []*os.File{toBrowserRead, fromBrowserWrite}
command.Stdout = devNull
command.Stderr = stderr
command.WaitDelay = 2 * time.Second
if err := command.Start(); err != nil {
_ = toBrowserRead.Close()
_ = fromBrowserWrite.Close()
_ = stderr.Close()
_ = devNull.Close()
t.Fatalf("start real browser: %v", err)
}
_ = toBrowserRead.Close()
_ = fromBrowserWrite.Close()
client := &devToolsPipe{reader: bufio.NewReader(fromBrowserRead), writer: toBrowserWrite}
targetResult := client.call(t, "", "Target.createTarget", map[string]any{"url": target})
var created struct {
TargetID string `json:"targetId"`
}
if err := json.Unmarshal(targetResult, &created); err != nil || created.TargetID == "" {
t.Fatalf("decode Chrome target: %v", err)
}
attachResult := client.call(t, "", "Target.attachToTarget", map[string]any{"targetId": created.TargetID, "flatten": true})
var attached struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(attachResult, &attached); err != nil || attached.SessionID == "" {
t.Fatalf("decode Chrome session: %v", err)
}
client.call(t, attached.SessionID, "Runtime.enable", map[string]any{})
waitForBrowserDOM(t, client, attached.SessionID, "")
if afterStart != nil {
afterStart()
}
document := waitForBrowserDOM(t, client, attached.SessionID, want)
_ = client.callIgnoringClose("", "Browser.close", map[string]any{})
waitResult := make(chan error, 1)
go func() { waitResult <- command.Wait() }()
select {
case err := <-waitResult:
if err != nil && !errors.Is(err, exec.ErrWaitDelay) {
_ = stderr.Close()
contents, _ := os.ReadFile(stderrPath)
t.Fatalf("real browser failed: %v; stderr=%s", err, boundedBrowserOutput(string(contents)))
}
case <-time.After(5 * time.Second):
_ = command.Process.Kill()
t.Fatal("real browser did not exit after Browser.close")
}
_ = stderr.Close()
_ = devNull.Close()
return document
}
type devToolsPipe struct {
reader *bufio.Reader
writer io.Writer
nextID int
}
type devToolsEnvelope struct {
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func (c *devToolsPipe) call(t *testing.T, session, method string, parameters map[string]any) json.RawMessage {
t.Helper()
c.nextID++
request := map[string]any{"id": c.nextID, "method": method, "params": parameters}
if session != "" {
request["sessionId"] = session
}
contents, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
contents = append(contents, 0)
if _, err := c.writer.Write(contents); err != nil {
t.Fatalf("write Chrome DevTools request: %v", err)
}
for {
message, err := c.reader.ReadBytes(0)
if err != nil {
t.Fatalf("read Chrome DevTools response: %v", err)
}
if len(message) > 4<<20 {
t.Fatal("Chrome DevTools response exceeds 4 MiB")
}
var response devToolsEnvelope
if err := json.Unmarshal(bytes.TrimSuffix(message, []byte{0}), &response); err != nil {
t.Fatalf("decode Chrome DevTools response: %v", err)
}
if response.ID != c.nextID {
continue
}
if response.Error != nil {
t.Fatalf("Chrome DevTools %s failed (%d): %s", method, response.Error.Code, response.Error.Message)
}
return response.Result
}
}
func (c *devToolsPipe) callIgnoringClose(session, method string, parameters map[string]any) error {
c.nextID++
request := map[string]any{"id": c.nextID, "method": method, "params": parameters}
if session != "" {
request["sessionId"] = session
}
contents, err := json.Marshal(request)
if err != nil {
return err
}
_, err = c.writer.Write(append(contents, 0))
return err
}
func waitForBrowserDOM(t *testing.T, client *devToolsPipe, session, want string) string {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
var last string
for time.Now().Before(deadline) {
result := client.call(t, session, "Runtime.evaluate", map[string]any{
"expression": "document.documentElement && document.documentElement.outerHTML",
"returnByValue": true,
})
var evaluated struct {
Result struct {
Type string `json:"type"`
Value string `json:"value"`
} `json:"result"`
}
if err := json.Unmarshal(result, &evaluated); err == nil && evaluated.Result.Type == "string" {
last = evaluated.Result.Value
if last != "" && (want == "" || strings.Contains(last, want)) {
return last
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("browser DOM did not contain %q; last DOM:\n%s", want, last)
return ""
}
func waitForRealBrowserSubscriber(t *testing.T, hub *eventHub) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
hub.mu.Lock()
count := len(hub.subscribers)
hub.mu.Unlock()
if count != 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("real browser did not establish the same-origin event stream")
}
func boundedBrowserOutput(value string) string {
const limit = 4096
if len(value) <= limit {
return value
}
return value[:limit] + "\n[Chrome output truncated]"
}
+77
View File
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-only
package devserver
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
func TestV1ConfigSchemaMatchesConfigTypeAndDefaults(t *testing.T) {
t.Parallel()
contents, err := os.ReadFile(filepath.Join("..", "..", "contracts", "himesan-config-v1.schema.json"))
if err != nil {
t.Fatal(err)
}
var schema struct {
AdditionalProperties bool `json:"additionalProperties"`
Required []string `json:"required"`
Properties map[string]json.RawMessage `json:"properties"`
}
if err := json.Unmarshal(contents, &schema); err != nil {
t.Fatal(err)
}
if schema.AdditionalProperties {
t.Fatal("v1 config schema must reject unknown fields")
}
if !reflect.DeepEqual(schema.Required, []string{"version"}) {
t.Fatalf("required config fields = %v, want [version]", schema.Required)
}
typeOfConfig := reflect.TypeOf(Config{})
fields := make([]string, 0, typeOfConfig.NumField())
for index := 0; index < typeOfConfig.NumField(); index++ {
name := strings.Split(typeOfConfig.Field(index).Tag.Get("json"), ",")[0]
fields = append(fields, name)
}
sort.Strings(fields)
properties := make([]string, 0, len(schema.Properties))
for name := range schema.Properties {
properties = append(properties, name)
}
sort.Strings(properties)
if !reflect.DeepEqual(fields, properties) {
t.Fatalf("Config JSON fields %v do not match schema properties %v", fields, properties)
}
defaults := DefaultConfig()
wantDefaults := map[string]any{
"version": float64(defaults.Version),
"sourceRoots": []any{"."},
"goPackage": defaults.GoPackage,
"appArgs": []any{},
"listenAddressEnv": defaults.ListenAddressEnv,
"healthPath": defaults.HealthPath,
"proxyAddress": defaults.ProxyAddress,
"additionalWatchRoots": []any{},
}
for name, want := range wantDefaults {
var property map[string]any
if err := json.Unmarshal(schema.Properties[name], &property); err != nil {
t.Fatal(err)
}
if name == "version" {
if !reflect.DeepEqual(property["const"], want) {
t.Fatalf("schema %s const = %#v, want %#v", name, property["const"], want)
}
continue
}
if !reflect.DeepEqual(property["default"], want) {
t.Fatalf("schema %s default = %#v, want %#v", name, property["default"], want)
}
}
}
+4 -1
View File
@@ -81,7 +81,7 @@ func (c *candidateProcess) cleanupProcessTree() error {
processTree := c.processTree
c.processTree = 0
c.mu.Unlock()
return cleanupProcess(c.command, processTree)
return acceptableStopError(cleanupProcess(c.command, processTree))
}
func (c *candidateProcess) stop(ctx context.Context) error {
@@ -119,6 +119,9 @@ func acceptableStopError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, os.ErrProcessDone) {
return nil
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return nil
+7
View File
@@ -87,6 +87,13 @@ func TestTaskkillArguments(t *testing.T) {
}
}
func TestAlreadyFinishedIsAnAcceptableStopResult(t *testing.T) {
t.Parallel()
if err := acceptableStopError(os.ErrProcessDone); err != nil {
t.Fatalf("already-finished process was treated as a stop failure: %v", err)
}
}
func TestManagedProcessHelper(t *testing.T) {
switch os.Getenv("HIMESAN_PROCESS_HELPER") {
case "":
+45 -14
View File
@@ -18,11 +18,14 @@ import (
"time"
)
const integrationCandidateStartupTimeout = 3 * time.Second
func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
if testing.Short() {
t.Skip("integration test builds temporary Go applications")
}
root := t.TempDir()
disableParentVCSStamping(t)
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-test\n\ngo 1.25\n"), 0o600); err != nil {
t.Fatal(err)
}
@@ -41,12 +44,18 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
generations.Add(1)
return nil
},
OnEvent: func(event Event) { events <- event },
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 25 * time.Millisecond,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
StartupTimeout: 750 * time.Millisecond,
OnEvent: func(event Event) {
t.Logf("supervisor event: type=%s phase=%s message=%s", event.Type, event.Phase, event.Message)
events <- event
},
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 25 * time.Millisecond,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
// Keep the production default unchanged. Native release runners may
// compile another Go line concurrently, so this integration test gives
// the temporary child enough time to be scheduled under honest load.
StartupTimeout: integrationCandidateStartupTimeout,
ShutdownTimeout: 2 * time.Second,
})
if err != nil {
@@ -101,6 +110,7 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
t.Skip("integration test builds a temporary Go application")
}
root := t.TempDir()
disableParentVCSStamping(t)
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)
}
@@ -112,15 +122,18 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
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 },
RootDir: root,
Config: cfg,
Generate: func(context.Context) error { return nil },
OnEvent: func(event Event) {
t.Logf("supervisor event: type=%s phase=%s message=%s", event.Type, event.Phase, event.Message)
events <- event
},
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 30 * time.Second,
Debounce: 25 * time.Millisecond,
BuildTimeout: 30 * time.Second,
StartupTimeout: time.Second,
StartupTimeout: integrationCandidateStartupTimeout,
ShutdownTimeout: 2 * time.Second,
})
if err != nil {
@@ -132,7 +145,11 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
t.Cleanup(cancel)
proxyAddress := waitForProxyAddress(t, supervisor)
waitForBody(t, "http://"+proxyAddress+"/", "short lived")
// Observe the supervisor's ordered activation event instead of racing an
// HTTP request against the deliberately short-lived child. Other tests
// prove proxy serving; this test proves that an activated child which exits
// clears its selected target under scheduler and compiler load.
waitForEvent(t, events, "reload", "serve")
waitForPhase(t, events, "run")
if target := supervisor.proxy.target.Load(); target != nil {
t.Fatalf("proxy retained exited upstream %v", target)
@@ -163,6 +180,15 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
}
}
func disableParentVCSStamping(t *testing.T) {
t.Helper()
// A temporary standalone module can live under a parent directory that is
// itself a VCS checkout (including hardened test sandboxes). Its candidate
// must not inherit or depend on that unrelated repository's status.
flags := strings.TrimSpace(os.Getenv("GOFLAGS") + " -buildvcs=false")
t.Setenv("GOFLAGS", flags)
}
func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) {
t.Parallel()
upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -273,17 +299,22 @@ func main() {
}
func waitForPhase(t *testing.T, events <-chan Event, phase string) {
t.Helper()
waitForEvent(t, events, "diagnostic", phase)
}
func waitForEvent(t *testing.T, events <-chan Event, eventType, phase string) {
t.Helper()
timer := time.NewTimer(10 * time.Second)
defer timer.Stop()
for {
select {
case event := <-events:
if event.Type == "diagnostic" && event.Phase == phase {
if event.Type == eventType && event.Phase == phase {
return
}
case <-timer.C:
t.Fatalf("did not receive %s diagnostic", phase)
t.Fatalf("did not receive %s/%s event", eventType, phase)
}
}
}
+304
View File
@@ -0,0 +1,304 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bytes"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"gamertan.com/sandwich-hime/internal/compiler"
)
type completionItem struct {
Label string `json:"label"`
Kind int `json:"kind,omitempty"`
Detail string `json:"detail,omitempty"`
Documentation any `json:"documentation,omitempty"`
InsertText string `json:"insertText,omitempty"`
InsertTextFormat int `json:"insertTextFormat,omitempty"`
SortText string `json:"sortText,omitempty"`
}
type markupContent struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
type hoverResult struct {
Contents markupContent `json:"contents"`
Range *Range `json:"range,omitempty"`
}
type documentSymbol struct {
Name string `json:"name"`
Detail string `json:"detail,omitempty"`
Kind int `json:"kind"`
Range Range `json:"range"`
SelectionRange Range `json:"selectionRange"`
Children []documentSymbol `json:"children,omitempty"`
}
func (server *Server) completions(request textDocumentPositionParams) any {
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
if !ok {
return []completionItem{}
}
offset, ok := positionToOffset(document.Text, request.Position)
if !ok {
return []completionItem{}
}
open := bytes.LastIndex(document.Text[:offset], []byte("<?"))
close := bytes.LastIndex(document.Text[:offset], []byte("?>"))
if open >= 0 && open > close && bytes.HasPrefix(document.Text[open:offset], []byte("<?~")) {
server.mu.RLock()
snapshot := server.snapshot
server.mu.RUnlock()
prefix := strings.TrimSpace(string(document.Text[open+3 : offset]))
items := make([]completionItem, 0)
for _, target := range snapshot.componentsFor(document.Path, analysis) {
label := target.Label()
if prefix != "" && !strings.HasPrefix(label, prefix) {
continue
}
items = append(items, completionItem{
Label: label, Kind: 3, Detail: target.Signature,
Documentation: markupContent{Kind: "markdown", Value: "Typed `.sando` component. Hime-san emits an ordinary Go constructor."},
InsertText: label, InsertTextFormat: 1, SortText: "1-" + label,
})
}
return struct {
IsIncomplete bool `json:"isIncomplete"`
Items []completionItem `json:"items"`
}{Items: items}
}
return struct {
IsIncomplete bool `json:"isIncomplete"`
Items []completionItem `json:"items"`
}{Items: tagCompletions()}
}
func tagCompletions() []completionItem {
return []completionItem{
{Label: "<?sando go", Kind: 15, Detail: "component file header", InsertText: "<?sando go\npackage ${1:views}\nfunc ${2:Component}(${3:})\n?>", InsertTextFormat: 2, SortText: "0-header"},
{Label: "<? … ?>", Kind: 15, Detail: "Go statement", InsertText: "<? ${1:if condition {} } ?>", InsertTextFormat: 2, SortText: "0-statement"},
{Label: "<?= … ?>", Kind: 15, Detail: "contextually escaped expression", InsertText: "<?= ${1:value} ?>", InsertTextFormat: 2, SortText: "0-expression"},
{Label: "<?~ … ?>", Kind: 15, Detail: "typed component composition", InsertText: "<?~ ${1:Component()} ?>", InsertTextFormat: 2, SortText: "0-component"},
{Label: "<?# … ?>", Kind: 15, Detail: "Hime-san template comment", InsertText: "<?# ${1:comment} ?>", InsertTextFormat: 2, SortText: "0-comment"},
}
}
func (server *Server) hover(request textDocumentPositionParams) any {
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
if !ok {
return nil
}
offset, ok := positionToOffset(document.Text, request.Position)
if !ok {
return nil
}
for _, region := range analysis.Regions {
if offset < region.Offset || offset > region.Offset+region.Length {
continue
}
if region.Kind == compiler.AnalysisComponent {
qualifier, name := referenceAt(region.Text, offset-region.Offset)
if name != "" {
if target, found := server.resolveComponent(document.Path, analysis, qualifier, name); found {
value := "```go\n" + target.Signature + "\n```\n\nTyped component composition. Handwritten `sando.Component` values are trusted output capabilities."
return hoverResult{Contents: markupContent{Kind: "markdown", Value: value}}
}
}
}
value := fmt.Sprintf("**Hime-san %s region**\n\nOutput context: `%s`.", region.Kind, region.Context)
if region.Context == compiler.ContextJS || region.Context == compiler.ContextCSS || strings.Contains(region.Text, "Trust") {
value += "\n\nTrusted output is an explicit security capability; audit its provenance and parser-state effects."
}
return hoverResult{Contents: markupContent{Kind: "markdown", Value: value}}
}
if marker, start, end := enclosingTag(document.Text, offset); marker != "" {
if text := tagDocumentation(marker); text != "" {
rangeValue := Range{Start: offsetToPosition(document.Text, start), End: offsetToPosition(document.Text, end)}
return hoverResult{Contents: markupContent{Kind: "markdown", Value: text}, Range: &rangeValue}
}
}
return nil
}
func tagDocumentation(marker string) string {
switch marker {
case "<?sando":
return "**`<?sando go … ?>`** declares the Go package, imports, and one typed component signature. It must be the first non-whitespace content."
case "<?~":
return "**`<?~ … ?>`** composes a typed `sando.Component` at an HTML content boundary. It is not template inheritance."
case "<?=":
return "**`<?= … ?>`** renders an expression through the helper selected by Hime-san's inferred HTML output context."
case "<?#":
return "**`<?# … ?>`** is a Hime-san comment. It emits no bytes and cannot change HTML parser state."
case "<?":
return "**`<? … ?>`** contains Go statements and is valid only at an HTML content boundary."
}
return ""
}
func enclosingTag(text []byte, offset int) (string, int, int) {
if offset < 0 || offset > len(text) {
return "", 0, 0
}
open := bytes.LastIndex(text[:offset], []byte("<?"))
if open < 0 {
return "", 0, 0
}
closeRelative := bytes.Index(text[open:], []byte("?>"))
if closeRelative < 0 || open+closeRelative+2 < offset {
return "", 0, 0
}
end := open + closeRelative + 2
marker := "<?"
for _, candidate := range []string{"<?sando", "<?~", "<?=", "<?#"} {
if bytes.HasPrefix(text[open:end], []byte(candidate)) {
marker = candidate
break
}
}
return marker, open, end
}
func (server *Server) definition(request textDocumentPositionParams) any {
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
if !ok {
return nil
}
offset, ok := positionToOffset(document.Text, request.Position)
if !ok {
return nil
}
for _, region := range analysis.Regions {
if region.Kind != compiler.AnalysisComponent || offset < region.Offset || offset > region.Offset+region.Length {
continue
}
qualifier, name := referenceAt(region.Text, offset-region.Offset)
if name == "" {
return nil
}
target, found := server.resolveComponent(document.Path, analysis, qualifier, name)
if !found {
return nil
}
server.mu.RLock()
targetDocument, exists := server.snapshot.documents[target.Path]
server.mu.RUnlock()
if !exists {
return nil
}
start := target.Analysis.ComponentOffset
end := start + len(target.Analysis.Signature)
return Location{URI: targetDocument.URI, Range: Range{Start: offsetToPosition(targetDocument.Text, start), End: offsetToPosition(targetDocument.Text, end)}}
}
return nil
}
func (server *Server) resolveComponent(path string, analysis compiler.DocumentAnalysis, qualifier, name string) (componentTarget, bool) {
server.mu.RLock()
snapshot := server.snapshot
server.mu.RUnlock()
for _, target := range snapshot.componentsFor(path, analysis) {
if target.Qualifier == qualifier && target.Name == name {
return target, true
}
}
return componentTarget{}, false
}
func referenceAt(expression string, cursor int) (string, string) {
if cursor < 0 {
cursor = 0
}
if cursor > len(expression) {
cursor = len(expression)
}
if cursor == len(expression) && cursor > 0 {
cursor--
}
for cursor > 0 && cursor < len(expression) && !identifierByte(expression[cursor]) && expression[cursor] != '.' {
cursor--
}
start := cursor
for start > 0 && (identifierByte(expression[start-1]) || expression[start-1] == '.') {
start--
}
end := cursor
for end < len(expression) && (identifierByte(expression[end]) || expression[end] == '.') {
end++
}
reference := strings.Trim(expression[start:end], ".")
parts := strings.Split(reference, ".")
if len(parts) == 1 && validIdentifier(parts[0]) {
return "", parts[0]
}
if len(parts) == 2 && validIdentifier(parts[0]) && validIdentifier(parts[1]) {
return parts[0], parts[1]
}
return "", ""
}
func identifierByte(value byte) bool {
return value == '_' || value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9'
}
func validIdentifier(value string) bool {
if value == "" {
return false
}
for index, r := range value {
if index == 0 && !(r == '_' || unicode.IsLetter(r)) {
return false
}
if index != 0 && !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return true
}
func (server *Server) documentSymbols(uri string) []documentSymbol {
document, analysis, ok := server.snapshotDocument(uri)
if !ok || analysis.Component == "" {
return []documentSymbol{}
}
documentRange := Range{Start: Position{}, End: offsetToPosition(document.Text, len(document.Text))}
selectionStart := analysis.ComponentOffset
selectionEnd := selectionStart + len(analysis.Component)
children := make([]documentSymbol, 0, len(analysis.Regions))
for index, region := range analysis.Regions {
end := region.Offset + region.Length
name := fmt.Sprintf("%s %d", region.Kind, index+1)
if region.Kind == compiler.AnalysisComponent {
_, componentName := referenceAt(region.Text, 0)
if componentName != "" {
name = "component " + componentName
}
}
rangeValue := Range{Start: offsetToPosition(document.Text, region.Offset), End: offsetToPosition(document.Text, end)}
children = append(children, documentSymbol{Name: name, Detail: string(region.Context), Kind: 13, Range: rangeValue, SelectionRange: rangeValue})
}
return []documentSymbol{{
Name: analysis.Component, Detail: analysis.Signature, Kind: 12,
Range: documentRange,
SelectionRange: Range{Start: offsetToPosition(document.Text, selectionStart), End: offsetToPosition(document.Text, selectionEnd)},
Children: children,
}}
}
func runeEnd(text []byte, offset int) int {
if offset >= len(text) {
return len(text)
}
_, size := utf8.DecodeRune(text[offset:])
if size < 1 {
size = 1
}
return offset + size
}
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"strconv"
"strings"
"sync"
)
const (
maxMessageBytes = 16 << 20
maxHeaderBytes = 64 << 10
maxHeaderLines = 64
)
type rpcMessage struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func validRequestID(id json.RawMessage) bool {
if len(id) == 0 {
return true
}
var value any
if err := json.Unmarshal(id, &value); err != nil {
return false
}
switch value := value.(type) {
case string:
return true
case float64:
return value == math.Trunc(value)
default:
return false
}
}
const (
errParse = -32700
errInvalidRequest = -32600
errMethodNotFound = -32601
errInvalidParams = -32602
errInternal = -32603
errRequestCancelled = -32800
)
type frameReader struct{ reader *bufio.Reader }
func newFrameReader(input io.Reader) *frameReader {
return &frameReader{reader: bufio.NewReaderSize(input, 64<<10)}
}
func (reader *frameReader) read() ([]byte, error) {
contentLength := -1
headerBytes := 0
headerLines := 0
for {
line, err := reader.reader.ReadString('\n')
if err != nil {
return nil, err
}
headerBytes += len(line)
headerLines++
if headerBytes > maxHeaderBytes || headerLines > maxHeaderLines {
return nil, errors.New("LSP headers exceed configured limits")
}
if len(line) > 8<<10 {
return nil, errors.New("LSP header line exceeds 8 KiB")
}
line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
if line == "" {
break
}
name, value, ok := strings.Cut(line, ":")
if !ok {
return nil, errors.New("malformed LSP header")
}
if strings.EqualFold(strings.TrimSpace(name), "Content-Length") {
if contentLength >= 0 {
return nil, errors.New("duplicate Content-Length header")
}
parsed, parseErr := strconv.Atoi(strings.TrimSpace(value))
if parseErr != nil || parsed < 0 || parsed > maxMessageBytes {
return nil, errors.New("invalid or excessive Content-Length")
}
contentLength = parsed
}
}
if contentLength < 0 {
return nil, errors.New("missing Content-Length header")
}
payload := make([]byte, contentLength)
if _, err := io.ReadFull(reader.reader, payload); err != nil {
return nil, err
}
return payload, nil
}
type frameWriter struct {
mu sync.Mutex
output io.Writer
}
func (writer *frameWriter) write(message rpcMessage) error {
payload, err := json.Marshal(message)
if err != nil {
return err
}
var frame bytes.Buffer
fmt.Fprintf(&frame, "Content-Length: %d\r\n\r\n", len(payload))
frame.Write(payload)
writer.mu.Lock()
defer writer.mu.Unlock()
written, err := writer.output.Write(frame.Bytes())
if err == nil && written != frame.Len() {
return io.ErrShortWrite
}
return err
}
type Position struct {
Line int `json:"line"`
Character int `json:"character"`
}
type Range struct {
Start Position `json:"start"`
End Position `json:"end"`
}
type Location struct {
URI string `json:"uri"`
Range Range `json:"range"`
}
type lspDiagnostic struct {
Range Range `json:"range"`
Severity int `json:"severity,omitempty"`
Code string `json:"code,omitempty"`
Source string `json:"source,omitempty"`
Message string `json:"message"`
}
type textDocumentIdentifier struct {
URI string `json:"uri"`
}
type versionedTextDocumentIdentifier struct {
URI string `json:"uri"`
Version int `json:"version"`
}
type textDocumentPositionParams struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
Position Position `json:"position"`
}
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
)
type shortWriter struct{}
func (shortWriter) Write(value []byte) (int, error) {
if len(value) == 0 {
return 0, nil
}
return len(value) - 1, nil
}
func TestProtocolFramingRoundTripAndLimits(t *testing.T) {
t.Parallel()
var output bytes.Buffer
writer := &frameWriter{output: &output}
if err := writer.write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("1"), Result: map[string]bool{"ok": true}}); err != nil {
t.Fatal(err)
}
payload, err := newFrameReader(&output).read()
if err != nil {
t.Fatal(err)
}
var decoded rpcMessage
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatal(err)
}
if string(decoded.ID) != "1" {
t.Fatalf("response ID = %s", decoded.ID)
}
invalid := "Content-Length: 999999999\r\n\r\n"
if _, err := newFrameReader(strings.NewReader(invalid)).read(); err == nil {
t.Fatal("excessive frame length was accepted")
}
duplicate := "Content-Length: 2\r\nContent-Length: 2\r\n\r\n{}"
if _, err := newFrameReader(strings.NewReader(duplicate)).read(); err == nil {
t.Fatal("duplicate Content-Length was accepted")
}
var excessive bytes.Buffer
for range maxHeaderLines + 1 {
excessive.WriteString("X-Test: value\r\n")
}
excessive.WriteString("Content-Length: 2\r\n\r\n{}")
if _, err := newFrameReader(&excessive).read(); err == nil {
t.Fatal("excessive header count was accepted")
}
if err := (&frameWriter{output: shortWriter{}}).write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("1"), Result: true}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v, want io.ErrShortWrite", err)
}
}
func TestMalformedJSONProducesProtocolErrorAndContinues(t *testing.T) {
t.Parallel()
input := bytes.NewBuffer(nil)
input.WriteString("Content-Length: 1\r\n\r\n{")
exit, err := json.Marshal(rpcMessage{JSONRPC: "2.0", Method: "exit"})
if err != nil {
t.Fatal(err)
}
fmt.Fprintf(input, "Content-Length: %d\r\n\r\n", len(exit))
input.Write(exit)
var output bytes.Buffer
if err := Run(context.Background(), Options{Input: input, Output: &output}); err != nil {
t.Fatal(err)
}
payload, err := newFrameReader(&output).read()
if err != nil {
t.Fatal(err)
}
var response rpcMessage
if err := json.Unmarshal(payload, &response); err != nil {
t.Fatal(err)
}
if response.Error == nil || response.Error.Code != errParse {
t.Fatalf("parse error response = %#v", response)
}
}
func TestRequestIDValidation(t *testing.T) {
t.Parallel()
for _, id := range []string{`1`, `-2`, `"request"`} {
if !validRequestID(json.RawMessage(id)) {
t.Errorf("valid request ID rejected: %s", id)
}
}
for _, id := range []string{`null`, `true`, `1.5`, `{}`, `[]`, `not-json`} {
if validRequestID(json.RawMessage(id)) {
t.Errorf("invalid request ID accepted: %s", id)
}
}
}
func TestUTF16PositionsWithUnicodeAndCRLF(t *testing.T) {
t.Parallel()
text := []byte("a😀b\r\n雪c\n")
tests := []struct {
offset int
position Position
}{
{offset: 0, position: Position{Line: 0, Character: 0}},
{offset: 1, position: Position{Line: 0, Character: 1}},
{offset: 5, position: Position{Line: 0, Character: 3}},
{offset: 8, position: Position{Line: 1, Character: 0}},
{offset: 11, position: Position{Line: 1, Character: 1}},
}
for _, test := range tests {
if got := offsetToPosition(text, test.offset); got != test.position {
t.Errorf("offsetToPosition(%d) = %#v, want %#v", test.offset, got, test.position)
}
if got, ok := positionToOffset(text, test.position); !ok || got != test.offset {
t.Errorf("positionToOffset(%#v) = %d, %v; want %d, true", test.position, got, ok, test.offset)
}
}
if _, ok := positionToOffset(text, Position{Line: 0, Character: 2}); ok {
t.Fatal("position inside UTF-16 surrogate pair was accepted")
}
}
func FuzzFrameReaderNeverPanics(f *testing.F) {
f.Add([]byte("Content-Length: 2\r\n\r\n{}"))
f.Add([]byte("Content-Length: nope\r\n\r\n"))
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > 64<<10 {
t.Skip()
}
_, _ = newFrameReader(bytes.NewReader(data)).read()
})
}
func FuzzDocumentPositionNeverPanics(f *testing.F) {
f.Add("hello 😀\r\nworld", 0, 7)
f.Add("雪", 0, 1)
f.Fuzz(func(t *testing.T, text string, line, character int) {
if len(text) > 64<<10 || line < -10000 || line > 10000 || character < -10000 || character > 100000 {
t.Skip()
}
offset, ok := positionToOffset([]byte(text), Position{Line: line, Character: character})
if ok {
_ = offsetToPosition([]byte(text), offset)
}
})
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestLanguageServerSourceHasNoExecutionNetworkOrWriteCapability(t *testing.T) {
t.Parallel()
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
forbiddenImports := map[string]bool{
"net": true, "net/http": true, "net/rpc": true,
"os/exec": true, "syscall": true,
}
forbiddenOSCalls := map[string]bool{
"Create": true, "CreateTemp": true, "Mkdir": true, "MkdirAll": true,
"OpenFile": true, "Remove": true, "RemoveAll": true, "Rename": true,
"WriteFile": true, "Chmod": true, "Chown": true,
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
parsed, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, 0)
if err != nil {
t.Fatal(err)
}
for _, imported := range parsed.Imports {
path, err := strconv.Unquote(imported.Path.Value)
if err != nil {
t.Fatal(err)
}
if forbiddenImports[path] {
t.Errorf("%s imports forbidden capability %s", entry.Name(), path)
}
}
ast.Inspect(parsed, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok || !forbiddenOSCalls[selector.Sel.Name] {
return true
}
identifier, ok := selector.X.(*ast.Ident)
if ok && identifier.Name == "os" {
t.Errorf("%s calls forbidden filesystem mutation os.%s", entry.Name(), selector.Sel.Name)
}
return true
})
}
}
+371
View File
@@ -0,0 +1,371 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Package lsp implements Hime-san's read-only Language Server Protocol
// adapter. It deliberately owns no generation, Go toolchain, HTTP, network,
// or project-execution behavior.
package lsp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gamertan.com/sandwich-hime/internal/compiler"
)
// Options configures one stdio language-server process.
type Options struct {
Input io.Reader
Output io.Writer
LogOutput io.Writer
Debounce time.Duration
}
// Server serves exactly one workspace root.
type Server struct {
context context.Context
cancel context.CancelFunc
reader *frameReader
writer *frameWriter
logs io.Writer
mu sync.RWMutex
root string
initialized bool
shutdown bool
overlays map[string]document
snapshot workspaceSnapshot
analysisCancel context.CancelFunc
analysisTimer *time.Timer
analysisGeneration uint64
debounce time.Duration
afterFunc func(time.Duration, func()) *time.Timer
requests map[string]context.CancelFunc
wait sync.WaitGroup
analysisWait sync.WaitGroup
}
// Run serves LSP JSON-RPC until the client sends exit, closes stdin, or the
// parent context is canceled.
func Run(parent context.Context, options Options) error {
if options.Input == nil || options.Output == nil {
return errors.New("LSP stdin and stdout are required")
}
if options.LogOutput == nil {
options.LogOutput = io.Discard
}
if options.Debounce <= 0 {
options.Debounce = 200 * time.Millisecond
}
ctx, cancel := context.WithCancel(parent)
server := &Server{
context: ctx, cancel: cancel,
reader: newFrameReader(options.Input), writer: &frameWriter{output: options.Output}, logs: options.LogOutput,
overlays: make(map[string]document), snapshot: workspaceSnapshot{documents: make(map[string]document), analyses: make(map[string]compiler.DocumentAnalysis)},
debounce: options.Debounce, afterFunc: time.AfterFunc, requests: make(map[string]context.CancelFunc),
}
defer func() {
cancel()
server.mu.Lock()
server.analysisGeneration++
if server.analysisTimer != nil {
server.analysisTimer.Stop()
}
if server.analysisCancel != nil {
server.analysisCancel()
}
for _, requestCancel := range server.requests {
requestCancel()
}
server.mu.Unlock()
server.wait.Wait()
server.analysisWait.Wait()
}()
for {
payload, err := server.reader.read()
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || ctx.Err() != nil {
return nil
}
return fmt.Errorf("read LSP frame: %w", err)
}
var message rpcMessage
if err := json.Unmarshal(payload, &message); err != nil {
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &rpcError{Code: errParse, Message: "invalid JSON"}})
continue
}
if message.JSONRPC != "2.0" || message.Method == "" || !validRequestID(message.ID) {
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: responseID(message.ID), Error: &rpcError{Code: errInvalidRequest, Message: "invalid JSON-RPC request"}})
continue
}
if len(message.ID) == 0 {
if message.Method == "exit" {
server.cancel()
return nil
}
server.handleNotification(message.Method, message.Params)
continue
}
server.startRequest(message)
}
}
func responseID(id json.RawMessage) json.RawMessage {
if len(id) == 0 {
return json.RawMessage("null")
}
return append(json.RawMessage(nil), id...)
}
func (server *Server) startRequest(message rpcMessage) {
key := string(message.ID)
ctx, cancel := context.WithCancel(server.context)
server.mu.Lock()
server.requests[key] = cancel
server.mu.Unlock()
server.wait.Add(1)
go func() {
defer server.wait.Done()
defer cancel()
result, rpcErr := server.handleRequest(ctx, message.Method, message.Params)
if ctx.Err() != nil && rpcErr == nil {
rpcErr = &rpcError{Code: errRequestCancelled, Message: "request canceled"}
}
if rpcErr == nil && result == nil {
result = json.RawMessage("null")
}
server.mu.Lock()
delete(server.requests, key)
server.mu.Unlock()
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: responseID(message.ID), Result: result, Error: rpcErr})
}()
}
func (server *Server) handleRequest(ctx context.Context, method string, params json.RawMessage) (any, *rpcError) {
switch method {
case "initialize":
return server.initialize(params)
case "shutdown":
server.mu.Lock()
server.shutdown = true
if server.analysisCancel != nil {
server.analysisCancel()
}
server.mu.Unlock()
return nil, nil
}
server.mu.RLock()
ready := server.initialized && !server.shutdown
server.mu.RUnlock()
if !ready {
return nil, &rpcError{Code: errInvalidRequest, Message: "language server is not initialized"}
}
select {
case <-ctx.Done():
return nil, &rpcError{Code: errRequestCancelled, Message: "request canceled"}
default:
}
switch method {
case "textDocument/completion":
var request textDocumentPositionParams
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid completion parameters"}
}
return server.completions(request), nil
case "textDocument/hover":
var request textDocumentPositionParams
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid hover parameters"}
}
return server.hover(request), nil
case "textDocument/definition":
var request textDocumentPositionParams
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid definition parameters"}
}
return server.definition(request), nil
case "textDocument/documentSymbol":
var request struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
}
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid document-symbol parameters"}
}
return server.documentSymbols(request.TextDocument.URI), nil
default:
return nil, &rpcError{Code: errMethodNotFound, Message: "method not supported"}
}
}
func (server *Server) initialize(params json.RawMessage) (any, *rpcError) {
var request struct {
RootURI string `json:"rootUri"`
RootPath string `json:"rootPath"`
WorkspaceFolders []struct {
URI string `json:"uri"`
} `json:"workspaceFolders"`
}
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid initialize parameters"}
}
if len(request.WorkspaceFolders) > 1 {
return nil, &rpcError{Code: errInvalidParams, Message: "Hime-san accepts one workspace folder per language-server process"}
}
rootURI := request.RootURI
if len(request.WorkspaceFolders) == 1 {
rootURI = request.WorkspaceFolders[0].URI
}
var root string
var err error
if rootURI != "" {
root, err = fileURIToPath(rootURI)
} else if request.RootPath != "" {
root, err = filepath.Abs(request.RootPath)
} else {
root, err = os.Getwd()
}
if err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "workspace root is not a local filesystem path"}
}
info, err := os.Lstat(root)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, &rpcError{Code: errInvalidParams, Message: "workspace root must be an existing non-symlink directory"}
}
evaluated, err := filepath.EvalSymlinks(root)
if err != nil || filepath.Clean(evaluated) != filepath.Clean(root) {
return nil, &rpcError{Code: errInvalidParams, Message: "workspace roots reached through symlinks are not supported"}
}
server.mu.Lock()
if server.initialized {
server.mu.Unlock()
return nil, &rpcError{Code: errInvalidRequest, Message: "initialize may be sent only once"}
}
server.root = filepath.Clean(root)
server.initialized = true
server.mu.Unlock()
return struct {
Capabilities any `json:"capabilities"`
ServerInfo any `json:"serverInfo"`
}{
Capabilities: map[string]any{
"positionEncoding": "utf-16",
"textDocumentSync": map[string]any{"openClose": true, "change": 1, "save": map[string]any{"includeText": true}},
"completionProvider": map[string]any{"triggerCharacters": []string{"<", "?", "~", "."}, "resolveProvider": false},
"hoverProvider": true, "definitionProvider": true, "documentSymbolProvider": true,
"workspace": map[string]any{"workspaceFolders": map[string]any{"supported": false, "changeNotifications": false}},
},
ServerInfo: map[string]any{"name": "himesan", "version": compiler.CompilerVersion},
}, nil
}
func (server *Server) handleNotification(method string, params json.RawMessage) {
switch method {
case "initialized":
server.scheduleReindex(false)
case "$/cancelRequest":
var request struct {
ID json.RawMessage `json:"id"`
}
if json.Unmarshal(params, &request) == nil {
server.mu.RLock()
cancel := server.requests[string(request.ID)]
server.mu.RUnlock()
if cancel != nil {
cancel()
}
}
case "textDocument/didOpen":
var request struct {
TextDocument struct {
URI string `json:"uri"`
Version int `json:"version"`
Text string `json:"text"`
} `json:"textDocument"`
}
if json.Unmarshal(params, &request) == nil {
server.updateOverlay(request.TextDocument.URI, request.TextDocument.Version, request.TextDocument.Text, true)
server.scheduleReindex(false)
}
case "textDocument/didChange":
var request struct {
TextDocument versionedTextDocumentIdentifier `json:"textDocument"`
ContentChanges []struct {
Range *Range `json:"range,omitempty"`
Text string `json:"text"`
} `json:"contentChanges"`
}
if json.Unmarshal(params, &request) == nil && len(request.ContentChanges) != 0 {
change := request.ContentChanges[len(request.ContentChanges)-1]
if change.Range == nil {
server.updateOverlay(request.TextDocument.URI, request.TextDocument.Version, change.Text, true)
server.scheduleReindex(true)
}
}
case "textDocument/didSave":
var request struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
Text *string `json:"text,omitempty"`
}
if json.Unmarshal(params, &request) == nil {
if request.Text != nil {
server.updateOverlay(request.TextDocument.URI, -1, *request.Text, true)
}
server.scheduleReindex(false)
}
case "textDocument/didClose":
var request struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
}
if json.Unmarshal(params, &request) == nil {
if path, err := fileURIToPath(request.TextDocument.URI); err == nil {
server.mu.Lock()
delete(server.overlays, path)
server.mu.Unlock()
server.scheduleReindex(false)
}
}
case "workspace/didChangeWatchedFiles":
server.scheduleReindex(false)
}
}
func (server *Server) updateOverlay(uri string, version int, text string, open bool) {
if len(text) > maxDocumentBytes || !strings.HasSuffix(strings.ToLower(uri), ".sando") {
return
}
path, err := fileURIToPath(uri)
if err != nil {
return
}
server.mu.Lock()
defer server.mu.Unlock()
if !editorPathAllowed(server.root, path) {
return
}
if previous, ok := server.overlays[path]; ok && version < 0 {
version = previous.Version
}
server.overlays[path] = document{URI: uri, Path: path, Text: []byte(text), Version: version, Open: open}
}
func (server *Server) notify(method string, params any) error {
payload, err := json.Marshal(params)
if err != nil {
return err
}
return server.writer.write(rpcMessage{JSONRPC: "2.0", Method: method, Params: payload})
}
func (server *Server) log(message string, count int) {
// Logs deliberately contain only fixed messages and counts. Source text,
// paths, environment values, and process details never cross this boundary.
fmt.Fprintf(server.logs, "himesan lsp: %s (%d)\n", message, count)
}
+318
View File
@@ -0,0 +1,318 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"gamertan.com/sandwich-hime/internal/compiler"
"gamertan.com/sandwich-hime/internal/testpath"
)
type protocolClient struct {
input *io.PipeWriter
output *frameReader
nextID int
lastPayload []byte
}
func newProtocolClient(t *testing.T, root string) (*protocolClient, <-chan error) {
t.Helper()
serverInput, clientInput := io.Pipe()
clientOutput, serverOutput := io.Pipe()
done := make(chan error, 1)
go func() {
done <- Run(context.Background(), Options{Input: serverInput, Output: serverOutput, LogOutput: io.Discard, Debounce: 10 * time.Millisecond})
_ = serverOutput.Close()
}()
client := &protocolClient{input: clientInput, output: newFrameReader(clientOutput)}
response := client.call(t, "initialize", map[string]any{"rootUri": pathToURI(root)})
if response.Error != nil {
t.Fatalf("initialize: %#v", response.Error)
}
client.notify(t, "initialized", map[string]any{})
return client, done
}
func (client *protocolClient) send(t *testing.T, message rpcMessage) {
t.Helper()
payload, err := json.Marshal(message)
if err != nil {
t.Fatal(err)
}
frame := append([]byte("Content-Length: "+itoa(len(payload))+"\r\n\r\n"), payload...)
if _, err := client.input.Write(frame); err != nil {
t.Fatal(err)
}
}
func (client *protocolClient) call(t *testing.T, method string, params any) rpcMessage {
t.Helper()
client.nextID++
id := client.nextID
payload, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
client.send(t, rpcMessage{JSONRPC: "2.0", ID: json.RawMessage(itoa(id)), Method: method, Params: payload})
for {
message := client.read(t)
if string(message.ID) == itoa(id) {
return message
}
}
}
func (client *protocolClient) notify(t *testing.T, method string, params any) {
t.Helper()
payload, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
client.send(t, rpcMessage{JSONRPC: "2.0", Method: method, Params: payload})
}
func (client *protocolClient) read(t *testing.T) rpcMessage {
t.Helper()
type result struct {
message rpcMessage
payload []byte
err error
}
ready := make(chan result, 1)
go func() {
payload, err := client.output.read()
if err != nil {
ready <- result{err: err}
return
}
var message rpcMessage
err = json.Unmarshal(payload, &message)
ready <- result{message: message, payload: payload, err: err}
}()
select {
case got := <-ready:
if got.err != nil {
t.Fatal(got.err)
}
client.lastPayload = got.payload
return got.message
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for language-server response")
return rpcMessage{}
}
}
func (client *protocolClient) waitDiagnostics(t *testing.T, uri string, wantCode string) []lspDiagnostic {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
message := client.read(t)
if message.Method != "textDocument/publishDiagnostics" {
continue
}
var published struct {
URI string `json:"uri"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
}
if json.Unmarshal(message.Params, &published) != nil || published.URI != uri {
continue
}
if wantCode == "" {
return published.Diagnostics
}
for _, item := range published.Diagnostics {
if item.Code == wantCode {
return published.Diagnostics
}
}
}
t.Fatalf("timed out waiting for %s diagnostic", wantCode)
return nil
}
func TestServerOverlayFeaturesAndNoWrites(t *testing.T) {
root := testpath.TempDir(t)
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.test/project\n\ngo 1.25\n")
homePath := filepath.Join(root, "home.sando")
badgePath := filepath.Join(root, "cards", "badge.sando")
writeTestFile(t, homePath, "<?sando go\npackage views\nfunc Home(visitor string)\n?>\n<p><?= visitor ?></p>\n")
writeTestFile(t, badgePath, "<?sando go\npackage cards\nfunc Badge(label string)\n?>\n<strong><?= label ?></strong>\n")
client, done := newProtocolClient(t, root)
homeURI := pathToURI(homePath)
client.waitDiagnostics(t, homeURI, "")
overlay := "<?sando go\npackage views\nimport \"example.test/project/cards\"\nfunc Home(visitor string)\n?>\n<p>😀 <?= visitor ?></p>\n<?~ cards.Badge(\"new\") ?>\n"
client.notify(t, "textDocument/didOpen", map[string]any{"textDocument": map[string]any{"uri": homeURI, "languageId": "sando", "version": 1, "text": overlay}})
if diagnostics := client.waitDiagnostics(t, homeURI, ""); len(diagnostics) != 0 {
t.Fatalf("valid overlay diagnostics = %#v", diagnostics)
}
completionOffset := strings.Index(overlay, "cards.Badge") + len("cards.B")
completion := client.call(t, "textDocument/completion", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), completionOffset)})
assertJSONContains(t, completion.Result, `"label":"cards.Badge"`)
definitionOffset := strings.Index(overlay, "Badge") + 2
definition := client.call(t, "textDocument/definition", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), definitionOffset)})
assertJSONContains(t, definition.Result, pathToURI(badgePath))
hoverOffset := strings.Index(overlay, "visitor ?></p>") + 2
hover := client.call(t, "textDocument/hover", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), hoverOffset)})
assertJSONContains(t, hover.Result, "html-text")
symbols := client.call(t, "textDocument/documentSymbol", map[string]any{"textDocument": map[string]string{"uri": homeURI}})
assertJSONContains(t, symbols.Result, `"name":"Home"`)
if err := os.Remove(badgePath); err != nil {
t.Fatal(err)
}
client.notify(t, "workspace/didChangeWatchedFiles", map[string]any{"changes": []map[string]any{{"uri": pathToURI(badgePath), "type": 3}}})
client.waitDiagnostics(t, pathToURI(badgePath), "")
completion = client.call(t, "textDocument/completion", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), completionOffset)})
if payload, _ := json.Marshal(completion.Result); strings.Contains(string(payload), `"label":"cards.Badge"`) {
t.Fatalf("deleted component remained in completion index: %s", payload)
}
broken := strings.Replace(overlay, "<?~ cards.Badge(\"new\") ?>", "<div>", 1)
client.notify(t, "textDocument/didChange", map[string]any{
"textDocument": map[string]any{"uri": homeURI, "version": 2},
"contentChanges": []map[string]string{{"text": broken}},
})
client.waitDiagnostics(t, homeURI, "HIM1311")
if _, err := os.Stat(homePath + ".go"); !os.IsNotExist(err) {
t.Fatalf("language server wrote generated output: %v", err)
}
shutdown := client.call(t, "shutdown", map[string]any{})
if shutdown.Error != nil {
t.Fatalf("shutdown: %#v", shutdown.Error)
}
if !bytes.Contains(client.lastPayload, []byte(`"result":null`)) {
t.Fatalf("shutdown response omitted JSON-RPC null result: %s", client.lastPayload)
}
client.notify(t, "exit", map[string]any{})
_ = client.input.Close()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("language server did not exit")
}
}
func TestOverlayHonorsNestedModuleAndSymlinkBoundaries(t *testing.T) {
root := testpath.TempDir(t)
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.test/root\n")
nestedPath := filepath.Join(root, "nested", "view.sando")
writeTestFile(t, filepath.Join(root, "nested", "go.mod"), "module example.test/nested\n")
writeTestFile(t, nestedPath, "<?sando go\npackage nested\nfunc View()\n?>\n<p>no</p>\n")
server := &Server{root: root, overlays: make(map[string]document)}
server.updateOverlay(pathToURI(nestedPath), 1, "ignored", true)
if len(server.overlays) != 0 {
t.Fatalf("nested-module overlay was accepted: %#v", server.overlays)
}
if runtime.GOOS != "windows" {
realDirectory := filepath.Join(root, "real")
if err := os.Mkdir(realDirectory, 0o700); err != nil {
t.Fatal(err)
}
linkDirectory := filepath.Join(root, "linked")
if err := os.Symlink(realDirectory, linkDirectory); err != nil {
t.Fatal(err)
}
linkedPath := filepath.Join(linkDirectory, "view.sando")
server.updateOverlay(pathToURI(linkedPath), 1, "ignored", true)
if len(server.overlays) != 0 {
t.Fatalf("symlink overlay was accepted: %#v", server.overlays)
}
}
}
func TestServerRejectsMultipleRootsAndCanceledRequest(t *testing.T) {
root := testpath.TempDir(t)
server := &Server{initialized: true, snapshot: workspaceSnapshot{documents: map[string]document{}, analyses: map[string]compiler.DocumentAnalysis{}}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, rpcErr := server.handleRequest(ctx, "textDocument/completion", json.RawMessage(`{}`))
if rpcErr == nil || rpcErr.Code != errRequestCancelled {
t.Fatalf("canceled request error = %#v", rpcErr)
}
input, writer := io.Pipe()
reader, serverOutput := io.Pipe()
done := make(chan error, 1)
go func() { done <- Run(context.Background(), Options{Input: input, Output: serverOutput}) }()
client := &protocolClient{input: writer, output: newFrameReader(reader)}
response := client.call(t, "initialize", map[string]any{"workspaceFolders": []map[string]string{{"uri": pathToURI(root)}, {"uri": pathToURI(root)}}})
if response.Error == nil || response.Error.Code != errInvalidParams {
t.Fatalf("multiple-root response = %#v", response)
}
client.notify(t, "exit", map[string]any{})
_ = writer.Close()
<-done
}
func TestReindexCountsOpenOverlaysInWorkspaceLimit(t *testing.T) {
root := testpath.TempDir(t)
server := &Server{
root: root,
overlays: make(map[string]document, maxWorkspaceFiles+1),
snapshot: workspaceSnapshot{documents: map[string]document{}, analyses: map[string]compiler.DocumentAnalysis{}},
writer: &frameWriter{output: io.Discard},
logs: io.Discard,
}
for index := range maxWorkspaceFiles + 1 {
path := filepath.Join(root, "overlay-"+itoa(index)+".sando")
server.overlays[path] = document{URI: pathToURI(path), Path: path, Text: []byte("<?sando go\npackage views\nfunc View()\n?>\n"), Open: true}
}
err := server.reindex(context.Background())
if err == nil || !strings.Contains(err.Error(), "more than 10000") {
t.Fatalf("reindex overlay limit error = %v", err)
}
}
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func assertJSONContains(t *testing.T, value any, marker string) {
t.Helper()
payload, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(payload), marker) {
t.Fatalf("JSON %s does not contain %q", payload, marker)
}
}
func itoa(value int) string {
if value == 0 {
return "0"
}
var digits [20]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + value%10)
value /= 10
}
return string(digits[index:])
}
+466
View File
@@ -0,0 +1,466 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"unicode/utf16"
"unicode/utf8"
"gamertan.com/sandwich-hime/internal/compiler"
)
const (
maxDocumentBytes = 16 << 20
maxWorkspaceBytes = 64 << 20
maxWorkspaceFiles = 10000
)
type document struct {
URI string
Path string
Text []byte
Version int
Open bool
}
type workspaceSnapshot struct {
documents map[string]document
analyses map[string]compiler.DocumentAnalysis
moduleRoot string
modulePath string
}
func (server *Server) reindex(ctx context.Context) error {
server.mu.RLock()
root := server.root
overlays := make(map[string]document, len(server.overlays))
for path, item := range server.overlays {
overlays[path] = item
}
server.mu.RUnlock()
if root == "" {
return nil
}
paths, discoveryDiagnostics := compiler.DiscoverSources(ctx, []string{root})
if err := ctx.Err(); err != nil {
return err
}
if len(paths) > maxWorkspaceFiles {
return fmt.Errorf("workspace contains more than %d .sando files", maxWorkspaceFiles)
}
documents := make(map[string]document, len(paths)+len(overlays))
total := 0
for _, path := range paths {
absolute, err := filepath.Abs(path)
if err != nil {
continue
}
absolute = filepath.Clean(absolute)
if overlay, ok := overlays[absolute]; ok {
documents[absolute] = overlay
total += len(overlay.Text)
continue
}
info, err := os.Lstat(absolute)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > maxDocumentBytes {
continue
}
content, err := os.ReadFile(absolute)
if err != nil || len(content) > maxDocumentBytes {
continue
}
documents[absolute] = document{URI: pathToURI(absolute), Path: absolute, Text: content}
total += len(content)
if total > maxWorkspaceBytes {
return fmt.Errorf("workspace .sando sources exceed %d bytes", maxWorkspaceBytes)
}
}
for path, overlay := range overlays {
if _, ok := documents[path]; ok {
continue
}
if overlay.Open && editorPathAllowed(root, path) {
documents[path] = overlay
total += len(overlay.Text)
}
}
if len(documents) > maxWorkspaceFiles {
return fmt.Errorf("workspace contains more than %d .sando files", maxWorkspaceFiles)
}
if total > maxWorkspaceBytes {
return fmt.Errorf("workspace .sando sources exceed %d bytes", maxWorkspaceBytes)
}
inputs := make([]compiler.SourceInput, 0, len(documents))
for _, item := range documents {
inputs = append(inputs, compiler.SourceInput{Path: item.Path, Source: item.Text})
}
analysed := compiler.AnalyzeSources(ctx, inputs)
if err := ctx.Err(); err != nil {
return err
}
analyses := make(map[string]compiler.DocumentAnalysis, len(analysed))
for _, item := range analysed {
analyses[filepath.Clean(item.Path)] = item
}
moduleRoot, modulePath := moduleIdentity(root)
server.mu.Lock()
previous := server.snapshot.documents
server.snapshot = workspaceSnapshot{documents: documents, analyses: analyses, moduleRoot: moduleRoot, modulePath: modulePath}
server.mu.Unlock()
all := make(map[string]bool, len(previous)+len(documents))
for path := range previous {
all[path] = true
}
for path := range documents {
all[path] = true
}
ordered := make([]string, 0, len(all))
for path := range all {
ordered = append(ordered, path)
}
sort.Strings(ordered)
for _, path := range ordered {
item, exists := documents[path]
uri := pathToURI(path)
diagnostics := make([]lspDiagnostic, 0)
if exists {
uri = item.URI
for _, diagnostic := range analyses[path].Diagnostics {
diagnostics = append(diagnostics, diagnosticToLSP(item.Text, diagnostic))
}
}
if err := server.notify("textDocument/publishDiagnostics", struct {
URI string `json:"uri"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
}{URI: uri, Diagnostics: diagnostics}); err != nil {
return err
}
}
if len(discoveryDiagnostics) != 0 {
server.log("workspace discovery reported boundary diagnostics", len(discoveryDiagnostics))
}
server.log("analysis completed", len(documents))
return nil
}
func (server *Server) scheduleReindex(delay bool) {
server.mu.Lock()
if server.analysisCancel != nil {
server.analysisCancel()
}
if server.analysisTimer != nil {
server.analysisTimer.Stop()
}
generation := server.analysisGeneration + 1
server.analysisGeneration = generation
wait := server.debounce
if !delay {
wait = 0
}
server.analysisTimer = server.afterFunc(wait, func() {
ctx, cancel := context.WithCancel(server.context)
server.mu.Lock()
if server.analysisGeneration != generation {
server.mu.Unlock()
cancel()
return
}
server.analysisWait.Add(1)
server.analysisCancel = cancel
server.mu.Unlock()
defer server.analysisWait.Done()
err := server.reindex(ctx)
cancel()
server.mu.Lock()
if server.analysisGeneration == generation {
server.analysisCancel = nil
}
server.mu.Unlock()
if err != nil && !errors.Is(err, context.Canceled) {
server.log("analysis failed", 1)
}
})
server.mu.Unlock()
}
func (server *Server) snapshotDocument(uri string) (document, compiler.DocumentAnalysis, bool) {
path, err := fileURIToPath(uri)
if err != nil {
return document{}, compiler.DocumentAnalysis{}, false
}
server.mu.RLock()
defer server.mu.RUnlock()
item, ok := server.snapshot.documents[path]
if !ok {
return document{}, compiler.DocumentAnalysis{}, false
}
return item, server.snapshot.analyses[path], true
}
func moduleIdentity(root string) (string, string) {
candidate := filepath.Join(root, "go.mod")
content, err := os.ReadFile(candidate)
if err != nil || len(content) > 1<<20 {
return "", ""
}
for _, line := range strings.Split(string(content), "\n") {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) == 2 && fields[0] == "module" {
return filepath.Clean(root), fields[1]
}
}
return "", ""
}
func (snapshot workspaceSnapshot) packageImportPath(directory string) string {
if snapshot.moduleRoot == "" || snapshot.modulePath == "" || !withinRoot(snapshot.moduleRoot, directory) {
return ""
}
relative, err := filepath.Rel(snapshot.moduleRoot, directory)
if err != nil || relative == "." {
return snapshot.modulePath
}
return strings.TrimSuffix(snapshot.modulePath, "/") + "/" + filepath.ToSlash(relative)
}
func (snapshot workspaceSnapshot) componentsFor(path string, analysis compiler.DocumentAnalysis) []componentTarget {
directory := filepath.Dir(path)
var targets []componentTarget
for targetPath, targetAnalysis := range snapshot.analyses {
if targetAnalysis.Component == "" {
continue
}
targetDirectory := filepath.Dir(targetPath)
if targetDirectory == directory && targetAnalysis.Package == analysis.Package {
targets = append(targets, componentTarget{Name: targetAnalysis.Component, Signature: targetAnalysis.Signature, Path: targetPath, Analysis: targetAnalysis})
continue
}
importPath := snapshot.packageImportPath(targetDirectory)
for _, imported := range analysis.Imports {
if imported.Path != importPath || imported.Alias == "_" || imported.Alias == "." {
continue
}
alias := imported.Alias
if alias == "" {
alias = targetAnalysis.Package
}
targets = append(targets, componentTarget{Qualifier: alias, Name: targetAnalysis.Component, Signature: targetAnalysis.Signature, Path: targetPath, Analysis: targetAnalysis})
}
}
sort.SliceStable(targets, func(i, j int) bool {
return targets[i].Label() < targets[j].Label()
})
return targets
}
type componentTarget struct {
Qualifier string
Name string
Signature string
Path string
Analysis compiler.DocumentAnalysis
}
func (target componentTarget) Label() string {
if target.Qualifier == "" {
return target.Name
}
return target.Qualifier + "." + target.Name
}
func withinRoot(root, path string) bool {
relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func editorPathAllowed(root, path string) bool {
if filepath.Ext(path) != ".sando" || !withinRoot(root, path) {
return false
}
relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
if err != nil {
return false
}
current := filepath.Clean(root)
parts := strings.Split(relative, string(filepath.Separator))
for index, part := range parts {
if part == "" || part == "." {
continue
}
if index < len(parts)-1 && (part == ".git" || part == ".hg" || part == ".svn" || part == "vendor") {
return false
}
current = filepath.Join(current, part)
info, statErr := os.Lstat(current)
if statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return false
}
if statErr != nil && !os.IsNotExist(statErr) {
return false
}
if index < len(parts)-1 && current != filepath.Clean(root) {
moduleInfo, moduleErr := os.Lstat(filepath.Join(current, "go.mod"))
if moduleErr == nil || moduleInfo != nil {
return false
}
if moduleErr != nil && !os.IsNotExist(moduleErr) {
return false
}
}
}
return true
}
func fileURIToPath(value string) (string, error) {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "file" || (parsed.Host != "" && parsed.Host != "localhost") {
return "", errors.New("only local file URIs are supported")
}
path, err := url.PathUnescape(parsed.EscapedPath())
if err != nil {
return "", err
}
if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' {
path = path[1:]
}
absolute, err := filepath.Abs(filepath.FromSlash(path))
if err != nil {
return "", err
}
return filepath.Clean(absolute), nil
}
func pathToURI(path string) string {
absolute, err := filepath.Abs(path)
if err != nil {
absolute = filepath.Clean(path)
}
slashed := filepath.ToSlash(absolute)
if runtime.GOOS == "windows" && !strings.HasPrefix(slashed, "/") {
slashed = "/" + slashed
}
return (&url.URL{Scheme: "file", Path: slashed}).String()
}
func offsetToPosition(text []byte, offset int) Position {
if offset < 0 {
offset = 0
}
if offset > len(text) {
offset = len(text)
}
line, character := 0, 0
for index := 0; index < offset; {
if text[index] == '\n' {
line++
character = 0
index++
continue
}
r, size := utf8.DecodeRune(text[index:])
if r == utf8.RuneError && size == 1 {
character++
index++
continue
}
character += len(utf16.Encode([]rune{r}))
index += size
}
return Position{Line: line, Character: character}
}
func positionToOffset(text []byte, position Position) (int, bool) {
if position.Line < 0 || position.Character < 0 {
return 0, false
}
line := 0
start := 0
for start < len(text) && line < position.Line {
if text[start] == '\n' {
line++
}
start++
}
if line != position.Line {
return 0, false
}
units := 0
for index := start; index < len(text) && text[index] != '\n'; {
if units == position.Character {
return index, true
}
r, size := utf8.DecodeRune(text[index:])
if r == utf8.RuneError && size == 1 {
units++
index++
} else {
units += len(utf16.Encode([]rune{r}))
index += size
}
if units > position.Character {
return 0, false
}
}
if units == position.Character {
index := start
for index < len(text) && text[index] != '\n' {
index++
}
return index, true
}
return 0, false
}
func compilerPositionOffset(text []byte, line, column int) int {
if line < 1 {
line = 1
}
if column < 1 {
column = 1
}
start := 0
for current := 1; current < line && start < len(text); current++ {
newline := strings.IndexByte(string(text[start:]), '\n')
if newline < 0 {
return len(text)
}
start += newline + 1
}
offset := start + column - 1
if offset > len(text) {
offset = len(text)
}
return offset
}
func diagnosticToLSP(text []byte, diagnostic compiler.Diagnostic) lspDiagnostic {
startOffset := compilerPositionOffset(text, diagnostic.Line, diagnostic.Column)
endOffset := startOffset
if endOffset < len(text) {
_, size := utf8.DecodeRune(text[endOffset:])
if size < 1 {
size = 1
}
endOffset += size
}
severity := 1
if diagnostic.Severity == compiler.SeverityWarning {
severity = 2
}
return lspDiagnostic{
Range: Range{Start: offsetToPosition(text, startOffset), End: offsetToPosition(text, endOffset)},
Severity: severity, Code: diagnostic.Code, Source: "himesan", Message: diagnostic.Message,
}
}
+423
View File
@@ -0,0 +1,423 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Package releaseartifact creates deterministic Hime-san release archives and
// checksummed native-platform verification receipts.
package releaseartifact
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
const receiptSchema = "himesan.native-verification.v1"
var (
releaseVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
gitObjectPattern = regexp.MustCompile(`^[0-9a-f]{40,64}$`)
digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
goVersionPattern = regexp.MustCompile(`^go(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
platformPattern = regexp.MustCompile(`^[a-z0-9]+$`)
)
// PackageOptions contains the reviewed inputs to one release archive.
type PackageOptions struct {
Version string
Commit string
Tree string
GoVersion string
GOOS string
GOARCH string
BinaryPath string
LicensePath string
ReleaseNotes string
OutputDirectory string
SourceDateEpoch int64
}
// PackageResult identifies the immutable unsigned release archive.
type PackageResult struct {
ArchivePath string `json:"archive_path"`
SHA256 string `json:"sha256"`
}
type buildInfo struct {
SchemaVersion int `json:"schema_version"`
Version string `json:"version"`
Commit string `json:"commit"`
Tree string `json:"tree"`
GoVersion string `json:"go_version"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
BinarySHA256 string `json:"binary_sha256"`
UnsignedBinarySHA256 string `json:"unsigned_binary_sha256,omitempty"`
UnsignedArchiveSHA256 string `json:"unsigned_archive_sha256,omitempty"`
SigningIdentifier string `json:"signing_identifier,omitempty"`
}
type spdxDocument struct {
SPDXVersion string `json:"spdxVersion"`
DataLicense string `json:"dataLicense"`
SPDXID string `json:"SPDXID"`
Name string `json:"name"`
DocumentNamespace string `json:"documentNamespace"`
CreationInfo creationInfo `json:"creationInfo"`
Packages []spdxPackage `json:"packages"`
}
type creationInfo struct {
Created string `json:"created"`
Creators []string `json:"creators"`
}
type spdxPackage struct {
Name string `json:"name"`
SPDXID string `json:"SPDXID"`
VersionInfo string `json:"versionInfo"`
DownloadLocation string `json:"downloadLocation"`
FilesAnalyzed bool `json:"filesAnalyzed"`
LicenseConcluded string `json:"licenseConcluded"`
LicenseDeclared string `json:"licenseDeclared"`
Checksums []spdxChecksum `json:"checksums"`
}
type spdxChecksum struct {
Algorithm string `json:"algorithm"`
ChecksumValue string `json:"checksumValue"`
}
// Receipt describes one completed native verification run.
type Receipt struct {
SchemaVersion string `json:"schema_version"`
Repository string `json:"repository"`
Commit string `json:"commit"`
Tree string `json:"tree"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
GoVersion string `json:"go_version"`
RunnerVersion string `json:"runner_version"`
RunnerName string `json:"runner_name"`
GeneratedDigest string `json:"generated_output_sha256"`
UnsignedArtifactSHA string `json:"unsigned_artifact_sha256,omitempty"`
SuccessfulGates []string `json:"successful_gates"`
CompletedAt string `json:"completed_at"`
}
type archiveMember struct {
name string
mode int64
data []byte
}
// Package creates a byte-reproducible unsigned tar.gz and its SHA-256 sidecar.
func Package(options PackageOptions) (PackageResult, error) {
if err := validatePackageOptions(options); err != nil {
return PackageResult{}, err
}
binary, err := os.ReadFile(options.BinaryPath)
if err != nil {
return PackageResult{}, fmt.Errorf("read binary: %w", err)
}
license, err := os.ReadFile(options.LicensePath)
if err != nil {
return PackageResult{}, fmt.Errorf("read license: %w", err)
}
releaseNotes, err := os.ReadFile(options.ReleaseNotes)
if err != nil {
return PackageResult{}, fmt.Errorf("read release notes: %w", err)
}
binaryDigest := digest(binary)
infoBytes, err := indentedJSON(buildInfo{
SchemaVersion: 1,
Version: options.Version,
Commit: options.Commit,
Tree: options.Tree,
GoVersion: options.GoVersion,
GOOS: options.GOOS,
GOARCH: options.GOARCH,
BinarySHA256: binaryDigest,
})
if err != nil {
return PackageResult{}, err
}
created := time.Unix(options.SourceDateEpoch, 0).UTC().Format(time.RFC3339)
sbomBytes, err := indentedJSON(spdxDocument{
SPDXVersion: "SPDX-2.3",
DataLicense: "CC0-1.0",
SPDXID: "SPDXRef-DOCUMENT",
Name: "himesan-" + options.Version + "-" + options.GOOS + "-" + options.GOARCH,
DocumentNamespace: "https://sandwichhime.com/spdx/himesan/" + options.Version + "/" + options.Commit + "/" + options.GOOS + "-" + options.GOARCH,
CreationInfo: creationInfo{
Created: created,
Creators: []string{"Organization: Gamertan"},
},
Packages: []spdxPackage{{
Name: "himesan",
SPDXID: "SPDXRef-Package-himesan",
VersionInfo: options.Version,
DownloadLocation: "NOASSERTION",
FilesAnalyzed: false,
LicenseConcluded: "AGPL-3.0-only",
LicenseDeclared: "AGPL-3.0-only",
Checksums: []spdxChecksum{{
Algorithm: "SHA256",
ChecksumValue: binaryDigest,
}},
}},
})
if err != nil {
return PackageResult{}, err
}
members := []archiveMember{
{name: "BUILDINFO.json", mode: 0o444, data: infoBytes},
{name: "LICENSE", mode: 0o444, data: license},
{name: "RELEASE.md", mode: 0o444, data: releaseNotes},
{name: "SBOM.spdx.json", mode: 0o444, data: sbomBytes},
{name: "himesan", mode: 0o555, data: binary},
}
checksumLines := make([]string, 0, len(members))
for _, member := range members {
checksumLines = append(checksumLines, digest(member.data)+" "+member.name)
}
members = append(members, archiveMember{
name: "SHA256SUMS", mode: 0o444,
data: []byte(strings.Join(checksumLines, "\n") + "\n"),
})
sort.Slice(members, func(i, j int) bool { return members[i].name < members[j].name })
if err := os.MkdirAll(options.OutputDirectory, 0o755); err != nil {
return PackageResult{}, fmt.Errorf("create output directory: %w", err)
}
base := "himesan-" + strings.TrimPrefix(options.Version, "v") + "-" + options.GOOS + "-" + options.GOARCH
archivePath := filepath.Join(options.OutputDirectory, base+".tar.gz")
if err := writeArchive(archivePath, base, members, time.Unix(options.SourceDateEpoch, 0).UTC()); err != nil {
return PackageResult{}, err
}
archive, err := os.ReadFile(archivePath)
if err != nil {
return PackageResult{}, fmt.Errorf("read completed archive: %w", err)
}
archiveDigest := digest(archive)
if err := writeExclusive(archivePath+".sha256", []byte(archiveDigest+" "+filepath.Base(archivePath)+"\n"), 0o444); err != nil {
return PackageResult{}, fmt.Errorf("write archive checksum: %w", err)
}
return PackageResult{ArchivePath: archivePath, SHA256: archiveDigest}, nil
}
// WriteReceipt validates, canonicalizes, and writes a receipt plus SHA sidecar.
func WriteReceipt(path string, receipt Receipt) (string, error) {
receipt.SchemaVersion = receiptSchema
if err := validateReceipt(receipt); err != nil {
return "", err
}
sort.Strings(receipt.SuccessfulGates)
contents, err := indentedJSON(receipt)
if err != nil {
return "", err
}
if err := writeExclusive(path, contents, 0o444); err != nil {
return "", fmt.Errorf("write receipt: %w", err)
}
checksum := digest(contents)
if err := writeExclusive(path+".sha256", []byte(checksum+" "+filepath.Base(path)+"\n"), 0o444); err != nil {
return "", fmt.Errorf("write receipt checksum: %w", err)
}
return checksum, nil
}
// DigestFiles returns a stable digest over sorted names and file contents.
func DigestFiles(paths []string) (string, error) {
if len(paths) == 0 {
return "", errors.New("at least one generated file is required")
}
sorted := append([]string(nil), paths...)
sort.Strings(sorted)
hash := sha256.New()
for _, path := range sorted {
contents, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
_, _ = io.WriteString(hash, filepath.ToSlash(path))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write(contents)
_, _ = hash.Write([]byte{0})
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func validatePackageOptions(options PackageOptions) error {
for name, value := range map[string]string{
"version": options.Version, "commit": options.Commit, "tree": options.Tree,
"go version": options.GoVersion, "GOOS": options.GOOS, "GOARCH": options.GOARCH,
"binary": options.BinaryPath, "license": options.LicensePath,
"release notes": options.ReleaseNotes, "output directory": options.OutputDirectory,
} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required", name)
}
}
if options.SourceDateEpoch <= 0 {
return errors.New("source date epoch must be positive")
}
if !releaseVersionPattern.MatchString(options.Version) {
return errors.New("version must be a canonical v-prefixed semantic version without build metadata")
}
if !gitObjectPattern.MatchString(options.Commit) || !gitObjectPattern.MatchString(options.Tree) {
return errors.New("commit and tree must be full lowercase Git object identities")
}
if !goVersionPattern.MatchString(options.GoVersion) {
return errors.New("Go version must be a complete goX.Y.Z toolchain identity")
}
if !platformPattern.MatchString(options.GOOS) || !platformPattern.MatchString(options.GOARCH) {
return errors.New("GOOS and GOARCH must contain only lowercase letters and digits")
}
return nil
}
func validateReceipt(receipt Receipt) error {
for name, value := range map[string]string{
"repository": receipt.Repository, "commit": receipt.Commit, "tree": receipt.Tree,
"GOOS": receipt.GOOS, "GOARCH": receipt.GOARCH, "Go version": receipt.GoVersion,
"runner version": receipt.RunnerVersion, "runner name": receipt.RunnerName,
"generated digest": receipt.GeneratedDigest, "completed at": receipt.CompletedAt,
} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required", name)
}
}
if len(receipt.SuccessfulGates) == 0 {
return errors.New("at least one successful gate is required")
}
if !gitObjectPattern.MatchString(receipt.Commit) || !gitObjectPattern.MatchString(receipt.Tree) {
return errors.New("commit and tree must be full lowercase Git object identities")
}
if !goVersionPattern.MatchString(receipt.GoVersion) {
return errors.New("Go version must be a complete goX.Y.Z toolchain identity")
}
if !platformPattern.MatchString(receipt.GOOS) || !platformPattern.MatchString(receipt.GOARCH) {
return errors.New("GOOS and GOARCH must contain only lowercase letters and digits")
}
if !digestPattern.MatchString(receipt.GeneratedDigest) {
return errors.New("generated-output digest must be a lowercase SHA-256")
}
if receipt.UnsignedArtifactSHA != "" && !digestPattern.MatchString(receipt.UnsignedArtifactSHA) {
return errors.New("unsigned-artifact digest must be a lowercase SHA-256")
}
if _, err := time.Parse(time.RFC3339, receipt.CompletedAt); err != nil {
return fmt.Errorf("completed at must be RFC3339: %w", err)
}
return nil
}
func writeArchive(path, root string, members []archiveMember, modified time.Time) error {
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("create archive: %w", err)
}
success := false
defer func() {
_ = file.Close()
if !success {
_ = os.Remove(path)
}
}()
gzipWriter := gzip.NewWriter(file)
gzipWriter.Header.ModTime = modified
gzipWriter.Header.OS = 255
tarWriter := tar.NewWriter(gzipWriter)
for _, member := range members {
header := &tar.Header{
Name: root + "/" + member.name,
Mode: member.mode,
Size: int64(len(member.data)),
ModTime: modified,
AccessTime: time.Time{},
ChangeTime: time.Time{},
Uid: 0,
Gid: 0,
Uname: "",
Gname: "",
Format: tar.FormatUSTAR,
}
if err := tarWriter.WriteHeader(header); err != nil {
return fmt.Errorf("write archive header: %w", err)
}
if _, err := tarWriter.Write(member.data); err != nil {
return fmt.Errorf("write archive member: %w", err)
}
}
if err := tarWriter.Close(); err != nil {
return fmt.Errorf("close tar stream: %w", err)
}
if err := gzipWriter.Close(); err != nil {
return fmt.Errorf("close gzip stream: %w", err)
}
if err := file.Sync(); err != nil {
return fmt.Errorf("sync archive: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close archive: %w", err)
}
if err := os.Chmod(path, 0o444); err != nil {
return fmt.Errorf("set archive permissions: %w", err)
}
success = true
return nil
}
func writeExclusive(path string, contents []byte, mode os.FileMode) error {
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
if err != nil {
return err
}
success := false
defer func() {
_ = file.Close()
if !success {
_ = os.Remove(path)
}
}()
if _, err := file.Write(contents); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
if err := os.Chmod(path, mode); err != nil {
return err
}
success = true
return nil
}
func indentedJSON(value any) ([]byte, error) {
contents, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode JSON: %w", err)
}
return append(contents, '\n'), nil
}
func digest(contents []byte) string {
sum := sha256.Sum256(contents)
return hex.EncodeToString(sum[:])
}
+103
View File
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-only
package releaseartifact
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPackageIsByteReproducible(t *testing.T) {
directory := t.TempDir()
binary := writeFixture(t, directory, "himesan", "native binary")
license := writeFixture(t, directory, "LICENSE", "license")
releaseNotes := writeFixture(t, directory, "RELEASE.md", "release")
options := PackageOptions{
Version: "v1.0.0-rc.1", Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40), GoVersion: "go1.27.0",
GOOS: "darwin", GOARCH: "arm64", BinaryPath: binary, LicensePath: license,
ReleaseNotes: releaseNotes, SourceDateEpoch: 1_700_000_000,
}
options.OutputDirectory = filepath.Join(directory, "first")
first, err := Package(options)
if err != nil {
t.Fatal(err)
}
options.OutputDirectory = filepath.Join(directory, "second")
second, err := Package(options)
if err != nil {
t.Fatal(err)
}
if first.SHA256 != second.SHA256 {
t.Fatalf("repeated packages differ: %s != %s", first.SHA256, second.SHA256)
}
}
func TestWriteReceiptSortsGatesAndWritesChecksum(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipt.json")
checksum, err := WriteReceipt(path, Receipt{
Repository: "example.test/development-source", Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
GOOS: "darwin", GOARCH: "arm64", GoVersion: "go1.27.0",
RunnerVersion: "v3.3.0", RunnerName: "mac", GeneratedDigest: strings.Repeat("c", 64),
SuccessfulGates: []string{"vet", "test"}, CompletedAt: time.Unix(1_700_000_000, 0).UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatal(err)
}
if checksum == "" {
t.Fatal("empty checksum")
}
if _, err := os.Stat(path + ".sha256"); err != nil {
t.Fatal(err)
}
}
func TestPackageRejectsUnsafeVersionAndExistingArtifact(t *testing.T) {
directory := t.TempDir()
options := PackageOptions{
Version: "../../outside", Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
GoVersion: "go1.27.0", GOOS: "darwin", GOARCH: "arm64",
BinaryPath: writeFixture(t, directory, "himesan", "native binary"),
LicensePath: writeFixture(t, directory, "LICENSE", "license"),
ReleaseNotes: writeFixture(t, directory, "RELEASE.md", "release"),
OutputDirectory: filepath.Join(directory, "output"), SourceDateEpoch: 1_700_000_000,
}
if _, err := Package(options); err == nil {
t.Fatal("unsafe version was accepted")
}
options.Version = "v1.0.0-rc.1"
if _, err := Package(options); err != nil {
t.Fatal(err)
}
if _, err := Package(options); err == nil {
t.Fatal("existing immutable artifact was overwritten")
}
}
func TestDigestFilesBindsNamesAndBytes(t *testing.T) {
directory := t.TempDir()
one := writeFixture(t, directory, "one", "same")
two := writeFixture(t, directory, "two", "same")
forward, err := DigestFiles([]string{two, one})
if err != nil {
t.Fatal(err)
}
reverse, err := DigestFiles([]string{one, two})
if err != nil {
t.Fatal(err)
}
if forward != reverse {
t.Fatal("file ordering changed digest")
}
}
func writeFixture(t *testing.T, directory, name, contents string) string {
t.Helper()
path := filepath.Join(directory, name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return path
}
+238
View File
@@ -0,0 +1,238 @@
// SPDX-License-Identifier: AGPL-3.0-only
package releaseartifact
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
)
const (
evidenceSchema = "himesan.release-evidence.v1"
evidenceManifestName = "RELEASE-EVIDENCE.json"
maximumEvidenceSize = 1 << 20
)
var requiredEvidenceFiles = []string{
"benchmark-methodology.md",
"development-supervisor.md",
"legal-review.md",
"native-platforms.md",
"security.md",
"signing-and-recovery.md",
"vanity-imports.md",
}
// EvidenceIdentity binds human-reviewed release evidence to one source state.
type EvidenceIdentity struct {
Repository string
Version string
Commit string
Tree string
ReviewedBy string
ReviewedAt string
}
type evidenceManifest struct {
SchemaVersion string `json:"schema_version"`
Repository string `json:"repository"`
Version string `json:"version"`
Commit string `json:"commit"`
Tree string `json:"tree"`
ReviewStatus string `json:"review_status"`
ReviewedBy string `json:"reviewed_by"`
ReviewedAt string `json:"reviewed_at"`
Files []evidenceFile `json:"files"`
}
type evidenceFile struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
}
// RequiredEvidenceFiles returns the fixed v1 human-review document set.
func RequiredEvidenceFiles() []string {
return append([]string(nil), requiredEvidenceFiles...)
}
// WriteEvidenceManifest seals the reviewed evidence documents without
// modifying them. The manifest is immutable and fails if it already exists.
func WriteEvidenceManifest(directory string, identity EvidenceIdentity) (string, error) {
if err := validateEvidenceIdentity(identity, true); err != nil {
return "", err
}
files, err := inspectEvidenceFiles(directory)
if err != nil {
return "", err
}
manifest := evidenceManifest{
SchemaVersion: evidenceSchema,
Repository: identity.Repository,
Version: identity.Version,
Commit: identity.Commit,
Tree: identity.Tree,
ReviewStatus: "reviewed",
ReviewedBy: identity.ReviewedBy,
ReviewedAt: identity.ReviewedAt,
Files: files,
}
contents, err := indentedJSON(manifest)
if err != nil {
return "", err
}
path := filepath.Join(directory, evidenceManifestName)
if err := writeExclusive(path, contents, 0o444); err != nil {
return "", fmt.Errorf("write release evidence manifest: %w", err)
}
return digest(contents), nil
}
// VerifyEvidenceManifest proves that the sealed evidence is for the expected
// source state and that every reviewed byte remains unchanged.
func VerifyEvidenceManifest(directory string, expected EvidenceIdentity) error {
if err := validateEvidenceIdentity(expected, false); err != nil {
return err
}
manifestPath := filepath.Join(directory, evidenceManifestName)
contents, err := readEvidenceFile(manifestPath, true)
if err != nil {
return fmt.Errorf("read release evidence manifest: %w", err)
}
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.DisallowUnknownFields()
var manifest evidenceManifest
if err := decoder.Decode(&manifest); err != nil {
return fmt.Errorf("decode release evidence manifest: %w", err)
}
if err := requireJSONEOF(decoder); err != nil {
return fmt.Errorf("decode release evidence manifest: %w", err)
}
if manifest.SchemaVersion != evidenceSchema {
return fmt.Errorf("release evidence schema = %q, want %q", manifest.SchemaVersion, evidenceSchema)
}
if manifest.ReviewStatus != "reviewed" {
return errors.New("release evidence review status must be reviewed")
}
actual := EvidenceIdentity{
Repository: manifest.Repository,
Version: manifest.Version,
Commit: manifest.Commit,
Tree: manifest.Tree,
ReviewedBy: manifest.ReviewedBy,
ReviewedAt: manifest.ReviewedAt,
}
if err := validateEvidenceIdentity(actual, true); err != nil {
return err
}
if actual.Repository != expected.Repository || actual.Version != expected.Version ||
actual.Commit != expected.Commit || actual.Tree != expected.Tree {
return errors.New("release evidence repository, version, commit, or tree does not match the candidate")
}
observed, err := inspectEvidenceFiles(directory)
if err != nil {
return err
}
if len(manifest.Files) != len(observed) {
return fmt.Errorf("release evidence manifest contains %d files, want %d", len(manifest.Files), len(observed))
}
for index := range observed {
if manifest.Files[index] != observed[index] {
return fmt.Errorf("release evidence file %q is missing, reordered, or has a changed digest", observed[index].Path)
}
}
return nil
}
func inspectEvidenceFiles(directory string) ([]evidenceFile, error) {
info, err := os.Lstat(directory)
if err != nil {
return nil, fmt.Errorf("inspect evidence directory: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("evidence directory must be a real directory, not a symlink")
}
files := make([]evidenceFile, 0, len(requiredEvidenceFiles))
for _, name := range requiredEvidenceFiles {
contents, err := readEvidenceFile(filepath.Join(directory, name), false)
if err != nil {
return nil, fmt.Errorf("inspect %s: %w", name, err)
}
if !utf8.Valid(contents) || bytes.IndexByte(contents, 0) >= 0 {
return nil, fmt.Errorf("%s must be NUL-free UTF-8 text", name)
}
if !strings.Contains(string(contents), "# ") {
return nil, fmt.Errorf("%s must contain a Markdown heading", name)
}
files = append(files, evidenceFile{Path: name, SHA256: digest(contents)})
}
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
return files, nil
}
func readEvidenceFile(path string, manifest bool) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("must be a regular file, not a symlink")
}
if info.Size() == 0 || info.Size() > maximumEvidenceSize {
return nil, fmt.Errorf("size must be between 1 and %d bytes", maximumEvidenceSize)
}
contents, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if manifest && len(contents) > maximumEvidenceSize {
return nil, errors.New("manifest exceeds the size limit")
}
return contents, nil
}
func validateEvidenceIdentity(identity EvidenceIdentity, requireReview bool) error {
if strings.TrimSpace(identity.Repository) == "" || strings.ContainsAny(identity.Repository, "\r\n\x00") {
return errors.New("evidence repository is required and must be one line")
}
if !releaseVersionPattern.MatchString(identity.Version) {
return errors.New("evidence version must be a canonical v-prefixed semantic version")
}
if !gitObjectPattern.MatchString(identity.Commit) || !gitObjectPattern.MatchString(identity.Tree) {
return errors.New("evidence commit and tree must be full lowercase Git object identities")
}
if !requireReview {
return nil
}
if strings.TrimSpace(identity.ReviewedBy) == "" || strings.ContainsAny(identity.ReviewedBy, "\r\n\x00") {
return errors.New("evidence reviewer is required and must be one line")
}
reviewedAt, err := time.Parse(time.RFC3339, identity.ReviewedAt)
if err != nil {
return fmt.Errorf("evidence review time must be RFC3339: %w", err)
}
if reviewedAt.After(time.Now().UTC().Add(5 * time.Minute)) {
return errors.New("evidence review time cannot be in the future")
}
return nil
}
func requireJSONEOF(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("multiple JSON values are not allowed")
}
return err
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: AGPL-3.0-only
package releaseartifact
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEvidenceManifestBindsReviewedContentAndIdentity(t *testing.T) {
directory := evidenceFixture(t)
identity := evidenceIdentity()
checksum, err := WriteEvidenceManifest(directory, identity)
if err != nil {
t.Fatal(err)
}
if !digestPattern.MatchString(checksum) {
t.Fatalf("manifest checksum = %q", checksum)
}
if err := VerifyEvidenceManifest(directory, identity); err != nil {
t.Fatal(err)
}
path := filepath.Join(directory, requiredEvidenceFiles[0])
if err := os.WriteFile(path, []byte("# Changed after review\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := VerifyEvidenceManifest(directory, identity); err == nil {
t.Fatal("changed evidence bytes were accepted")
}
}
func TestEvidenceManifestRejectsCandidateMismatchAndUnknownFields(t *testing.T) {
t.Run("candidate identity", func(t *testing.T) {
directory := evidenceFixture(t)
identity := evidenceIdentity()
if _, err := WriteEvidenceManifest(directory, identity); err != nil {
t.Fatal(err)
}
identity.Commit = strings.Repeat("d", 40)
if err := VerifyEvidenceManifest(directory, identity); err == nil {
t.Fatal("mismatched candidate was accepted")
}
})
t.Run("unknown manifest field", func(t *testing.T) {
directory := evidenceFixture(t)
identity := evidenceIdentity()
if _, err := WriteEvidenceManifest(directory, identity); err != nil {
t.Fatal(err)
}
path := filepath.Join(directory, evidenceManifestName)
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
contents = []byte(strings.Replace(string(contents), `"files":`, `"unexpected": true, "files":`, 1))
if err := os.Chmod(path, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, contents, 0o600); err != nil {
t.Fatal(err)
}
if err := VerifyEvidenceManifest(directory, identity); err == nil {
t.Fatal("unknown manifest field was accepted")
}
})
}
func TestEvidenceManifestRejectsSymlinkAndPlaceholderDocuments(t *testing.T) {
t.Run("symlink", func(t *testing.T) {
directory := evidenceFixture(t)
name := requiredEvidenceFiles[0]
path := filepath.Join(directory, name)
target := filepath.Join(directory, "target.md")
if err := os.WriteFile(target, []byte("# Target\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
if _, err := WriteEvidenceManifest(directory, evidenceIdentity()); err == nil {
t.Fatal("symlinked evidence was accepted")
}
})
t.Run("placeholder", func(t *testing.T) {
directory := evidenceFixture(t)
if err := os.WriteFile(filepath.Join(directory, requiredEvidenceFiles[0]), []byte("not reviewed\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := WriteEvidenceManifest(directory, evidenceIdentity()); err == nil {
t.Fatal("heading-free placeholder was accepted")
}
})
}
func evidenceFixture(t *testing.T) string {
t.Helper()
directory := t.TempDir()
for _, name := range requiredEvidenceFiles {
if err := os.WriteFile(filepath.Join(directory, name), []byte("# Reviewed "+name+"\n\nExact bounded evidence.\n"), 0o600); err != nil {
t.Fatal(err)
}
}
return directory
}
func evidenceIdentity() EvidenceIdentity {
return EvidenceIdentity{
Repository: "gamertan/sandwich-hime",
Version: "v1.0.0-rc.1",
Commit: strings.Repeat("a", 40),
Tree: strings.Repeat("b", 40),
ReviewedBy: "release operator",
ReviewedAt: "2026-08-23T18:00:00Z",
}
}
+364
View File
@@ -0,0 +1,364 @@
// SPDX-License-Identifier: AGPL-3.0-only
package releaseartifact
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
const maximumNativeArchiveSize = 256 << 20
var unsignedPackageMembers = map[string]os.FileMode{
"BUILDINFO.json": 0o444,
"LICENSE": 0o444,
"RELEASE.md": 0o444,
"SBOM.spdx.json": 0o444,
"SHA256SUMS": 0o444,
"himesan": 0o555,
}
// MacOSSigningOptions are the reviewed identities added after Developer ID
// signing changes the native binary bytes.
type MacOSSigningOptions struct {
Directory string
UnsignedArchiveSHA256 string
Identity string
Identifier string
FinalizedAt string
}
type signingInfo struct {
SchemaVersion int `json:"schema_version"`
UnsignedArchiveSHA256 string `json:"unsigned_archive_sha256"`
UnsignedBinarySHA256 string `json:"unsigned_binary_sha256"`
SignedBinarySHA256 string `json:"signed_binary_sha256"`
Identity string `json:"identity"`
Identifier string `json:"identifier"`
FinalizedAt string `json:"finalized_at"`
}
// ExtractVerifiedMacOSPackage verifies the approved unsigned archive and
// extracts its fixed file set without delegating path handling to system tar.
func ExtractVerifiedMacOSPackage(archivePath, expectedSHA256, outputDirectory string) (string, error) {
if !digestPattern.MatchString(expectedSHA256) {
return "", errors.New("approved archive digest must be a lowercase SHA-256")
}
info, err := os.Lstat(archivePath)
if err != nil {
return "", fmt.Errorf("inspect unsigned archive: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("unsigned archive must be a regular file, not a symlink")
}
if info.Size() <= 0 || info.Size() > maximumNativeArchiveSize {
return "", errors.New("unsigned archive size is outside the permitted range")
}
archive, err := os.ReadFile(archivePath)
if err != nil {
return "", fmt.Errorf("read unsigned archive: %w", err)
}
if digest(archive) != expectedSHA256 {
return "", errors.New("unsigned archive does not match the approved digest")
}
members, root, err := readUnsignedPackage(archive)
if err != nil {
return "", err
}
if err := validateUnsignedPackage(members, root); err != nil {
return "", err
}
outputInfo, err := os.Lstat(outputDirectory)
if err != nil {
return "", fmt.Errorf("inspect extraction directory: %w", err)
}
if !outputInfo.IsDir() || outputInfo.Mode()&os.ModeSymlink != 0 {
return "", errors.New("extraction destination must be a real directory")
}
rootPath := filepath.Join(outputDirectory, root)
if err := os.Mkdir(rootPath, 0o700); err != nil {
return "", fmt.Errorf("create extracted package root: %w", err)
}
success := false
defer func() {
if !success {
_ = os.RemoveAll(rootPath)
}
}()
for name, mode := range unsignedPackageMembers {
if err := writeExclusive(filepath.Join(rootPath, name), members[name], mode); err != nil {
return "", fmt.Errorf("extract %s: %w", name, err)
}
}
success = true
return rootPath, nil
}
// FinalizeSignedMacOSDistribution replaces unsigned binary provenance with a
// complete signed-distribution record and regenerates every internal checksum.
func FinalizeSignedMacOSDistribution(options MacOSSigningOptions) error {
if !digestPattern.MatchString(options.UnsignedArchiveSHA256) {
return errors.New("unsigned archive digest must be a lowercase SHA-256")
}
if strings.TrimSpace(options.Identity) == "" || strings.ContainsAny(options.Identity, "\r\n\x00") {
return errors.New("signing identity is required and must be one line")
}
if strings.TrimSpace(options.Identifier) == "" || strings.ContainsAny(options.Identifier, "\r\n\x00") {
return errors.New("signing identifier is required and must be one line")
}
if _, err := time.Parse(time.RFC3339, options.FinalizedAt); err != nil {
return fmt.Errorf("finalization time must be RFC3339: %w", err)
}
for name := range unsignedPackageMembers {
if _, err := readRegularFile(filepath.Join(options.Directory, name), maximumNativeArchiveSize); err != nil {
return fmt.Errorf("inspect signed distribution %s: %w", name, err)
}
}
buildPath := filepath.Join(options.Directory, "BUILDINFO.json")
buildContents, err := os.ReadFile(buildPath)
if err != nil {
return fmt.Errorf("read unsigned build information: %w", err)
}
var build buildInfo
if err := decodeStrictJSON(buildContents, &build); err != nil {
return fmt.Errorf("decode unsigned build information: %w", err)
}
if build.SchemaVersion != 1 || !digestPattern.MatchString(build.BinarySHA256) || build.GOOS != "darwin" || build.GOARCH != "arm64" {
return errors.New("unsigned build information is not a supported Darwin/arm64 package")
}
unsignedBinaryDigest := build.BinarySHA256
signedBinary, err := os.ReadFile(filepath.Join(options.Directory, "himesan"))
if err != nil {
return fmt.Errorf("read signed binary: %w", err)
}
signedBinaryDigest := digest(signedBinary)
if signedBinaryDigest == unsignedBinaryDigest {
return errors.New("Developer ID signing did not change the native binary bytes")
}
build.SchemaVersion = 2
build.BinarySHA256 = signedBinaryDigest
build.UnsignedBinarySHA256 = unsignedBinaryDigest
build.UnsignedArchiveSHA256 = options.UnsignedArchiveSHA256
build.SigningIdentifier = options.Identifier
newBuild, err := indentedJSON(build)
if err != nil {
return err
}
sbomPath := filepath.Join(options.Directory, "SBOM.spdx.json")
sbomContents, err := os.ReadFile(sbomPath)
if err != nil {
return fmt.Errorf("read unsigned SBOM: %w", err)
}
var sbom spdxDocument
if err := decodeStrictJSON(sbomContents, &sbom); err != nil {
return fmt.Errorf("decode unsigned SBOM: %w", err)
}
if len(sbom.Packages) != 1 || len(sbom.Packages[0].Checksums) != 1 ||
sbom.Packages[0].Checksums[0].Algorithm != "SHA256" ||
sbom.Packages[0].Checksums[0].ChecksumValue != unsignedBinaryDigest {
return errors.New("unsigned SBOM does not match the unsigned binary")
}
sbom.Packages[0].Checksums[0].ChecksumValue = signedBinaryDigest
newSBOM, err := indentedJSON(sbom)
if err != nil {
return err
}
signingContents, err := indentedJSON(signingInfo{
SchemaVersion: 1,
UnsignedArchiveSHA256: options.UnsignedArchiveSHA256,
UnsignedBinarySHA256: unsignedBinaryDigest,
SignedBinarySHA256: signedBinaryDigest,
Identity: options.Identity,
Identifier: options.Identifier,
FinalizedAt: options.FinalizedAt,
})
if err != nil {
return err
}
if err := replaceRegularFile(buildPath, newBuild, 0o444); err != nil {
return fmt.Errorf("replace signed build information: %w", err)
}
if err := replaceRegularFile(sbomPath, newSBOM, 0o444); err != nil {
return fmt.Errorf("replace signed SBOM: %w", err)
}
if err := writeExclusive(filepath.Join(options.Directory, "SIGNING.json"), signingContents, 0o444); err != nil {
return fmt.Errorf("write signing information: %w", err)
}
checksumNames := []string{"BUILDINFO.json", "LICENSE", "RELEASE.md", "SBOM.spdx.json", "SIGNING.json", "himesan"}
var checksumLines []string
for _, name := range checksumNames {
contents, err := os.ReadFile(filepath.Join(options.Directory, name))
if err != nil {
return fmt.Errorf("read signed distribution member %s: %w", name, err)
}
checksumLines = append(checksumLines, digest(contents)+" "+name)
}
checksums := []byte(strings.Join(checksumLines, "\n") + "\n")
if err := replaceRegularFile(filepath.Join(options.Directory, "SHA256SUMS"), checksums, 0o444); err != nil {
return fmt.Errorf("replace signed distribution checksums: %w", err)
}
return nil
}
func readUnsignedPackage(archive []byte) (map[string][]byte, string, error) {
gzipReader, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, "", fmt.Errorf("open unsigned gzip archive: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(io.LimitReader(gzipReader, maximumNativeArchiveSize+1))
members := make(map[string][]byte, len(unsignedPackageMembers))
root := ""
var totalSize int64
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, "", fmt.Errorf("read unsigned tar archive: %w", err)
}
if header.Typeflag != tar.TypeReg || header.Size < 0 || header.Size > maximumNativeArchiveSize {
return nil, "", errors.New("unsigned archive contains a non-regular or oversized member")
}
totalSize += header.Size
if totalSize > maximumNativeArchiveSize {
return nil, "", errors.New("unsigned archive expands beyond the permitted size")
}
clean := filepath.ToSlash(filepath.Clean(header.Name))
parts := strings.Split(clean, "/")
if len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." {
return nil, "", fmt.Errorf("unsigned archive member has an unsafe path: %q", header.Name)
}
if root == "" {
root = parts[0]
} else if root != parts[0] {
return nil, "", errors.New("unsigned archive contains more than one package root")
}
name := parts[1]
expectedMode, ok := unsignedPackageMembers[name]
if !ok || os.FileMode(header.Mode).Perm() != expectedMode || header.Linkname != "" {
return nil, "", fmt.Errorf("unsigned archive member %q has an unexpected name, mode, or link", name)
}
if _, exists := members[name]; exists {
return nil, "", fmt.Errorf("unsigned archive repeats member %q", name)
}
contents, err := io.ReadAll(io.LimitReader(tarReader, header.Size+1))
if err != nil || int64(len(contents)) != header.Size {
return nil, "", fmt.Errorf("read unsigned archive member %q", name)
}
members[name] = contents
}
if err := gzipReader.Close(); err != nil {
return nil, "", fmt.Errorf("finish unsigned gzip archive: %w", err)
}
return members, root, nil
}
func validateUnsignedPackage(members map[string][]byte, root string) error {
if len(members) != len(unsignedPackageMembers) || root == "" {
return errors.New("unsigned archive does not contain the exact release file set")
}
var build buildInfo
if err := decodeStrictJSON(members["BUILDINFO.json"], &build); err != nil {
return fmt.Errorf("decode unsigned build information: %w", err)
}
if build.SchemaVersion != 1 || build.GOOS != "darwin" || build.GOARCH != "arm64" ||
!releaseVersionPattern.MatchString(build.Version) || root != "himesan-"+strings.TrimPrefix(build.Version, "v")+"-darwin-arm64" ||
!gitObjectPattern.MatchString(build.Commit) || !gitObjectPattern.MatchString(build.Tree) ||
!goVersionPattern.MatchString(build.GoVersion) || build.BinarySHA256 != digest(members["himesan"]) {
return errors.New("unsigned build information does not match the archive")
}
var sbom spdxDocument
if err := decodeStrictJSON(members["SBOM.spdx.json"], &sbom); err != nil {
return fmt.Errorf("decode unsigned SBOM: %w", err)
}
if len(sbom.Packages) != 1 || len(sbom.Packages[0].Checksums) != 1 ||
sbom.Packages[0].Checksums[0].Algorithm != "SHA256" ||
sbom.Packages[0].Checksums[0].ChecksumValue != build.BinarySHA256 {
return errors.New("unsigned SBOM does not match the native binary")
}
expectedNames := []string{"BUILDINFO.json", "LICENSE", "RELEASE.md", "SBOM.spdx.json", "himesan"}
var expectedLines []string
for _, name := range expectedNames {
expectedLines = append(expectedLines, digest(members[name])+" "+name)
}
if string(members["SHA256SUMS"]) != strings.Join(expectedLines, "\n")+"\n" {
return errors.New("unsigned package checksum manifest does not match its members")
}
return nil
}
func readRegularFile(path string, maximum int64) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() < 0 || info.Size() > maximum {
return nil, errors.New("must be a bounded regular file, not a symlink")
}
return os.ReadFile(path)
}
func decodeStrictJSON(contents []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
return requireJSONEOF(decoder)
}
func replaceRegularFile(path string, contents []byte, mode os.FileMode) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("replacement target must be a regular file, not a symlink")
}
temporary, err := os.CreateTemp(filepath.Dir(path), ".himesan-release-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
success := false
defer func() {
_ = temporary.Close()
if !success {
_ = os.Remove(temporaryPath)
}
}()
if _, err := temporary.Write(contents); err != nil {
return err
}
if err := temporary.Sync(); err != nil {
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Chmod(temporaryPath, mode); err != nil {
return err
}
if err := os.Rename(temporaryPath, path); err != nil {
return err
}
success = true
return nil
}
+181
View File
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: AGPL-3.0-only
package releaseartifact
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"strings"
"testing"
)
func TestExtractAndFinalizeSignedMacOSDistribution(t *testing.T) {
archive, archiveDigest := macOSPackageFixture(t)
extraction := t.TempDir()
root, err := ExtractVerifiedMacOSPackage(archive, archiveDigest, extraction)
if err != nil {
t.Fatal(err)
}
binaryPath := filepath.Join(root, "himesan")
unsignedBinary, err := os.ReadFile(binaryPath)
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(binaryPath, 0o700); err != nil {
t.Fatal(err)
}
binary, err := os.OpenFile(binaryPath, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
if _, err := binary.WriteString("developer-id-signature"); err != nil {
t.Fatal(err)
}
if err := binary.Close(); err != nil {
t.Fatal(err)
}
options := MacOSSigningOptions{
Directory: root, UnsignedArchiveSHA256: archiveDigest,
Identity: "Developer ID Application: Example (TEAMID)",
Identifier: "com.gamertan.sandwich-hime.himesan",
FinalizedAt: "2026-08-23T18:00:00Z",
}
if err := FinalizeSignedMacOSDistribution(options); err != nil {
t.Fatal(err)
}
var build buildInfo
buildContents, err := os.ReadFile(filepath.Join(root, "BUILDINFO.json"))
if err != nil {
t.Fatal(err)
}
if err := decodeStrictJSON(buildContents, &build); err != nil {
t.Fatal(err)
}
signedBinary, err := os.ReadFile(binaryPath)
if err != nil {
t.Fatal(err)
}
if build.SchemaVersion != 2 || build.BinarySHA256 != digest(signedBinary) ||
build.UnsignedBinarySHA256 != digest(unsignedBinary) || build.UnsignedArchiveSHA256 != archiveDigest ||
build.SigningIdentifier != options.Identifier {
t.Fatalf("signed build information is incomplete: %#v", build)
}
var signing signingInfo
signingContents, err := os.ReadFile(filepath.Join(root, "SIGNING.json"))
if err != nil {
t.Fatal(err)
}
if err := decodeStrictJSON(signingContents, &signing); err != nil {
t.Fatal(err)
}
if signing.SignedBinarySHA256 != build.BinarySHA256 || signing.UnsignedBinarySHA256 != build.UnsignedBinarySHA256 {
t.Fatalf("signing record does not match build information: %#v", signing)
}
var sbom spdxDocument
sbomContents, err := os.ReadFile(filepath.Join(root, "SBOM.spdx.json"))
if err != nil {
t.Fatal(err)
}
if err := decodeStrictJSON(sbomContents, &sbom); err != nil {
t.Fatal(err)
}
if got := sbom.Packages[0].Checksums[0].ChecksumValue; got != build.BinarySHA256 {
t.Fatalf("signed SBOM checksum = %s, want %s", got, build.BinarySHA256)
}
assertChecksumManifest(t, root, []string{"BUILDINFO.json", "LICENSE", "RELEASE.md", "SBOM.spdx.json", "SIGNING.json", "himesan"})
if err := FinalizeSignedMacOSDistribution(options); err == nil {
t.Fatal("signed distribution was finalized twice")
}
}
func TestExtractVerifiedMacOSPackageRejectsSubstitutionAndSymlink(t *testing.T) {
archive, archiveDigest := macOSPackageFixture(t)
if _, err := ExtractVerifiedMacOSPackage(archive, strings.Repeat("0", 64), t.TempDir()); err == nil {
t.Fatal("archive substitution was accepted")
}
symlink := filepath.Join(t.TempDir(), "candidate.tar.gz")
if err := os.Symlink(archive, symlink); err != nil {
t.Fatal(err)
}
if _, err := ExtractVerifiedMacOSPackage(symlink, archiveDigest, t.TempDir()); err == nil {
t.Fatal("symlinked archive was accepted")
}
unsafeArchive := filepath.Join(t.TempDir(), "unsafe.tar.gz")
destination := t.TempDir()
escapeName := filepath.Base(destination) + "-outside"
unsafeContents := unsafeTarGzip(t, "../"+escapeName, []byte("not a package"))
if err := os.WriteFile(unsafeArchive, unsafeContents, 0o600); err != nil {
t.Fatal(err)
}
if _, err := ExtractVerifiedMacOSPackage(unsafeArchive, digest(unsafeContents), destination); err == nil {
t.Fatal("archive path traversal was accepted")
}
if _, err := os.Stat(filepath.Join(filepath.Dir(destination), escapeName)); !os.IsNotExist(err) {
t.Fatal("unsafe archive wrote outside the extraction directory")
}
}
func macOSPackageFixture(t *testing.T) (string, string) {
t.Helper()
directory := t.TempDir()
options := PackageOptions{
Version: "v1.0.0-rc.1", Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
GoVersion: "go1.27.0", GOOS: "darwin", GOARCH: "arm64",
BinaryPath: writeFixture(t, directory, "himesan", "unsigned Mach-O fixture"),
LicensePath: writeFixture(t, directory, "LICENSE", "licence"),
ReleaseNotes: writeFixture(t, directory, "RELEASE.md", "release"),
OutputDirectory: filepath.Join(directory, "package"), SourceDateEpoch: 1_700_000_000,
}
result, err := Package(options)
if err != nil {
t.Fatal(err)
}
return result.ArchivePath, result.SHA256
}
func assertChecksumManifest(t *testing.T, directory string, names []string) {
t.Helper()
var lines []string
for _, name := range names {
contents, err := os.ReadFile(filepath.Join(directory, name))
if err != nil {
t.Fatal(err)
}
lines = append(lines, digest(contents)+" "+name)
}
checksums, err := os.ReadFile(filepath.Join(directory, "SHA256SUMS"))
if err != nil {
t.Fatal(err)
}
if got, want := string(checksums), strings.Join(lines, "\n")+"\n"; got != want {
t.Fatalf("checksum manifest mismatch\ngot:\n%s\nwant:\n%s", got, want)
}
}
func unsafeTarGzip(t *testing.T, name string, contents []byte) []byte {
t.Helper()
var output bytes.Buffer
gzipWriter := gzip.NewWriter(&output)
tarWriter := tar.NewWriter(gzipWriter)
if err := tarWriter.WriteHeader(&tar.Header{Name: name, Mode: 0o444, Size: int64(len(contents)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write(contents); err != nil {
t.Fatal(err)
}
if err := tarWriter.Close(); err != nil {
t.Fatal(err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
+220
View File
@@ -0,0 +1,220 @@
// SPDX-License-Identifier: AGPL-3.0-only
package releaseartifact
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
const nativeReceiptMaximumAge = 30 * 24 * time.Hour
var runnerVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
type nativeLane struct {
directory string
goos string
goarch string
goVersion string
runner string
}
var requiredNativeLanes = []nativeLane{
{directory: "darwin-arm64-go1.26.7", goos: "darwin", goarch: "arm64", goVersion: "go1.26.7", runner: "macbook-air-himesan-darwin-arm64"},
{directory: "darwin-arm64-go1.27.0", goos: "darwin", goarch: "arm64", goVersion: "go1.27.0", runner: "macbook-air-himesan-darwin-arm64"},
{directory: "linux-amd64-go1.26.7", goos: "linux", goarch: "amd64", goVersion: "go1.26.7", runner: "cliff-himesan-linux-amd64"},
{directory: "linux-amd64-go1.27.0", goos: "linux", goarch: "amd64", goVersion: "go1.27.0", runner: "cliff-himesan-linux-amd64"},
}
var requiredNativeGates = []string{
"build", "consumer", "contracts", "fuzz", "generation", "package",
"public-snapshot", "race", "test", "vet", "vulnerability",
}
// NativeReceiptExpectation identifies the exact source verified by all native
// runner lanes.
type NativeReceiptExpectation struct {
Repository string
Commit string
Tree string
}
// NativeReceiptSummary is safe to include in human release evidence.
type NativeReceiptSummary struct {
SchemaVersion string `json:"schema_version"`
Repository string `json:"repository"`
Commit string `json:"commit"`
Tree string `json:"tree"`
GeneratedDigest string `json:"generated_output_sha256"`
Lanes []NativeLaneSummary `json:"lanes"`
}
// NativeLaneSummary records one verified native receipt without local paths.
type NativeLaneSummary struct {
Platform string `json:"platform"`
GoVersion string `json:"go_version"`
RunnerVersion string `json:"runner_version"`
ArtifactSHA256 string `json:"unsigned_artifact_sha256"`
ReceiptSHA256 string `json:"receipt_sha256"`
CompletedAt string `json:"completed_at"`
}
// VerifyNativeReceiptSet requires all four maintained target/toolchain lanes,
// validates their checksum sidecars, and proves identical generated output.
func VerifyNativeReceiptSet(directory string, expected NativeReceiptExpectation) (NativeReceiptSummary, error) {
if strings.TrimSpace(expected.Repository) == "" || strings.ContainsAny(expected.Repository, "\r\n\x00") {
return NativeReceiptSummary{}, errors.New("native receipt repository is required and must be one line")
}
if !gitObjectPattern.MatchString(expected.Commit) || !gitObjectPattern.MatchString(expected.Tree) {
return NativeReceiptSummary{}, errors.New("native receipt commit and tree must be full lowercase Git object identities")
}
if err := validateNativeDirectory(directory); err != nil {
return NativeReceiptSummary{}, err
}
summary := NativeReceiptSummary{
SchemaVersion: "himesan.native-receipt-set.v1",
Repository: expected.Repository, Commit: expected.Commit, Tree: expected.Tree,
}
for _, lane := range requiredNativeLanes {
receipt, receiptDigest, err := readNativeLane(filepath.Join(directory, lane.directory))
if err != nil {
return NativeReceiptSummary{}, fmt.Errorf("verify native lane %s: %w", lane.directory, err)
}
if receipt.SchemaVersion != receiptSchema || receipt.Repository != expected.Repository ||
receipt.Commit != expected.Commit || receipt.Tree != expected.Tree ||
receipt.GOOS != lane.goos || receipt.GOARCH != lane.goarch ||
receipt.GoVersion != lane.goVersion || receipt.RunnerName != lane.runner {
return NativeReceiptSummary{}, fmt.Errorf("native lane %s does not match its source, platform, toolchain, or runner", lane.directory)
}
if !runnerVersionPattern.MatchString(receipt.RunnerVersion) || !digestPattern.MatchString(receipt.UnsignedArtifactSHA) {
return NativeReceiptSummary{}, fmt.Errorf("native lane %s has an invalid runner or artifact identity", lane.directory)
}
completedAt, err := time.Parse(time.RFC3339, receipt.CompletedAt)
if err != nil || completedAt.After(time.Now().UTC().Add(5*time.Minute)) || time.Since(completedAt) > nativeReceiptMaximumAge {
return NativeReceiptSummary{}, fmt.Errorf("native lane %s is not fresh, completed, RFC3339 evidence", lane.directory)
}
if err := requireNativeGates(receipt.SuccessfulGates); err != nil {
return NativeReceiptSummary{}, fmt.Errorf("native lane %s: %w", lane.directory, err)
}
if summary.GeneratedDigest == "" {
summary.GeneratedDigest = receipt.GeneratedDigest
} else if summary.GeneratedDigest != receipt.GeneratedDigest {
return NativeReceiptSummary{}, errors.New("native lanes did not produce identical generated output")
}
summary.Lanes = append(summary.Lanes, NativeLaneSummary{
Platform: lane.goos + "/" + lane.goarch, GoVersion: lane.goVersion,
RunnerVersion: receipt.RunnerVersion, ArtifactSHA256: receipt.UnsignedArtifactSHA,
ReceiptSHA256: receiptDigest, CompletedAt: receipt.CompletedAt,
})
}
return summary, nil
}
func validateNativeDirectory(directory string) error {
info, err := os.Lstat(directory)
if err != nil {
return fmt.Errorf("inspect native evidence directory: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("native evidence must be a real directory")
}
entries, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("read native evidence directory: %w", err)
}
expected := make([]string, 0, len(requiredNativeLanes))
for _, lane := range requiredNativeLanes {
expected = append(expected, lane.directory)
}
observed := make([]string, 0, len(entries))
for _, entry := range entries {
observed = append(observed, entry.Name())
}
sort.Strings(expected)
sort.Strings(observed)
if !equalStrings(expected, observed) {
return fmt.Errorf("native evidence directories = %v, want exactly %v", observed, expected)
}
return nil
}
func readNativeLane(directory string) (Receipt, string, error) {
info, err := os.Lstat(directory)
if err != nil {
return Receipt{}, "", err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return Receipt{}, "", errors.New("lane must be a real directory")
}
entries, err := os.ReadDir(directory)
if err != nil {
return Receipt{}, "", err
}
if len(entries) != 2 || entries[0].Name() != "TEND-CI-VERIFICATION.json" || entries[1].Name() != "TEND-CI-VERIFICATION.json.sha256" {
return Receipt{}, "", errors.New("lane must contain only its receipt and checksum sidecar")
}
receiptPath := filepath.Join(directory, "TEND-CI-VERIFICATION.json")
contents, err := readRegularFile(receiptPath, maximumEvidenceSize)
if err != nil || len(contents) == 0 {
return Receipt{}, "", errors.New("receipt must be a non-empty bounded regular file")
}
receiptDigest := digest(contents)
sidecar, err := readRegularFile(receiptPath+".sha256", 512)
if err != nil {
return Receipt{}, "", fmt.Errorf("read receipt checksum: %w", err)
}
expectedSidecar := receiptDigest + " TEND-CI-VERIFICATION.json\n"
if string(sidecar) != expectedSidecar {
return Receipt{}, "", errors.New("receipt checksum sidecar does not match")
}
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.DisallowUnknownFields()
var receipt Receipt
if err := decoder.Decode(&receipt); err != nil {
return Receipt{}, "", fmt.Errorf("decode receipt: %w", err)
}
if err := requireJSONEOF(decoder); err != nil {
return Receipt{}, "", fmt.Errorf("decode receipt: %w", err)
}
if err := validateReceipt(receipt); err != nil {
return Receipt{}, "", err
}
return receipt, receiptDigest, nil
}
func requireNativeGates(gates []string) error {
observed := make(map[string]bool, len(gates))
for _, gate := range gates {
if observed[gate] {
return fmt.Errorf("successful gate %q is duplicated", gate)
}
observed[gate] = true
}
for _, gate := range requiredNativeGates {
if !observed[gate] {
return fmt.Errorf("required successful gate %q is missing", gate)
}
}
return nil
}
func equalStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
+109
View File
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-only
package releaseartifact
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestVerifyNativeReceiptSetRequiresEveryLaneAndMatchingGeneratedOutput(t *testing.T) {
expected := NativeReceiptExpectation{
Repository: "gamertan/sandwich-hime",
Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
}
directory := nativeReceiptFixture(t, expected, strings.Repeat("c", 64))
summary, err := VerifyNativeReceiptSet(directory, expected)
if err != nil {
t.Fatal(err)
}
if summary.GeneratedDigest != strings.Repeat("c", 64) || len(summary.Lanes) != 4 {
t.Fatalf("native summary is incomplete: %#v", summary)
}
t.Run("source mismatch", func(t *testing.T) {
mismatch := expected
mismatch.Commit = strings.Repeat("d", 40)
if _, err := VerifyNativeReceiptSet(directory, mismatch); err == nil {
t.Fatal("mismatched source commit was accepted")
}
})
t.Run("generated mismatch", func(t *testing.T) {
mismatched := nativeReceiptFixture(t, expected, strings.Repeat("c", 64))
lane := requiredNativeLanes[0]
laneDirectory := filepath.Join(mismatched, lane.directory)
if err := os.RemoveAll(laneDirectory); err != nil {
t.Fatal(err)
}
writeNativeLaneFixture(t, laneDirectory, lane, expected, strings.Repeat("e", 64))
if _, err := VerifyNativeReceiptSet(mismatched, expected); err == nil {
t.Fatal("different generated output was accepted")
}
})
}
func TestVerifyNativeReceiptSetRejectsTamperingAndUnexpectedFiles(t *testing.T) {
expected := NativeReceiptExpectation{
Repository: "gamertan/sandwich-hime",
Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
}
t.Run("checksum", func(t *testing.T) {
directory := nativeReceiptFixture(t, expected, strings.Repeat("c", 64))
sidecar := filepath.Join(directory, requiredNativeLanes[0].directory, "TEND-CI-VERIFICATION.json.sha256")
if err := os.Chmod(sidecar, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sidecar, []byte(strings.Repeat("0", 64)+" TEND-CI-VERIFICATION.json\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := VerifyNativeReceiptSet(directory, expected); err == nil {
t.Fatal("tampered checksum was accepted")
}
})
t.Run("unexpected entry", func(t *testing.T) {
directory := nativeReceiptFixture(t, expected, strings.Repeat("c", 64))
if err := os.WriteFile(filepath.Join(directory, "notes.txt"), []byte("not a receipt"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := VerifyNativeReceiptSet(directory, expected); err == nil {
t.Fatal("unexpected native evidence entry was accepted")
}
})
}
func nativeReceiptFixture(t *testing.T, expected NativeReceiptExpectation, generatedDigest string) string {
t.Helper()
directory := t.TempDir()
for _, lane := range requiredNativeLanes {
writeNativeLaneFixture(t, filepath.Join(directory, lane.directory), lane, expected, generatedDigest)
}
return directory
}
func writeNativeLaneFixture(t *testing.T, directory string, lane nativeLane, expected NativeReceiptExpectation, generatedDigest string) {
t.Helper()
if err := os.Mkdir(directory, 0o700); err != nil {
t.Fatal(err)
}
runnerVersion := "v3.1.0"
if lane.goos == "darwin" {
runnerVersion = "v3.3.0"
}
receipt := Receipt{
Repository: expected.Repository, Commit: expected.Commit, Tree: expected.Tree,
GOOS: lane.goos, GOARCH: lane.goarch, GoVersion: lane.goVersion,
RunnerVersion: runnerVersion, RunnerName: lane.runner,
GeneratedDigest: generatedDigest, UnsignedArtifactSHA: strings.Repeat("f", 64),
SuccessfulGates: append([]string(nil), requiredNativeGates...),
CompletedAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339),
}
if _, err := WriteReceipt(filepath.Join(directory, "TEND-CI-VERIFICATION.json"), receipt); err != nil {
t.Fatal(err)
}
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Package testpath provides filesystem helpers for tests that exercise
// Hime-san's deliberate symlink boundaries.
package testpath
import (
"os"
"path/filepath"
"testing"
)
// TempDir returns the physical path to a fresh directory owned by the test.
//
// macOS commonly exposes its temporary directory through /var even though
// /var is a root-owned system symlink to /private/var. Resolving a directory
// immediately after testing.TB creates it keeps tests portable without
// teaching production path validation to follow user-controlled symlinks.
func TempDir(t testing.TB) string {
t.Helper()
directory := t.TempDir()
resolved, err := filepath.EvalSymlinks(directory)
if err != nil {
t.Fatalf("resolve test temporary directory: %v", err)
}
resolved = filepath.Clean(resolved)
info, err := os.Lstat(resolved)
if err != nil {
t.Fatalf("inspect resolved test temporary directory: %v", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("resolved test temporary directory is not a physical directory: %s", resolved)
}
return resolved
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
package testpath
import (
"path/filepath"
"testing"
)
func TestTempDirReturnsPhysicalPath(t *testing.T) {
directory := TempDir(t)
resolved, err := filepath.EvalSymlinks(directory)
if err != nil {
t.Fatal(err)
}
if filepath.Clean(resolved) != directory {
t.Fatalf("TempDir() = %q, physical path = %q", directory, resolved)
}
}
+2
View File
@@ -17,6 +17,7 @@ func TestSelectCompilerVersion(t *testing.T) {
{name: "missing build info", linkerValue: developmentCompilerVersion, moduleVersion: "", want: developmentCompilerVersion},
{name: "versioned go install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0", want: "v1.0.0"},
{name: "beta launch install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.1", want: "v1.0.0-beta.1"},
{name: "beta two install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.2", want: "v1.0.0-beta.2"},
{name: "versioned prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-rc.1", want: "v1.0.0-rc.1"},
{name: "hyphenated prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.2.3-beta-2", want: "v1.2.3-beta-2"},
{name: "pseudo version", linkerValue: developmentCompilerVersion, moduleVersion: "v0.0.0-20260811120000-0123456789ab", want: developmentCompilerVersion},
@@ -30,6 +31,7 @@ func TestSelectCompilerVersion(t *testing.T) {
{name: "leading zero numeric beta identifier", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.01", want: developmentCompilerVersion},
{name: "empty beta identifier", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta..1", want: developmentCompilerVersion},
{name: "beta linker override wins", linkerValue: "v1.0.0-beta.1", moduleVersion: "(devel)", want: "v1.0.0-beta.1"},
{name: "beta two linker override wins", linkerValue: "v1.0.0-beta.2", moduleVersion: "(devel)", want: "v1.0.0-beta.2"},
{name: "linker override wins", linkerValue: "v1.0.0-rc.1", moduleVersion: "v1.0.0", want: "v1.0.0-rc.1"},
}
for _, test := range tests {
+3
View File
@@ -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.
+106
View File
@@ -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)
}
}
+119
View File
@@ -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()
}
+225
View File
@@ -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())
}
})
}
+32
View File
@@ -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
+69
View File
@@ -0,0 +1,69 @@
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# Sanitized public source snapshots
`export-public-snapshot.sh` creates a host-neutral filesystem snapshot from a
committed Git tree. It does not initialize a repository, copy `.git`, configure
a remote, commit, tag, push, or publish anything.
Release use requires a clean worktree, a ref resolving exactly to `HEAD`, and
the exact-file policy committed at `scripts/public-snapshot.allow` in that
ref:
```sh
scripts/export-public-snapshot.sh \
--mode release \
--ref HEAD \
--destination ../sandwich-hime-public-review
```
The destination must not exist and its canonical parent must be outside the
source worktree, its worktree-specific Git directory, and its shared Git common
directory. This includes ordinary `.git` directories and linked-worktree
metadata stored elsewhere. The exporter creates a private sibling staging
directory and renames it into place only after all checks pass. It never clears
or replaces an existing destination; failure cleanup is limited to a staging
directory carrying the exporter's ownership marker.
Review mode may use an externally reviewed exact-file policy while changes to
the exporter itself await a commit. Its provenance is conspicuously marked
`review` and is not a release artifact:
```sh
scripts/export-public-snapshot.sh \
--mode review \
--allowlist /path/to/reviewed-exact-files.allow \
--destination ../snapshot-for-review
```
The policy accepts individual files only—never directories or globs. The
export fails for missing/duplicate/forbidden entries, non-regular Git objects,
symlinks, binary or oversized content, aggregate size limits, private developer
filesystem indicators, common private-key/token indicators, database or build
artifacts, and explicitly private integration material. Host workflow folders,
private trees, prototype/history trees, and application-specific integrations
are not in the reviewed policy.
`PUBLIC-SNAPSHOT.sha256` records every exported source file. The deterministic
`PUBLIC-SNAPSHOT.json` records only the project identifier, export policy and
mode, file count, and policy/manifest digests. Private commit and tree IDs,
commit timestamps, author or committer identity, email, hostname, branch name,
remote URL, and checkout path stay outside the exported tree. Filesystem
timestamps are normalized to the Unix epoch. A separate private release ledger
may map the private source commit to the resulting public commit and signed
tags.
When an exported tree is reviewed into an existing public checkout, compare and
copy files by content (for example, checksum-aware synchronization or a fresh
tree replacement). Size-and-modification-time shortcuts are unsafe here because
the exporter deliberately gives every snapshot the same normalized timestamp;
the manifest and provenance records must be verified again before publication.
Run the focused checks with:
```sh
bash scripts/test-public-snapshot.sh
```
The implementation expects Bash, Git, tar, GNU-compatible core utilities, and
a filesystem supporting an atomic rename within the destination parent.
+28 -6
View File
@@ -2,20 +2,42 @@
# Repository verification tools
These scripts are intentionally understandable shell and PowerShell rather than a release framework with hidden defaults.
These scripts are intentionally understandable shell rather than a release
framework with hidden defaults. The maintained native verification paths are
Linux/amd64 and macOS/arm64.
- `verify.sh` runs root and nested-module tests and vet, builds `himesan`, checks the compiler-owned golden output, and proves two generation passes leave the same bytes and unchanged modification times. Set `HIMESAN_RACE=1` for race tests.
- `verify.ps1` provides the equivalent native Windows lane; pass `-Race` to include the race detector.
- `check-licenses.sh` enforces the AGPL compiler / Apache runtime boundary and prevents generated application Go from inheriting an AGPL identifier.
- `release-check.sh --version vX.Y.Z` is a clean-checkout technical preflight, including exact candidate-version and generated-provenance checks. Beta publication follows the narrower prerelease gates in `RELEASE.md`; release candidates and final v1 additionally use `--public` with a human-reviewed `HIMESAN_RELEASE_EVIDENCE_DIR`. The script never tags, pushes, publishes, or deploys.
- `verify-public-install.sh --version vX.Y.Z` is a post-tag/publication check. It verifies exact `go-get=1` package routes and runs the documented compiler install and runtime get from fresh direct-fetch and public-proxy caches without interactive Git credentials.
- `test-public-snapshot.sh` proves the exact allowlist, secret/path scanner, legal boundary, deterministic manifest, and no-overwrite export behavior on both maintained native hosts.
- Contract tests bind the exported runtime API, CLI help, JSON/configuration schemas, diagnostic-code inventory, generic component signature, and generated provenance to the reviewed files under `contracts/` and `sando/testdata/`.
- `release-check.sh --version vX.Y.Z` is a clean-checkout technical preflight, including exact candidate-version and generated-provenance checks. Beta publication follows the narrower prerelease gates in `RELEASE.md`; release candidates and final v1 additionally use `--public` with a human-reviewed `HIMESAN_RELEASE_EVIDENCE_DIR` and the four-lane `HIMESAN_NATIVE_EVIDENCE_DIR`. Seal the review directory with `go run ./cmd/himesan-release evidence-manifest`; `verify-native` independently checks every native receipt sidecar, source identity, gate, freshness bound, and generated-output digest. The script never tags, pushes, publishes, or deploys.
- `verify-public-install.sh --version vX.Y.Z` is a post-tag/publication check. It verifies exact `go-get=1` package routes, adds the nested runtime before installing the parent compiler, and exercises fresh direct-fetch and public-proxy caches without interactive Git credentials.
- `package-native.sh` performs two native builds and uses the repository-owned Go packager for a deterministic archive, manifest, SBOM, and checksums. `package-macos.sh` is the explicit Apple Silicon entrypoint used by the release operator.
- `sign-notarize-macos.sh` is a deliberately manual boundary. It requires the explicitly approved unsigned archive digest, uses Cole's Developer ID and Keychain-held notary profile, regenerates provenance and checksums for the changed signed Mach-O bytes, and produces a signed, notarized, and stapled DMG. The native runner receives neither credential.
- `verify-real-browser.sh` is opt-in release evidence. It runs the development client in an actual reviewed Chrome/Chromium binary, exercising CSP-restricted execution, SSE diagnostics, reload, and fragment/API exclusions, then reruns the process cleanup integration cases. Chrome is not a normal build or consumer dependency.
The canonical Linux CI and release preflight also run bounded fuzz sessions for the parser/context compiler and Go-aware delimiter scanner. Seed-corpus execution remains part of ordinary `go test`; the bounded sessions are extra evidence, not a substitute for longer scheduled fuzzing before v1.
The canonical Linux and macOS CI gates run the contract and public-snapshot
checks plus bounded fuzz sessions for the parser/context compiler, Go-aware
delimiter scanner, URL policy, and LSP boundaries. The compiler target also
asserts deterministic diagnostics and generated Go, source-digest binding, and
safe source-map directives. Seed-corpus execution remains part of ordinary
`go test`; the bounded sessions are extra evidence, not a substitute for the
long exact-candidate campaign before v1.
The release preflight invokes `govulncheck` from the official Go vulnerability project at the exact module version `golang.org/x/vuln@v1.6.0`. Updating that pin requires reviewing the upstream tag and rerunning the supported Go lines.
## Preview automation status
Forge workflows are intentionally excluded from the sanitized pre-1.0 public snapshot until the project has confirmed its own Gitea runner availability and reviewed locally hosted or otherwise pinned dependencies. Local `verify.sh`, `verify.ps1`, license, and release-preflight results are the preview gates.
Forge workflows are intentionally excluded from the sanitized pre-1.0 public
snapshot. The private development repository uses pinned Linux/amd64 and
repository-scoped native macOS/arm64 runners; the public source remains
independently verifiable with the repository scripts and both native release
preflights.
The private `public-candidate-verification` workflow is a release controller,
not public-source evidence by association. It accepts only the exact lowercase
commit currently at canonical public `main`, clones only that fixed Gitea origin without credentials,
and produces receipts naming `gamertan/sandwich-hime`. Development-repository
receipts cannot satisfy the public release preflight.
If Gitea automation is later added to the public repository, pin every external action to a reviewed immutable commit, document its provenance, grant minimum permissions, and keep a local verification path. A secondary forge may host a sanitized, read-only discovery snapshot, but hosted workflows stay disabled there and it does not become a release or contribution authority.
+7 -4
View File
@@ -54,7 +54,7 @@ check_sha256() {
is_comment_capable_project_file() {
case "$1" in
COPYRIGHT | */COPYRIGHT | .editorconfig | .gitattributes | .gitignore | *.go | *.mod | *.md | *.sh | *.ps1 | *.yml | *.yaml | *.html | *.css | *.js | *.toml | *.allow)
COPYRIGHT | */COPYRIGHT | .editorconfig | .gitattributes | .gitignore | *.go | *.mod | *.md | *.txt | *.sh | *.ps1 | *.yml | *.yaml | *.html | *.css | *.js | *.toml | *.plist | *.allow)
return 0
;;
*)
@@ -67,8 +67,10 @@ list_project_files() {
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git ls-files --cached --others --exclude-standard -z
else
find . -type d \( -name .git -o -name vendor -o -name bin -o -name dist -o -name coverage \) -prune -o \
-type f -print0 | sed -z 's#^\./##'
while IFS= read -r -d '' path; do
printf '%s\0' "${path#./}"
done < <(find . -type d \( -name .git -o -name vendor -o -name bin -o -name dist -o -name coverage \) -prune -o \
-type f -print0)
fi
}
@@ -152,7 +154,8 @@ while IFS= read -r -d '' path; do
continue
;;
*.json)
fail "$path cannot carry a comment and needs an explicit license-map entry"
grep -Fq "\`$path\`" LICENSES.md || \
fail "$path cannot carry a comment and needs an exact license-map entry"
continue
;;
esac
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
script_root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
project_root=$(CDPATH= cd -- "$script_root/.." && pwd)
# shellcheck source=public-snapshot-lib.sh
source "$script_root/public-snapshot-lib.sh"
# Release safety limits are policy, not caller-tunable settings.
SNAPSHOT_MAX_FILE_BYTES=1048576
SNAPSHOT_MAX_TOTAL_BYTES=16777216
SNAPSHOT_MAX_FILES=2000
usage() {
cat >&2 <<'USAGE'
Usage: export-public-snapshot.sh --destination PATH [options]
Options:
--source PATH Git worktree root (default: repository containing script)
--ref REF Committed source ref (default: HEAD)
--mode MODE release (default) or review
--allowlist PATH Review mode only: audited external exact-file policy
The destination must not exist and must be outside the source worktree. The
exporter creates it atomically and never initializes Git, configures a remote,
commits, tags, pushes, or copies .git.
USAGE
}
source_path=$project_root
source_ref=HEAD
destination=""
mode=release
allowlist_override=""
while [[ $# -gt 0 ]]; do
case $1 in
--source)
[[ $# -ge 2 ]] || { usage; exit 2; }
source_path=$2
shift 2
;;
--ref)
[[ $# -ge 2 ]] || { usage; exit 2; }
source_ref=$2
shift 2
;;
--destination)
[[ $# -ge 2 ]] || { usage; exit 2; }
destination=$2
shift 2
;;
--mode)
[[ $# -ge 2 ]] || { usage; exit 2; }
mode=$2
shift 2
;;
--allowlist)
[[ $# -ge 2 ]] || { usage; exit 2; }
allowlist_override=$2
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
echo "public snapshot: unknown argument: $1" >&2
usage
exit 2
;;
esac
done
[[ -n $destination ]] || { usage; exit 2; }
[[ $mode == release || $mode == review ]] || { echo "public snapshot: mode must be release or review" >&2; exit 2; }
[[ $source_ref != -* && $source_ref != *$'\n'* && $source_ref != *$'\r'* ]] || {
echo "public snapshot: invalid source ref" >&2
exit 2
}
if [[ $mode == release && -n $allowlist_override ]]; then
echo "public snapshot: release mode requires the allowlist committed in the source ref" >&2
exit 2
fi
source_path=$(snapshot_realpath_existing "$source_path")
git_root=$(git -C "$source_path" rev-parse --show-toplevel 2>/dev/null) || {
echo "public snapshot: source is not a Git worktree" >&2
exit 1
}
git_root=$(snapshot_realpath_existing "$git_root")
[[ $source_path == "$git_root" ]] || { echo "public snapshot: --source must name the worktree root" >&2; exit 1; }
git_dir=$(git -C "$git_root" rev-parse --absolute-git-dir 2>/dev/null) || {
echo "public snapshot: cannot resolve source Git metadata directory" >&2
exit 1
}
git_common_dir=$(git -C "$git_root" rev-parse --git-common-dir 2>/dev/null) || {
echo "public snapshot: cannot resolve source Git common directory" >&2
exit 1
}
git_dir=$(snapshot_realpath_existing "$git_dir")
if [[ $git_common_dir != /* ]]; then
git_common_dir=$git_root/$git_common_dir
fi
git_common_dir=$(snapshot_realpath_existing "$git_common_dir")
commit=$(git -C "$git_root" rev-parse --verify "${source_ref}^{commit}" 2>/dev/null) || {
echo "public snapshot: source ref does not resolve to a commit" >&2
exit 1
}
[[ $commit =~ ^[0-9a-f]{40}$ || $commit =~ ^[0-9a-f]{64}$ ]] || {
echo "public snapshot: source commit is not a full object ID" >&2
exit 1
}
if [[ $mode == release ]]; then
head_commit=$(git -C "$git_root" rev-parse --verify HEAD^{commit})
[[ $commit == "$head_commit" ]] || { echo "public snapshot: release ref must resolve to HEAD" >&2; exit 1; }
status=$(git -C "$git_root" status --porcelain=v1 --untracked-files=all)
snapshot_status_is_clean "$status" || {
echo "public snapshot: release source worktree is dirty" >&2
exit 1
}
fi
destination_parent=$(dirname -- "$destination")
destination_name=$(basename -- "$destination")
[[ $destination_name != . && $destination_name != .. && -n $destination_name ]] || {
echo "public snapshot: invalid destination name" >&2
exit 2
}
destination_parent=$(snapshot_realpath_existing "$destination_parent")
[[ -d $destination_parent && ! -L $destination_parent ]] || {
echo "public snapshot: destination parent must be an existing non-symlink directory" >&2
exit 1
}
for protected_root in "$git_root" "$git_dir" "$git_common_dir"; do
if snapshot_path_is_at_or_below "$destination_parent" "$protected_root"; then
echo "public snapshot: destination must be outside the source worktree and Git metadata" >&2
exit 1
fi
done
destination=$destination_parent/$destination_name
[[ ! -e $destination && ! -L $destination ]] || {
echo "public snapshot: destination already exists; refusing to alter it" >&2
exit 1
}
staging=$(mktemp -d "$destination_parent/.${destination_name}.himesan-public-export.XXXXXX")
marker_name=.himesan-public-export-owned
marker=$staging/$marker_name
printf 'owned temporary public snapshot staging directory\n' >"$marker"
cleanup() {
local status=$?
if [[ -n ${staging:-} && -d $staging && -f $marker ]]; then
case $staging in
"$destination_parent"/."$destination_name".himesan-public-export.*)
rm -rf -- "$staging"
;;
esac
fi
exit "$status"
}
trap cleanup EXIT
policy_file=$staging/.himesan-policy-input
if [[ -n $allowlist_override ]]; then
[[ $mode == review ]] || { echo "public snapshot: external policy is review-only" >&2; exit 2; }
[[ -f $allowlist_override && ! -L $allowlist_override ]] || {
echo "public snapshot: external allowlist must be a regular non-symlink file" >&2
exit 1
}
cp -- "$allowlist_override" "$policy_file"
else
policy_path=scripts/public-snapshot.allow
git -C "$git_root" cat-file -e "$commit:$policy_path" 2>/dev/null || {
echo "public snapshot: committed ref lacks $policy_path" >&2
exit 1
}
git -C "$git_root" show "$commit:$policy_path" >"$policy_file"
fi
policy_sha256=$(sha256sum "$policy_file" | awk '{print $1}')
seen_lines=$'\n'
paths=()
while IFS= read -r line || [[ -n $line ]]; do
[[ -n $line && ${line:0:1} != '#' ]] || continue
if [[ $line == *[[:space:]]* ]] || snapshot_forbidden_path "$line"; then
echo "public snapshot: invalid or forbidden allowlist entry: $line" >&2
exit 1
fi
case $seen_lines in
*$'\n'"$line"$'\n'*)
echo "public snapshot: duplicate allowlist entry: $line" >&2
exit 1
;;
esac
seen_lines=$seen_lines$line$'\n'
record=$(git -C "$git_root" ls-tree "$commit" -- "$line")
[[ -n $record && ${record#*$'\t'} == "$line" && $record != *$'\n'* ]] || {
echo "public snapshot: allowlisted path is absent or ambiguous in source ref: $line" >&2
exit 1
}
read -r object_mode object_type object_id <<<"${record%%$'\t'*}"
[[ $object_type == blob && ($object_mode == 100644 || $object_mode == 100755) ]] || {
echo "public snapshot: allowlisted path is not a regular file: $line" >&2
exit 1
}
blob_size=$(git -C "$git_root" cat-file -s "$object_id")
((blob_size <= SNAPSHOT_MAX_FILE_BYTES)) || {
echo "public snapshot: allowlisted blob is oversized: $line" >&2
exit 1
}
paths+=("$line")
done <"$policy_file"
[[ ${#paths[@]} -gt 0 ]] || { echo "public snapshot: allowlist selected no files" >&2; exit 1; }
sorted_paths=()
while IFS= read -r -d '' path; do
sorted_paths[${#sorted_paths[@]}]=$path
done < <(printf '%s\0' "${paths[@]}" | LC_ALL=C sort -z)
git -C "$git_root" archive --format=tar "$commit" -- "${sorted_paths[@]}" | tar -xf - -C "$staging"
unlink "$policy_file"
exported_count=$(find "$staging" -type f ! -name "$marker_name" | wc -l)
exported_count=${exported_count//[[:space:]]/}
[[ $exported_count -eq ${#sorted_paths[@]} ]] || {
echo "public snapshot: extracted file count does not match allowlist" >&2
exit 1
}
snapshot_validate_export_tree "$staging" "$marker_name"
manifest=$staging/PUBLIC-SNAPSHOT.sha256
manifest_input=$staging/.himesan-manifest-input
(cd "$staging" && find . -type f ! -name "$marker_name" ! -name .himesan-manifest-input -print0 | LC_ALL=C sort -z | xargs -0 sha256sum) >"$manifest_input"
mv "$manifest_input" "$manifest"
manifest_sha256=$(sha256sum "$manifest" | awk '{print $1}')
provenance=$staging/PUBLIC-SNAPSHOT.json
printf '{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"%s","file_count":%s,"allowlist_sha256":"%s","manifest_sha256":"%s"}\n' \
"$mode" "$exported_count" "$policy_sha256" "$manifest_sha256" >"$provenance"
# Normalize filesystem metadata to a public constant as well as normalizing
# content. Private commit IDs, tree IDs, timestamps, identities, refs, remote
# URLs, and checkout paths do not enter the exported tree.
TZ=UTC find "$staging" -exec touch -t 197001010000 {} +
unlink "$marker"
if mv --help 2>&1 | grep -q -- '-T'; then
mv -nT -- "$staging" "$destination"
else
# BSD mv has no -T. The existing-destination preflight above preserves the
# same no-overwrite policy for the local macOS review lane.
mv -n "$staging" "$destination"
fi
if [[ -e $staging || ! -d $destination ]]; then
echo "public snapshot: destination appeared during activation; staging was not published" >&2
exit 1
fi
staging=""
printf 'public_snapshot=%s\ncommit=%s\nfiles=%s\n' "$destination" "$commit" "$exported_count"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$repo_root"
usage() {
printf 'Usage: scripts/native-gate.sh --repository OWNER/REPOSITORY --go-version goX.Y.Z --runner-version VERSION --runner-name NAME --output DIR\n' >&2
}
repository=''
expected_go=''
runner_version=''
runner_name=''
output=''
while (( $# > 0 )); do
case "$1" in
--repository) repository=$2; shift 2 ;;
--go-version) expected_go=$2; shift 2 ;;
--runner-version) runner_version=$2; shift 2 ;;
--runner-name) runner_name=$2; shift 2 ;;
--output) output=$2; shift 2 ;;
*) usage; exit 2 ;;
esac
done
if [[ ! "$repository" =~ ^[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*$ || -z "$expected_go" || -z "$runner_version" || -z "$runner_name" || -z "$output" ]]; then
usage
exit 2
fi
if [[ "$(go env GOVERSION)" != "$expected_go" ]]; then
printf 'error: expected %s, found %s\n' "$expected_go" "$(go env GOVERSION)" >&2
exit 1
fi
mkdir -p -- "$output"
./scripts/check-licenses.sh
./scripts/test-public-snapshot.sh
HIMESAN_RACE=1 ./scripts/verify.sh
go test ./internal/compiler -run '^$' -fuzz '^FuzzCompileNeverPanics$' -fuzztime=15s
go test ./internal/compiler -run '^$' -fuzz '^FuzzGoDelimiterNeverPanics$' -fuzztime=15s
go test ./internal/lsp -run '^$' -fuzz '^FuzzFrameReaderNeverPanics$' -fuzztime=15s
go test ./internal/lsp -run '^$' -fuzz '^FuzzDocumentPositionNeverPanics$' -fuzztime=15s
(
cd sando
go test -run '^$' -fuzz '^FuzzWriteURLPolicy$' -fuzztime=15s
)
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
(
cd sando
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
)
./scripts/verify-consumer.sh
./scripts/package-native.sh --version v0.0.0-verification.1 --output "$output"
artifact=$(find "$output" -maxdepth 1 -type f -name '*.tar.gz' -print -quit)
artifact_sha=$(awk '{print $1}' "$artifact.sha256")
completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
go run ./cmd/himesan-release receipt \
--output "$output/TEND-CI-VERIFICATION.json" \
--repository "$repository" \
--commit "$(git rev-parse HEAD)" \
--tree "$(git rev-parse 'HEAD^{tree}')" \
--goos "$(go env GOOS)" --goarch "$(go env GOARCH)" \
--go-version "$expected_go" --runner-version "$runner_version" \
--runner-name "$runner_name" --artifact-sha256 "$artifact_sha" \
--completed-at "$completed_at" \
--gates test,vet,build,race,generation,contracts,public-snapshot,fuzz,vulnerability,consumer,package \
--generated-files internal/compiler/testdata/golden/basic.sando.go
printf 'Native verification evidence: %s\n' "$output"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
if [[ "$(uname -s)/$(uname -m)" != Darwin/arm64 ]]; then
printf 'error: the maintained macOS artifact must be built natively on darwin/arm64\n' >&2
exit 1
fi
exec "$(dirname -- "${BASH_SOURCE[0]}")/package-native.sh" "$@"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$repo_root"
usage() { printf 'Usage: scripts/package-native.sh --version vX.Y.Z --output DIR\n' >&2; }
version=''
output=''
while (( $# > 0 )); do
case "$1" in
--version) [[ $# -ge 2 ]] || { usage; exit 2; }; version=$2; shift 2 ;;
--output) [[ $# -ge 2 ]] || { usage; exit 2; }; output=$2; shift 2 ;;
*) usage; exit 2 ;;
esac
done
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
printf 'error: a canonical v-prefixed release version is required\n' >&2
exit 2
fi
[[ -n "$output" ]] || { usage; exit 2; }
if ! git diff --quiet -- || ! git diff --cached --quiet --; then
printf 'error: native release packaging requires a clean tracked worktree\n' >&2
exit 1
fi
untracked_sources=$(git ls-files --others --exclude-standard -- \
'*.go' '*.sando' 'go.mod' 'go.sum' 'vendor/**' || true)
if [[ -n "$untracked_sources" ]]; then
printf 'error: untracked build inputs prevent trustworthy release provenance:\n%s\n' "$untracked_sources" >&2
exit 1
fi
target="$(go env GOOS)/$(go env GOARCH)"
case "$target" in
darwin/arm64 | linux/amd64) ;;
*) printf 'error: unsupported maintained native target: %s\n' "$target" >&2; exit 1 ;;
esac
if [[ "$(go env GOVERSION)" != go1.26.7 && "$(go env GOVERSION)" != go1.27.0 ]]; then
printf 'error: unsupported release toolchain: %s\n' "$(go env GOVERSION)" >&2
exit 1
fi
temporary=$(mktemp -d "${TMPDIR:-/tmp}/himesan-native-package.XXXXXXXX")
temporary=$(CDPATH= cd -- "$temporary" && pwd -P)
cleanup() { rm -rf -- "$temporary"; }
trap cleanup EXIT HUP INT TERM
commit=$(git rev-parse HEAD)
tree=$(git rev-parse 'HEAD^{tree}')
source_date_epoch=$(git show -s --format=%ct HEAD)
go_version=$(go env GOVERSION)
target_os=${target%/*}
target_arch=${target#*/}
linker_flags="-buildid= -X gamertan.com/sandwich-hime/internal/version.Compiler=$version"
for pass in one two; do
CGO_ENABLED=0 GOTOOLCHAIN=local go build -buildvcs=false -trimpath \
-ldflags "$linker_flags" -o "$temporary/himesan-$pass" ./cmd/himesan
done
if ! cmp -s "$temporary/himesan-one" "$temporary/himesan-two"; then
printf 'error: repeated native builds were not byte-identical\n' >&2
exit 1
fi
case "$target" in
darwin/arm64) expected='Mach-O 64-bit executable arm64' ;;
linux/amd64) expected='ELF 64-bit LSB executable, x86-64' ;;
esac
if ! file "$temporary/himesan-one" | grep -Fq "$expected"; then
printf 'error: candidate has the wrong native executable format\n' >&2
file "$temporary/himesan-one" >&2
exit 1
fi
mkdir -p -- "$output"
go run ./cmd/himesan-release package \
--version "$version" --commit "$commit" --tree "$tree" \
--go-version "$go_version" --goos "$target_os" --goarch "$target_arch" \
--binary "$temporary/himesan-one" --output "$output" \
--source-date-epoch "$source_date_epoch"
printf 'Unsigned native package created. Signing and notarization were not performed.\n'
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Sourced validation helpers for export-public-snapshot.sh and its focused
# tests. This file deliberately leaves the caller's shell options unchanged.
snapshot_status_is_clean() {
[[ -z $1 ]]
}
snapshot_path_is_at_or_below() {
local candidate=$1
local boundary=$2
[[ $boundary == / || $candidate == "$boundary" || $candidate == "$boundary/"* ]]
}
snapshot_realpath_existing() {
local path=$1
local directory base
if [[ -d $path ]]; then
(CDPATH= cd -- "$path" && pwd -P)
return
fi
if [[ -f $path ]]; then
directory=$(dirname -- "$path")
base=$(basename -- "$path")
directory=$(CDPATH= cd -- "$directory" && pwd -P) || return 1
printf '%s/%s\n' "$directory" "$base"
return
fi
return 1
}
snapshot_forbidden_path() {
local path=$1
local lower
lower=$(printf '%s' "$path" | LC_ALL=C tr '[:upper:]' '[:lower:]')
[[ $path != /* && $path != *\\* && $path != *//* ]] || return 0
[[ $path != . && $path != .. && $path != ../* && $path != */../* && $path != */.. ]] || return 0
[[ $path != *$'\n'* && $path != *$'\r'* && $path != *$'\t'* ]] || return 0
case "/$lower/" in
*/.git/* | */.gitea/* | */.github/* | */private/* | */prototype/* | */prototypes/* | */history/* | */legacy/* | */vendor/* | */bin/* | */dist/* | */coverage/* | */cmd/himetest/* | */cmd/himework/* | */internal/himesan/* | */internal/integration/* | */templates/*)
return 0
;;
esac
case $lower in
go.work | go.work.sum | .env | .env.* | */.env | */.env.* | *.db | *.db-* | *.sqlite | *.sqlite3 | *.pem | *.key | *.p12 | *.pfx | */id_rsa | */id_ed25519 | *credentials* | *.exe | *.dll | *.dylib | *.so | *.a | *.o | *.test | *.prof | *.cover | *.zip | *.tar | *.tar.gz | *.tgz)
return 0
;;
esac
return 1
}
snapshot_validate_export_tree() {
local root=$1
local marker=${2:-}
local max_file_bytes=${SNAPSHOT_MAX_FILE_BYTES:-1048576}
local max_total_bytes=${SNAPSHOT_MAX_TOTAL_BYTES:-16777216}
local max_files=${SNAPSHOT_MAX_FILES:-2000}
local total=0
local count=0
local file rel size
local users_word=Users
local private_unix="/(home|${users_word})/[^/[:space:]]+"
local private_windows='[A-Za-z]:[\\/]+Users[\\/]'
local private_wsl="/mnt/[a-zA-Z]/${users_word}/"
local pem_begin='-----BEGIN '
local private_key="${pem_begin}([A-Z0-9]+ )?PRIVATE KEY-----|${pem_begin}PGP PRIVATE KEY BLOCK-----"
local provider_token='AKIA[0-9A-Z]{16}|(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}'
local private_commit_id='(private development (source|baseline)|private (development )?(commit|source))[^[:cntrl:]]*[0-9a-f]{12,64}'
local private_repository='sandwich-hime-''dev'
if find "$root" -type l -print -quit | grep -q .; then
echo "public snapshot: symbolic links are forbidden" >&2
return 1
fi
while IFS= read -r -d '' file; do
rel=${file#"$root"/}
[[ -z $marker || $rel != "$marker" ]] || continue
if snapshot_forbidden_path "$rel"; then
echo "public snapshot: forbidden path: $rel" >&2
return 1
fi
size=$(wc -c <"$file")
size=${size//[[:space:]]/}
if ((size > max_file_bytes)); then
echo "public snapshot: oversized file: $rel ($size bytes)" >&2
return 1
fi
total=$((total + size))
count=$((count + 1))
if ((total > max_total_bytes || count > max_files)); then
echo "public snapshot: export exceeds aggregate size/count limits" >&2
return 1
fi
if [[ -s $file ]] && ! LC_ALL=C grep -Iq . "$file"; then
echo "public snapshot: binary file rejected: $rel" >&2
return 1
fi
if LC_ALL=C grep -Eq "$private_unix|$private_windows|$private_wsl" "$file"; then
echo "public snapshot: private filesystem path indicator in $rel" >&2
return 1
fi
if LC_ALL=C grep -Eq -- "$private_key|$provider_token" "$file"; then
echo "public snapshot: key or credential indicator in $rel" >&2
return 1
fi
if LC_ALL=C grep -Eiq -- "$private_commit_id" "$file"; then
echo "public snapshot: private commit identifier in $rel" >&2
return 1
fi
if LC_ALL=C grep -Fq -- "$private_repository" "$file"; then
echo "public snapshot: private repository indicator in $rel" >&2
return 1
fi
done < <(find "$root" -type f -print0 | LC_ALL=C sort -z)
}
+129
View File
@@ -0,0 +1,129 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Exact reviewed file allowlist for a host-neutral public source snapshot.
# Directories, globs, and implicit descendants are intentionally unsupported.
.editorconfig
.gitattributes
.gitignore
AI_CONTRIBUTIONS.md
CHANGELOG.md
CLA.md
CONTRIBUTING.md
COPYRIGHT
DCO.txt
GENERATED_CODE.md
GOVERNANCE.md
LICENSE
LICENSES.md
MAINTAINERS.md
OUTPUT_EXCEPTION.md
README.md
RELEASE.md
ROADMAP.md
SECURITY.md
SPEC.md
TRADEMARKS.md
cmd/himesan/contract_test.go
cmd/himesan/main.go
cmd/himesan/main_test.go
cmd/himesan-release/main.go
contracts/diagnostic-codes-v1.txt
contracts/generated-provenance-v1.txt
contracts/himesan-cli-help-v1.txt
contracts/himesan-config-v1.schema.json
contracts/himesan-operation-output-v1.schema.json
contracts/himesan-version-output-v1.schema.json
docs/ARCHITECTURE.md
docs/BENCHMARKS.md
docs/BRAND.md
docs/COMPATIBILITY.md
docs/DEVELOPMENT_SERVER.md
docs/DIAGNOSTICS.md
docs/LANGUAGE_SERVER.md
docs/SECURITY_EVIDENCE.md
docs/THREAT_MODEL.md
docs/V1_RELEASE_PLAN.md
go.mod
internal/compiler/abi_test.go
internal/compiler/analysis.go
internal/compiler/analysis_test.go
internal/compiler/backend.go
internal/compiler/compiler_test.go
internal/compiler/contract_test.go
internal/compiler/context.go
internal/compiler/diagnostic.go
internal/compiler/discover.go
internal/compiler/e2e_test.go
internal/compiler/fuzz_test.go
internal/compiler/model.go
internal/compiler/operation.go
internal/compiler/parse.go
internal/compiler/replace_unix.go
internal/compiler/replace_windows.go
internal/compiler/testdata/golden/basic.sando
internal/compiler/testdata/golden/basic.sando.go
internal/devserver/browser_evidence_test.go
internal/devserver/config.go
internal/devserver/config_test.go
internal/devserver/contract_test.go
internal/devserver/events.go
internal/devserver/process.go
internal/devserver/process_test.go
internal/devserver/process_unix.go
internal/devserver/process_windows.go
internal/devserver/process_windows_32.go
internal/devserver/process_windows_64.go
internal/devserver/proxy.go
internal/devserver/proxy_test.go
internal/devserver/supervisor.go
internal/devserver/supervisor_test.go
internal/devserver/watch.go
internal/devserver/watch_test.go
internal/lsp/features.go
internal/lsp/protocol.go
internal/lsp/protocol_test.go
internal/lsp/security_test.go
internal/lsp/server.go
internal/lsp/server_test.go
internal/lsp/workspace.go
internal/releaseartifact/artifact.go
internal/releaseartifact/artifact_test.go
internal/releaseartifact/evidence.go
internal/releaseartifact/evidence_test.go
internal/releaseartifact/macos.go
internal/releaseartifact/macos_test.go
internal/releaseartifact/native.go
internal/releaseartifact/native_test.go
internal/testpath/tempdir.go
internal/testpath/tempdir_test.go
internal/version/version.go
internal/version/version_test.go
sando/COPYRIGHT
sando/LICENSE
sando/README.md
sando/api_contract_test.go
sando/benchmark_test.go
sando/component.go
sando/component_test.go
sando/differential_test.go
sando/go.mod
sando/trust.go
sando/write.go
sando/write_test.go
sando/testdata/public-api-v1.txt
scripts/README.md
scripts/PUBLIC_SNAPSHOT.md
scripts/check-licenses.sh
scripts/export-public-snapshot.sh
scripts/native-gate.sh
scripts/package-macos.sh
scripts/package-native.sh
scripts/public-snapshot-lib.sh
scripts/public-snapshot.allow
scripts/release-check.sh
scripts/sign-notarize-macos.sh
scripts/test-public-snapshot.sh
scripts/verify-consumer.sh
scripts/verify-public-install.sh
scripts/verify-real-browser.sh
scripts/verify.sh
+111 -46
View File
@@ -8,28 +8,37 @@ cd "$repo_root"
usage() {
cat <<'EOF'
Usage: scripts/release-check.sh --version vX.Y.Z [--public]
Usage: scripts/release-check.sh --version vX.Y.Z [--runtime-tag sando/vX.Y.Z] [--public]
Runs a read-only release preflight. It never creates tags, commits, release
artifacts in the repository, pushes, or deploys.
--version Candidate compiler version. The corresponding runtime tag is
sando/vX.Y.Z.
--public Require the human-reviewed RC/final launch evidence bundle named
by HIMESAN_RELEASE_EVIDENCE_DIR. Canonical beta prereleases may
run their narrower publication preflight without this flag.
--version Candidate compiler version.
--runtime-tag Existing runtime tag retained by a compiler-only release.
Omit only when publishing a matching new runtime tag.
--public Require the human-reviewed RC/final launch evidence bundle
named by HIMESAN_RELEASE_EVIDENCE_DIR and the four native
runner receipts named by HIMESAN_NATIVE_EVIDENCE_DIR.
Canonical beta prereleases may run their narrower publication
preflight without this flag.
EOF
}
version=''
runtime_tag=''
public_release=0
while (( $# > 0 )); do
case "$1" in
--version)
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
version=$2
shift 2
;;
--version)
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
version=$2
shift 2
;;
--runtime-tag)
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
runtime_tag=$2
shift 2
;;
--public)
public_release=1
shift
@@ -74,7 +83,32 @@ if (( public_release == 0 && beta_release == 0 )); then
exit 2
fi
runtime_tag="sando/$version"
paired_runtime_tag="sando/$version"
compiler_only=0
if [[ -z "$runtime_tag" ]]; then
runtime_tag=$paired_runtime_tag
elif [[ ! "$runtime_tag" =~ ^sando/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$ ]]; then
printf 'error: --runtime-tag must be a canonical sando/v semantic version without build metadata\n' >&2
exit 2
else
runtime_prerelease=${BASH_REMATCH[5]:-}
if [[ "$runtime_prerelease" =~ (^|[.-])(0\.)?[0-9]{14}-[0-9a-f]{12,}$ ]]; then
printf 'error: --runtime-tag must identify a signed release, not a Go pseudo-version\n' >&2
exit 2
fi
if [[ -n "$runtime_prerelease" ]]; then
IFS=. read -r -a runtime_prerelease_identifiers <<<"$runtime_prerelease"
for identifier in "${runtime_prerelease_identifiers[@]}"; do
if [[ "$identifier" =~ ^[0-9]+$ && "$identifier" =~ ^0[0-9]+$ ]]; then
printf 'error: runtime numeric prerelease identifiers must not contain leading zeroes: %s\n' "$identifier" >&2
exit 2
fi
done
fi
if [[ "$runtime_tag" != "$paired_runtime_tag" ]]; then
compiler_only=1
fi
fi
if [[ -n "$(git status --porcelain=v1 --untracked-files=all)" ]]; then
printf 'error: release preflight requires a clean canonical checkout\n' >&2
@@ -99,7 +133,11 @@ if [[ "$branch" != main ]]; then
exit 1
fi
for tag in "$version" "$runtime_tag"; do
candidate_tags=("$version")
if (( compiler_only == 0 )); then
candidate_tags+=("$runtime_tag")
fi
for tag in "${candidate_tags[@]}"; do
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
printf 'error: candidate tag already exists locally: %s\n' "$tag" >&2
exit 1
@@ -114,7 +152,28 @@ for tag in "$version" "$runtime_tag"; do
fi
done
if (( compiler_only == 1 )); then
if ! runtime_refs=$(git ls-remote --tags origin "refs/tags/$runtime_tag" "refs/tags/$runtime_tag^{}" 2>/dev/null); then
printf 'error: could not verify retained runtime tag against canonical origin: %s\n' "$runtime_tag" >&2
exit 1
fi
runtime_commit=$(printf '%s\n' "$runtime_refs" | awk '$2 ~ /\^\{\}$/ {print $1}')
if [[ -z "$runtime_commit" || ! "$runtime_commit" =~ ^[0-9a-f]{40}$ ]]; then
printf 'error: retained runtime tag is absent or is not annotated: %s\n' "$runtime_tag" >&2
exit 1
fi
if ! git cat-file -e "$runtime_commit^{commit}" 2>/dev/null; then
printf 'error: retained runtime tag commit is not present in canonical history: %s\n' "$runtime_tag" >&2
exit 1
fi
if [[ "$(git rev-parse HEAD:sando)" != "$(git rev-parse "$runtime_commit:sando")" ]]; then
printf 'error: compiler-only release changed the sando subtree retained at %s\n' "$runtime_tag" >&2
exit 1
fi
fi
artifact_dir=$(mktemp -d "${TMPDIR:-/tmp}/himesan-release-check.XXXXXXXX")
artifact_dir=$(CDPATH= cd -- "$artifact_dir" && pwd -P)
cleanup() {
if [[ -n "${artifact_dir:-}" && -d "$artifact_dir" ]]; then
rm -rf -- "$artifact_dir"
@@ -135,7 +194,7 @@ if [[ "$actual_human_version" != "$expected_human_version" ]]; then
"$expected_human_version" "$actual_human_version" >&2
exit 1
fi
expected_json_version=$(printf '{"compiler":"%s","runtime_abi":"sando.v1","go":"%s"}' "$version" "$candidate_go_version")
expected_json_version=$(printf '{"compiler":"%s","runtime_abi":"sando.v1","go":"%s","features":["lsp-stdio"]}' "$version" "$candidate_go_version")
actual_json_version=$("$candidate_binary" version --json)
if [[ "$actual_json_version" != "$expected_json_version" ]]; then
printf 'error: candidate JSON version mismatch\nexpected: %s\nactual: %s\n' \
@@ -182,11 +241,18 @@ for pass in 1 2; do
done
./scripts/check-licenses.sh
./scripts/test-public-snapshot.sh
HIMESAN_RACE=1 ./scripts/verify.sh
printf '\n==> bounded compiler fuzz gates\n'
go test ./internal/compiler -run '^$' -fuzz '^FuzzCompileNeverPanics$' -fuzztime=20s
go test ./internal/compiler -run '^$' -fuzz '^FuzzGoDelimiterNeverPanics$' -fuzztime=20s
go test ./internal/lsp -run '^$' -fuzz '^FuzzFrameReaderNeverPanics$' -fuzztime=20s
go test ./internal/lsp -run '^$' -fuzz '^FuzzDocumentPositionNeverPanics$' -fuzztime=20s
(
cd sando
go test -run '^$' -fuzz '^FuzzWriteURLPolicy$' -fuzztime=20s
)
printf '\n==> vulnerability scan (pinned golang.org/x/vuln v1.6.0)\n'
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
@@ -195,24 +261,16 @@ go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
)
printf '\n==> cross-compiling release binary smoke set\n'
for target in \
linux/amd64 \
linux/arm64 \
darwin/amd64 \
darwin/arm64 \
windows/amd64 \
windows/arm64; do
target_os=${target%/*}
target_arch=${target#*/}
extension=''
if [[ "$target_os" == windows ]]; then
extension='.exe'
fi
CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" \
go build -trimpath -ldflags "$compiler_linker_flags" \
-o "$artifact_dir/himesan-$target_os-$target_arch$extension" ./cmd/himesan
done
target="$(go env GOOS)/$(go env GOARCH)"
case "$target" in
linux/amd64 | darwin/arm64) ;;
*) printf 'error: release preflight requires a maintained native target, found %s\n' "$target" >&2; exit 1 ;;
esac
target_os=${target%/*}
target_arch=${target#*/}
printf '\n==> building supported native %s release binary\n' "$target"
CGO_ENABLED=0 go build -trimpath -ldflags "$compiler_linker_flags" \
-o "$artifact_dir/himesan-$target_os-$target_arch" ./cmd/himesan
for required in \
scripts/verify-public-install.sh \
@@ -225,23 +283,26 @@ done
if (( public_release == 1 )); then
evidence_dir=${HIMESAN_RELEASE_EVIDENCE_DIR:-}
native_evidence_dir=${HIMESAN_NATIVE_EVIDENCE_DIR:-}
if [[ -z "$evidence_dir" || ! -d "$evidence_dir" ]]; then
printf 'error: --public requires HIMESAN_RELEASE_EVIDENCE_DIR\n' >&2
exit 1
fi
for evidence in \
legal-review.md \
cross-platform.md \
security.md \
development-supervisor.md \
benchmark-methodology.md \
vanity-imports.md \
signing-and-recovery.md; do
if [[ ! -s "$evidence_dir/$evidence" ]]; then
printf 'error: public release evidence is missing or empty: %s\n' "$evidence_dir/$evidence" >&2
exit 1
fi
done
if [[ -z "$native_evidence_dir" || ! -d "$native_evidence_dir" ]]; then
printf 'error: --public requires HIMESAN_NATIVE_EVIDENCE_DIR\n' >&2
exit 1
fi
go run ./cmd/himesan-release verify-native \
--directory "$native_evidence_dir" \
--repository gamertan/sandwich-hime \
--commit "$(git rev-parse HEAD)" \
--tree "$(git rev-parse 'HEAD^{tree}')"
go run ./cmd/himesan-release verify-evidence \
--directory "$evidence_dir" \
--repository gamertan/sandwich-hime \
--version "$version" \
--commit "$(git rev-parse HEAD)" \
--tree "$(git rev-parse 'HEAD^{tree}')"
fi
if [[ -n "$(git status --porcelain=v1 --untracked-files=all)" ]]; then
@@ -256,4 +317,8 @@ else
printf '\nBeta technical publication preflight passed. This does not establish RC/final launch evidence or production stability.\n'
fi
printf 'No tag, push, publication, or deployment was performed for %s / %s.\n' "$version" "$runtime_tag"
if (( compiler_only == 1 )); then
printf 'No tag, push, publication, or deployment was performed for %s; runtime remains %s.\n' "$version" "$runtime_tag"
else
printf 'No tag, push, publication, or deployment was performed for %s / %s.\n' "$version" "$runtime_tag"
fi
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage: scripts/sign-notarize-macos.sh --archive FILE --sha256 DIGEST --output DIR --keychain-profile NAME
Run this manually from Cole's signed-in macOS account. It never runs in CI.
It signs the native CLI, creates and signs a DMG, submits that DMG to Apple's
notary service, staples its ticket, and validates the distribution.
EOF
}
archive=''
archive_sha256=''
output=''
profile=''
identity='Developer ID Application: Cole Speelman (5BXR9JCUBL)'
identifier='com.gamertan.sandwich-hime.himesan'
while (( $# > 0 )); do
case "$1" in
--archive) archive=$2; shift 2 ;;
--sha256) archive_sha256=$2; shift 2 ;;
--output) output=$2; shift 2 ;;
--keychain-profile) profile=$2; shift 2 ;;
*) usage; exit 2 ;;
esac
done
if [[ -z "$archive" || -z "$archive_sha256" || -z "$output" || -z "$profile" ]]; then usage; exit 2; fi
if [[ ! "$archive_sha256" =~ ^[0-9a-f]{64}$ ]]; then
printf 'error: --sha256 must be the approved lowercase archive digest\n' >&2
exit 2
fi
if [[ "$(uname -s)/$(uname -m)" != Darwin/arm64 ]]; then
printf 'error: signing must run natively on Apple Silicon macOS\n' >&2
exit 1
fi
temporary=$(mktemp -d "${TMPDIR:-/tmp}/himesan-notarize.XXXXXXXX")
temporary=$(CDPATH= cd -- "$temporary" && pwd -P)
cleanup() { rm -rf -- "$temporary"; }
trap cleanup EXIT HUP INT TERM
go run ./cmd/himesan-release extract-macos \
--archive "$archive" --sha256 "$archive_sha256" --output "$temporary"
root=$(find "$temporary" -mindepth 1 -maxdepth 1 -type d -print -quit)
binary="$root/himesan"
[[ -x "$binary" ]] || { printf 'error: archive does not contain executable himesan\n' >&2; exit 1; }
codesign --force --options runtime --timestamp \
--identifier "$identifier" --sign "$identity" "$binary"
codesign --verify --strict --verbose=2 "$binary"
go run ./cmd/himesan-release finalize-macos \
--directory "$root" \
--unsigned-archive-sha256 "$archive_sha256" \
--identity "$identity" --identifier "$identifier" \
--finalized-at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
codesign --verify --strict --verbose=2 "$binary"
mkdir -p -- "$output"
version=$(basename "$root")
dmg="$output/$version.dmg"
if [[ -e "$dmg" || -e "$dmg.sha256" ]]; then
printf 'error: signed distribution output already exists: %s\n' "$dmg" >&2
exit 1
fi
hdiutil create -quiet -fs HFS+ -format UDZO -volname "$version" -srcfolder "$root" "$dmg"
codesign --force --timestamp --sign "$identity" "$dmg"
codesign --verify --strict --verbose=2 "$dmg"
xcrun notarytool submit "$dmg" --keychain-profile "$profile" --wait
xcrun stapler staple "$dmg"
xcrun stapler validate "$dmg"
codesign --verify --strict --verbose=2 "$dmg"
spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg"
dmg_sha256=$(shasum -a 256 "$dmg" | awk '{print $1}')
printf '%s %s\n' "$dmg_sha256" "$(basename "$dmg")" >"$dmg.sha256"
chmod 0444 "$dmg" "$dmg.sha256"
printf 'Signed, notarized, and stapled distribution: %s\n' "$dmg"
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
# shellcheck source=public-snapshot-lib.sh
source "$project_root/scripts/public-snapshot-lib.sh"
exporter=$project_root/scripts/export-public-snapshot.sh
temporary=$(mktemp -d)
cleanup() {
rm -rf -- "$temporary"
}
trap cleanup EXIT
bash -n "$project_root/scripts/public-snapshot-lib.sh" "$exporter"
if grep -Eq 'git[[:space:]]+(init|commit|tag|push|remote)([[:space:]]|$)' "$exporter"; then
echo "exporter contains a forbidden Git mutation command" >&2
exit 1
fi
for required in COPYRIGHT OUTPUT_EXCEPTION.md sando/COPYRIGHT; do
grep -Fxq "$required" "$project_root/scripts/public-snapshot.allow" || {
echo "required legal boundary is absent from public allowlist: $required" >&2
exit 1
}
done
snapshot_status_is_clean ""
if snapshot_status_is_clean " M reviewed.go"; then
echo "dirty status was accepted" >&2
exit 1
fi
snapshot_path_is_at_or_below /var/tmp / || {
echo "filesystem-root boundary did not contain an absolute path" >&2
exit 1
}
if snapshot_path_is_at_or_below /safe-ish /safe; then
echo "path boundary accepted a sibling prefix" >&2
exit 1
fi
for path in .gitea/workflows/verify.yml .github/workflows/verify.yml private/notes.md \
internal/integration/product/test.go go.work build/output.exe data/private.db history/prototype.go; do
if ! snapshot_forbidden_path "$path"; then
echo "private/build path was not rejected: $path" >&2
exit 1
fi
done
snapshot_forbidden_path README.md && { echo "safe path was rejected" >&2; exit 1; }
# Exercise the scanner against the complete proposed policy, including these
# uncommitted exporter files, so the next clean commit cannot reveal a
# self-triggering detector or a missing reviewed path.
proposed_tree=$temporary/proposed
mkdir -p "$proposed_tree"
while IFS= read -r line || [[ -n $line ]]; do
[[ -n $line && ${line:0:1} != '#' ]] || continue
[[ -f $project_root/$line && ! -L $project_root/$line ]] || {
echo "reviewed allowlist path is missing or not regular: $line" >&2
exit 1
}
mkdir -p "$proposed_tree/$(dirname -- "$line")"
cp -p -- "$project_root/$line" "$proposed_tree/$line"
done <"$project_root/scripts/public-snapshot.allow"
snapshot_validate_export_tree "$proposed_tree"
(cd "$proposed_tree" && bash scripts/check-licenses.sh)
private_home_pattern='/'home'/'cole
private_commit_pattern='80bed136''75e8'
private_tag_pattern='prototype-''2025'
if LC_ALL=C grep -IRq -e "$private_home_pattern" -e "$private_commit_pattern" -e "$private_tag_pattern" "$proposed_tree"; then
echo "public allowlist contains a private path or history identifier" >&2
exit 1
fi
safe_tree=$temporary/safe
mkdir -p "$safe_tree"
printf 'ordinary reviewed source\n' >"$safe_tree/source.go"
snapshot_validate_export_tree "$safe_tree"
empty_tree=$temporary/empty
mkdir -p "$empty_tree"
: >"$empty_tree/empty.txt"
snapshot_validate_export_tree "$empty_tree"
symlink_tree=$temporary/symlink
mkdir -p "$symlink_tree"
printf 'target\n' >"$symlink_tree/target"
ln -s target "$symlink_tree/link"
if snapshot_validate_export_tree "$symlink_tree" >/dev/null 2>&1; then
echo "symlink tree was accepted" >&2
exit 1
fi
oversized_tree=$temporary/oversized
mkdir -p "$oversized_tree"
printf '123456789\n' >"$oversized_tree/large.txt"
if SNAPSHOT_MAX_FILE_BYTES=8 snapshot_validate_export_tree "$oversized_tree" >/dev/null 2>&1; then
echo "oversized file was accepted" >&2
exit 1
fi
binary_tree=$temporary/binary
mkdir -p "$binary_tree"
printf 'text\000binary\n' >"$binary_tree/blob.dat"
if snapshot_validate_export_tree "$binary_tree" >/dev/null 2>&1; then
echo "binary file was accepted" >&2
exit 1
fi
private_tree=$temporary/private
mkdir -p "$private_tree"
printf '/%s/%s/project/private.db\n' home developer >"$private_tree/path.txt"
if snapshot_validate_export_tree "$private_tree" >/dev/null 2>&1; then
echo "private filesystem path was accepted" >&2
exit 1
fi
private_commit_tree=$temporary/private-commit
mkdir -p "$private_commit_tree"
printf 'Private development source: %040d\n' 0 >"$private_commit_tree/ledger.txt"
if snapshot_validate_export_tree "$private_commit_tree" >/dev/null 2>&1; then
echo "private commit identifier was accepted" >&2
exit 1
fi
private_repository_tree=$temporary/private-repository
mkdir -p "$private_repository_tree"
printf 'gamertan/%s%s\n' 'sandwich-hime-' 'dev' >"$private_repository_tree/source.txt"
if snapshot_validate_export_tree "$private_repository_tree" >/dev/null 2>&1; then
echo "private repository identifier was accepted" >&2
exit 1
fi
credential_tree=$temporary/credential
mkdir -p "$credential_tree"
printf '%s%s\n' '-----BEGIN ' 'PRIVATE KEY-----' >"$credential_tree/secret.txt"
if snapshot_validate_export_tree "$credential_tree" >/dev/null 2>&1; then
echo "private key indicator was accepted" >&2
exit 1
fi
# Until these new exporter files themselves are committed, construct a
# review-only policy containing the intersection of the reviewed policy and
# the selected committed source ref. No repository or Git object is mutated.
review_policy=$temporary/review.allow
while IFS= read -r line || [[ -n $line ]]; do
[[ -n $line && ${line:0:1} != '#' ]] || continue
if git -C "$project_root" cat-file -e "HEAD:$line" 2>/dev/null &&
git -C "$project_root" diff --quiet HEAD -- "$line"; then
printf '%s\n' "$line" >>"$review_policy"
fi
done <"$project_root/scripts/public-snapshot.allow"
# A destination beneath the source worktree (including .git) must fail before
# staging creation. This test never removes anything from the source tree.
inside_name=himesan-export-must-not-exist-$$
inside_destination=$project_root/.git/$inside_name
[[ ! -e $inside_destination && ! -L $inside_destination ]] || {
echo "in-worktree destination unexpectedly exists before test" >&2
exit 1
}
if "$exporter" --source "$project_root" --ref HEAD --mode review \
--allowlist "$review_policy" --destination "$inside_destination" >/dev/null 2>&1; then
echo "exporter accepted a destination inside the source worktree" >&2
exit 1
fi
[[ ! -e $inside_destination && ! -L $inside_destination ]] || {
echo "failed in-worktree export created its destination" >&2
exit 1
}
if find "$project_root/.git" -maxdepth 1 -name ".${inside_name}.himesan-public-export.*" -print -quit | grep -q .; then
echo "failed in-worktree export created a staging directory" >&2
exit 1
fi
# A linked worktree stores its private Git directory and shared common Git
# directory outside that worktree root. Neither metadata location may become
# an export destination. The isolated repositories live entirely in $temporary.
linked_main=$temporary/linked-main
linked_worktree=$temporary/linked-worktree
git init -q "$linked_main"
printf 'reviewed linked-worktree source\n' >"$linked_main/source.go"
git -C "$linked_main" add source.go
git -C "$linked_main" -c user.name='Snapshot Test' -c user.email='snapshot@example.invalid' \
commit -qm 'seed isolated exporter test'
git -C "$linked_main" worktree add -q --detach "$linked_worktree" HEAD
linked_policy=$temporary/linked.allow
printf 'source.go\n' >"$linked_policy"
linked_git_dir=$(git -C "$linked_worktree" rev-parse --absolute-git-dir)
linked_common_dir=$(git -C "$linked_worktree" rev-parse --git-common-dir)
if [[ $linked_common_dir != /* ]]; then
linked_common_dir=$linked_worktree/$linked_common_dir
fi
linked_common_dir=$(snapshot_realpath_existing "$linked_common_dir")
assert_metadata_destination_rejected() {
local label=$1
local parent=$2
local name=$3
local rejected_destination=$parent/$name
[[ ! -e $rejected_destination && ! -L $rejected_destination ]] || {
echo "$label destination unexpectedly exists before test" >&2
exit 1
}
if "$exporter" --source "$linked_worktree" --ref HEAD --mode review \
--allowlist "$linked_policy" --destination "$rejected_destination" >/dev/null 2>&1; then
echo "exporter accepted destination inside $label" >&2
exit 1
fi
[[ ! -e $rejected_destination && ! -L $rejected_destination ]] || {
echo "failed $label export created its destination" >&2
exit 1
}
if find "$parent" -maxdepth 1 -name ".${name}.himesan-public-export.*" -print -quit | grep -q .; then
echo "failed $label export created a staging directory" >&2
exit 1
fi
}
assert_metadata_destination_rejected 'linked-worktree Git directory' "$linked_git_dir" linked-private-destination
assert_metadata_destination_rejected 'shared Git common directory' "$linked_common_dir" linked-common-destination
first=$temporary/public-one
second=$temporary/public-two
"$exporter" --source "$project_root" --ref HEAD --mode review \
--allowlist "$review_policy" --destination "$first" >/dev/null
"$exporter" --source "$project_root" --ref HEAD --mode review \
--allowlist "$review_policy" --destination "$second" >/dev/null
diff -r --no-dereference "$first" "$second" >/dev/null
(cd "$first" && sha256sum -c PUBLIC-SNAPSHOT.sha256 >/dev/null)
for excluded in .git .gitea .github private history internal/integration go.work; do
[[ ! -e $first/$excluded && ! -L $first/$excluded ]] || {
echo "excluded path reached snapshot: $excluded" >&2
exit 1
}
done
[[ -f $first/sando/component.go ]] || { echo "reviewed runtime source was not exported" >&2; exit 1; }
[[ -f $first/PUBLIC-SNAPSHOT.json && -f $first/PUBLIC-SNAPSHOT.sha256 ]] || {
echo "public provenance files are missing" >&2
exit 1
}
grep -q '"schema_version":2' "$first/PUBLIC-SNAPSHOT.json"
grep -q '"export_mode":"review"' "$first/PUBLIC-SNAPSHOT.json"
if grep -Fq "$project_root" "$first/PUBLIC-SNAPSHOT.json" \
|| grep -Fq "$project_root" "$first/PUBLIC-SNAPSHOT.sha256" \
|| grep -q '@' "$first/PUBLIC-SNAPSHOT.json" \
|| grep -Eq '"(commit|tree|source_date_epoch)"' "$first/PUBLIC-SNAPSHOT.json"; then
echo "public provenance exposed a checkout path or email" >&2
exit 1
fi
occupied=$temporary/occupied
mkdir -p "$occupied"
printf 'do not delete\n' >"$occupied/owner-marker"
if "$exporter" --source "$project_root" --ref HEAD --mode review \
--allowlist "$review_policy" --destination "$occupied" >/dev/null 2>&1; then
echo "exporter accepted an existing destination" >&2
exit 1
fi
grep -q 'do not delete' "$occupied/owner-marker"
status=$(git -C "$project_root" status --porcelain=v1 --untracked-files=all)
release_destination=$temporary/release
if snapshot_status_is_clean "$status"; then
"$exporter" --source "$project_root" --ref HEAD --mode release \
--destination "$release_destination" >/dev/null
else
if "$exporter" --source "$project_root" --ref HEAD --mode release \
--destination "$release_destination" >"$temporary/release.log" 2>&1; then
echo "release export accepted a dirty source" >&2
exit 1
fi
grep -q 'release source worktree is dirty' "$temporary/release.log"
[[ ! -e $release_destination ]] || { echo "failed release created a destination" >&2; exit 1; }
fi
echo "public snapshot export checks passed"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
temporary=$(mktemp -d "${TMPDIR:-/tmp}/himesan-consumer.XXXXXXXX")
temporary=$(CDPATH= cd -- "$temporary" && pwd -P)
cleanup() { rm -rf -- "$temporary"; }
trap cleanup EXIT HUP INT TERM
mkdir -p "$temporary/golden"
cp "$repo_root/internal/compiler/testdata/golden/basic.sando" "$temporary/golden/page.sando"
GOTOOLCHAIN=local go build -trimpath -o "$temporary/himesan" "$repo_root/cmd/himesan"
"$temporary/himesan" generate "$temporary/golden/page.sando"
(
cd "$temporary"
GOTOOLCHAIN=local go mod init example.test/himesan-consumer
GOTOOLCHAIN=local go mod edit -go=1.25
GOTOOLCHAIN=local go mod edit -replace=gamertan.com/sandwich-hime/sando="$repo_root/sando"
GOTOOLCHAIN=local go mod tidy
GOTOOLCHAIN=local go test ./...
)
printf 'Temporary Go 1.25 consumer compiled generated output successfully.\n'
+25 -23
View File
@@ -101,8 +101,10 @@ if [[ "$browser_status" != 404 ]]; then
fi
scratch_dir=$(mktemp -d "${TMPDIR:-/tmp}/himesan-public-install.XXXXXXXX")
scratch_dir=$(CDPATH= cd -- "$scratch_dir" && pwd -P)
cleanup() {
if [[ -n "${scratch_dir:-}" && -d "$scratch_dir" ]]; then
chmod -R u+w -- "$scratch_dir" 2>/dev/null || true
rm -rf -- "$scratch_dir"
fi
}
@@ -116,7 +118,29 @@ run_install_pair() {
local installed_binary installed_version go_executable_suffix
mkdir -p "$mode_dir/gopath" "$mode_dir/modcache" "$mode_dir/buildcache" "$mode_dir/consumer"
printf '\n==> %s clean-cache install\n' "$mode"
printf '\n==> %s clean-cache runtime then compiler install\n' "$mode"
(
cd "$mode_dir/consumer"
go mod init example.invalid/himesan-public-install >/dev/null
env \
GIT_TERMINAL_PROMPT=0 \
GIT_CONFIG_NOSYSTEM=1 \
GIT_CONFIG_GLOBAL=/dev/null \
GIT_ASKPASS="$false_command" \
SSH_ASKPASS="$false_command" \
GOPATH="$mode_dir/gopath" \
GOMODCACHE="$mode_dir/modcache" \
GOCACHE="$mode_dir/buildcache" \
GOPROXY="$proxy" \
GOPRIVATE= \
GONOPROXY=none \
GONOSUMDB="$no_sum_db" \
GOSUMDB=sum.golang.org \
GOINSECURE= \
GOAUTH=off \
go get "gamertan.com/sandwich-hime/sando@$version"
)
env \
GIT_TERMINAL_PROMPT=0 \
GIT_CONFIG_NOSYSTEM=1 \
@@ -154,28 +178,6 @@ EOF
printf 'error: generated provenance did not record installed compiler version %s\n' "$version" >&2
exit 1
fi
(
cd "$mode_dir/consumer"
go mod init example.invalid/himesan-public-install >/dev/null
env \
GIT_TERMINAL_PROMPT=0 \
GIT_CONFIG_NOSYSTEM=1 \
GIT_CONFIG_GLOBAL=/dev/null \
GIT_ASKPASS="$false_command" \
SSH_ASKPASS="$false_command" \
GOPATH="$mode_dir/gopath" \
GOMODCACHE="$mode_dir/modcache" \
GOCACHE="$mode_dir/buildcache" \
GOPROXY="$proxy" \
GOPRIVATE= \
GONOPROXY=none \
GONOSUMDB="$no_sum_db" \
GOSUMDB=sum.golang.org \
GOINSECURE= \
GOAUTH=off \
go get "gamertan.com/sandwich-hime/sando@$version"
)
}
run_install_pair direct direct gamertan.com/sandwich-hime
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$repo_root"
chrome=${HIMESAN_CHROME:-}
if [[ -z "$chrome" ]]; then
case "$(uname -s)" in
Darwin) chrome='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' ;;
Linux)
for candidate in google-chrome-stable google-chrome chromium chromium-browser; do
if command -v "$candidate" >/dev/null 2>&1; then
chrome=$(command -v "$candidate")
break
fi
done
;;
esac
fi
if [[ -z "$chrome" || ! -f "$chrome" || ! -x "$chrome" ]]; then
printf 'error: set HIMESAN_CHROME to a reviewed Chrome or Chromium executable\n' >&2
exit 1
fi
printf 'Real-browser executable: '
"$chrome" --version
printf 'Go toolchain: '
go version
HIMESAN_CHROME="$chrome" go test -count=1 -tags=himesan_browser_evidence \
./internal/devserver -run '^TestRealBrowserDevelopmentClient$' -v
go test -count=1 ./internal/devserver \
-run '^(TestSupervisorBuildsSwapsAndCleansUp|TestSupervisorClearsTargetWhenCurrentApplicationExits)$' -v
-133
View File
@@ -1,133 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
[CmdletBinding()]
param(
[switch]$Race
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location $RepoRoot
function Invoke-Checked {
param(
[Parameter(Mandatory = $true)]
[string]$Label,
[Parameter(Mandatory = $true)]
[scriptblock]$Command
)
Write-Host "`n==> $Label"
& $Command
if ($LASTEXITCODE -ne 0) {
throw "$Label failed with exit code $LASTEXITCODE"
}
}
function Invoke-ModuleChecks {
param(
[Parameter(Mandatory = $true)]
[string]$Directory,
[Parameter(Mandatory = $true)]
[string]$Label
)
Push-Location $Directory
try {
Invoke-Checked "$Label`: go test" { go test ./... }
Invoke-Checked "$Label`: go vet" { go vet ./... }
}
finally {
Pop-Location
}
}
function Get-SandoSources {
if (-not (Test-Path "internal/compiler/testdata/golden" -PathType Container)) {
return @()
}
return @(Get-ChildItem "internal/compiler/testdata/golden" -File -Filter "*.sando" |
Sort-Object FullName)
}
function Get-GeneratedManifest {
$lines = foreach ($source in (Get-SandoSources)) {
$output = "$($source.FullName).go"
if (-not (Test-Path $output -PathType Leaf)) {
"missing $output"
continue
}
$hash = (Get-FileHash -Algorithm SHA256 $output).Hash.ToLowerInvariant()
$modified = (Get-Item -LiteralPath $output).LastWriteTimeUtc.Ticks
"$hash $modified $output"
}
return ($lines -join "`n")
}
$TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("himesan-verify-" + [guid]::NewGuid())
New-Item -ItemType Directory -Path $TempRoot | Out-Null
try {
Invoke-ModuleChecks "." "compiler module"
Invoke-Checked "compiler module: go build" {
go build -trimpath -o (Join-Path $TempRoot "himesan.exe") ./cmd/himesan
}
if (-not (Test-Path "sando/go.mod" -PathType Leaf)) {
throw "nested Apache runtime module sando/go.mod is missing"
}
Invoke-ModuleChecks "sando" "sando runtime module"
$Sources = @(Get-SandoSources)
if ($Sources.Count -eq 0) {
throw "compiler-owned golden .sando fixture is missing"
}
else {
$SourcePaths = @($Sources | ForEach-Object { $_.FullName })
$CheckArgs = @("run", "./cmd/himesan", "check") + $SourcePaths
$GenerateArgs = @("run", "./cmd/himesan", "generate") + $SourcePaths
Invoke-Checked "golden generation: read-only freshness check" {
& go $CheckArgs
}
$Before = Get-GeneratedManifest
Invoke-Checked "golden generation: first deterministic pass" {
& go $GenerateArgs
}
$First = Get-GeneratedManifest
if ($Before -cne $First) {
throw "generation changed committed output after check declared it fresh"
}
Invoke-Checked "golden generation: second deterministic pass" {
& go $GenerateArgs
}
$Second = Get-GeneratedManifest
if ($First -cne $Second) {
throw "repeated generation changed output bytes or an unchanged timestamp"
}
Invoke-Checked "golden generation: final freshness check" {
& go $CheckArgs
}
}
if ($Race) {
Invoke-Checked "compiler module: race tests" { go test -race ./... }
Push-Location "sando"
try {
Invoke-Checked "sando runtime module: race tests" { go test -race ./... }
}
finally {
Pop-Location
}
}
Write-Host "`n==> verification complete"
}
finally {
if (Test-Path $TempRoot -PathType Container) {
Remove-Item -LiteralPath $TempRoot -Recurse -Force
}
}
+1
View File
@@ -65,6 +65,7 @@ run_module_checks . "compiler module"
log "compiler module: go build"
build_dir=$(mktemp -d "${TMPDIR:-/tmp}/himesan-verify.XXXXXXXX")
build_dir=$(CDPATH= cd -- "$build_dir" && pwd -P)
cleanup() {
if [[ -n "${build_dir:-}" && -d "$build_dir" ]]; then
rm -rf -- "$build_dir"