Compare commits
7
Commits
v1.0.0-beta.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e730dd1b56 | ||
|
|
efd5b997ed | ||
|
|
c11552b87a | ||
|
|
4fef65f9b0 | ||
|
|
1082d9d61e | ||
|
|
e3a94fd16b | ||
|
|
532724baf7 |
+83
-9
@@ -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
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
{"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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -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":
|
||||
@@ -119,7 +122,8 @@ func runVersion(args []string, stdout, stderr io.Writer) int {
|
||||
Compiler string `json:"compiler"`
|
||||
RuntimeABI string `json:"runtime_abi"`
|
||||
Go string `json:"go"`
|
||||
}{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version()}
|
||||
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.")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.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)
|
||||
}
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, source string) {
|
||||
_, _ = Compile("fuzz.sando", []byte(source))
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
@@ -69,6 +69,7 @@ type sourceFile struct {
|
||||
Nodes []rendererNode
|
||||
Source []byte
|
||||
HeaderEnd int
|
||||
FunctionPos sourcePosition
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ func parseSource(path string, source []byte) (*sourceFile, []Diagnostic) {
|
||||
Imports: parsedHeader.Imports,
|
||||
Source: source,
|
||||
HeaderEnd: headerClose + 2,
|
||||
FunctionPos: parsedHeader.FunctionPos,
|
||||
AST: parsedHeader.AST,
|
||||
}
|
||||
|
||||
@@ -144,6 +145,7 @@ type parsedHeader struct {
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
FunctionPos sourcePosition
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
@@ -259,6 +261,7 @@ func parseHeader(path string, declarations []byte, sourceOffset int, table posit
|
||||
TypeParams: typeParams,
|
||||
Params: params,
|
||||
Imports: imports,
|
||||
FunctionPos: table.at(sourceOffset + functionPosition.Offset),
|
||||
AST: parsed,
|
||||
}, diagnostics
|
||||
}
|
||||
|
||||
@@ -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"><unsafe> & "quoted"</h1>`,
|
||||
`<textarea id="note"></textarea><script id="attacker">window.evidenceFailed=true</script></textarea>`,
|
||||
`<a id="destination" href="/safe?q=a&b=c">Open</a>`,
|
||||
`<table id="table"><tbody><tr><td id="cell"><unsafe> & "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]"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 "":
|
||||
|
||||
@@ -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 },
|
||||
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,
|
||||
StartupTimeout: 750 * time.Millisecond,
|
||||
// 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)
|
||||
}
|
||||
@@ -115,12 +125,15 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
|
||||
RootDir: root,
|
||||
Config: cfg,
|
||||
Generate: func(context.Context) error { return nil },
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,4 +16,7 @@ type ComponentFunc func(context.Context, io.Writer) error
|
||||
|
||||
`Render` invokes components; generated code uses the context-specific writer helpers and checks `ABI`. Opaque `TrustedHTML`, `TrustedURL`, `TrustedJS`, and `TrustedCSS` values can be constructed only by conspicuous `Trust*` calls in trusted application code.
|
||||
|
||||
The exact exported v1 symbol, value, method, field, and signature inventory is
|
||||
machine-checked in [`testdata/public-api-v1.txt`](testdata/public-api-v1.txt).
|
||||
|
||||
The runtime has its own `go.mod`, Apache-2.0 license, `COPYRIGHT` record, semantic version, and `sando/vX.Y.Z` tags. It never imports the AGPL compiler. Importing the runtime does not make an application AGPL under the project's terms. Applications may license their own code and generated files under terms they choose to the extent they hold the necessary rights, while redistribution of the runtime remains subject to Apache-2.0.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/importer"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestV1PublicAPIContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fileSet := token.NewFileSet()
|
||||
var files []*ast.File
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(fileSet, entry.Name(), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", entry.Name(), err)
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
configuration := types.Config{Importer: importer.Default()}
|
||||
checked, err := configuration.Check("gamertan.com/sandwich-hime/sando", fileSet, files, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
qualifier := func(pkg *types.Package) string {
|
||||
if pkg == nil || pkg.Path() == checked.Path() {
|
||||
return ""
|
||||
}
|
||||
return pkg.Name()
|
||||
}
|
||||
var actual []string
|
||||
for _, name := range checked.Scope().Names() {
|
||||
if !token.IsExported(name) {
|
||||
continue
|
||||
}
|
||||
object := checked.Scope().Lookup(name)
|
||||
switch object := object.(type) {
|
||||
case *types.Const:
|
||||
actual = append(actual, fmt.Sprintf("const %s = %s", object.Name(), object.Val().ExactString()))
|
||||
case *types.TypeName:
|
||||
named, ok := object.Type().(*types.Named)
|
||||
if !ok {
|
||||
actual = append(actual, types.ObjectString(object, qualifier))
|
||||
break
|
||||
}
|
||||
if structure, ok := named.Underlying().(*types.Struct); ok {
|
||||
var fields []string
|
||||
for index := 0; index < structure.NumFields(); index++ {
|
||||
field := structure.Field(index)
|
||||
if field.Exported() {
|
||||
fields = append(fields, field.Name()+" "+types.TypeString(field.Type(), qualifier))
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
actual = append(actual, "type "+object.Name()+" struct{ /* opaque */ }")
|
||||
} else {
|
||||
actual = append(actual, "type "+object.Name()+" struct{"+strings.Join(fields, "; ")+"}")
|
||||
}
|
||||
} else {
|
||||
actual = append(actual, types.ObjectString(object, qualifier))
|
||||
}
|
||||
default:
|
||||
actual = append(actual, types.ObjectString(object, qualifier))
|
||||
}
|
||||
typeName, ok := object.(*types.TypeName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
named, ok := typeName.Type().(*types.Named)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for index := 0; index < named.NumMethods(); index++ {
|
||||
method := named.Method(index)
|
||||
if method.Exported() {
|
||||
actual = append(actual, types.ObjectString(method, qualifier))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(actual)
|
||||
got := strings.Join(actual, "\n") + "\n"
|
||||
want, err := os.ReadFile("testdata/public-api-v1.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = []byte(strings.TrimPrefix(string(want), "# SPDX-License-Identifier: Apache-2.0\n\n"))
|
||||
if string(want) != got {
|
||||
t.Fatalf("v1 public API drifted\n--- committed contract ---\n%s--- observed API ---\n%s", want, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"html/template"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type benchmarkView struct {
|
||||
Title string
|
||||
URL string
|
||||
Admin bool
|
||||
Items []string
|
||||
}
|
||||
|
||||
var (
|
||||
benchmarkContext = context.Background()
|
||||
benchmarkData = benchmarkView{
|
||||
Title: `A typed <view> & its "output"`,
|
||||
URL: "/projects/sandwich-hime/?from=benchmark&mode=equivalent",
|
||||
Admin: true,
|
||||
Items: []string{"compiler", "runtime", "language server", "editor tooling"},
|
||||
}
|
||||
benchmarkHTMLTemplate = template.Must(template.New("v1-corpus").Parse(`<article data-title="{{.Title}}"><h1>{{.Title}}</h1>{{if .Admin}}<strong>Admin</strong>{{end}}<ul>{{range .Items}}<li>{{.}}</li>{{end}}</ul><a href="{{.URL}}">Open</a></article>`))
|
||||
)
|
||||
|
||||
func benchmarkSandoComponent(view benchmarkView) Component {
|
||||
return ComponentFunc(func(_ context.Context, writer io.Writer) error {
|
||||
if err := WriteString(writer, `<article data-title="`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteAttr(writer, view.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteString(writer, `"><h1>`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteText(writer, view.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteString(writer, `</h1>`); err != nil {
|
||||
return err
|
||||
}
|
||||
if view.Admin {
|
||||
if err := WriteString(writer, `<strong>Admin</strong>`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := WriteString(writer, `<ul>`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range view.Items {
|
||||
if err := WriteString(writer, `<li>`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteText(writer, item); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteString(writer, `</li>`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := WriteString(writer, `</ul><a href="`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteURL(writer, view.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteString(writer, `">Open</a></article>`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBenchmarkCorpusEquivalent(t *testing.T) {
|
||||
t.Parallel()
|
||||
var himeOutput bytes.Buffer
|
||||
if err := Render(benchmarkContext, &himeOutput, benchmarkSandoComponent(benchmarkData)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var standardOutput bytes.Buffer
|
||||
if err := benchmarkHTMLTemplate.Execute(&standardOutput, benchmarkData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if himeOutput.String() != standardOutput.String() {
|
||||
t.Fatalf("benchmark corpus is not output-equivalent\nhtml/template: %q\nSandwich Hime: %q", standardOutput.String(), himeOutput.String())
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkV1CorpusSandwichHime(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(renderedBenchmarkSize(b)))
|
||||
for b.Loop() {
|
||||
if err := Render(benchmarkContext, io.Discard, benchmarkSandoComponent(benchmarkData)); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkV1CorpusHTMLTemplate(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(renderedBenchmarkSize(b)))
|
||||
for b.Loop() {
|
||||
if err := benchmarkHTMLTemplate.Execute(io.Discard, benchmarkData); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderedBenchmarkSize(tb testing.TB) int {
|
||||
tb.Helper()
|
||||
var output bytes.Buffer
|
||||
if err := Render(benchmarkContext, &output, benchmarkSandoComponent(benchmarkData)); err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
return output.Len()
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestHTMLTemplateDifferentialCorpus(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
prefix string
|
||||
suffix string
|
||||
write func(*bytes.Buffer, any) error
|
||||
values []string
|
||||
}{
|
||||
{
|
||||
name: "HTML text", source: `<p>{{.}}</p>`, prefix: `<p>`, suffix: `</p>`,
|
||||
write: func(output *bytes.Buffer, value any) error { return WriteText(output, value) },
|
||||
values: differentialTextValues(),
|
||||
},
|
||||
{
|
||||
name: "quoted attribute", source: `<p title="{{.}}">x</p>`, prefix: `<p title="`, suffix: `">x</p>`,
|
||||
write: func(output *bytes.Buffer, value any) error { return WriteAttr(output, value) },
|
||||
values: differentialTextValues(),
|
||||
},
|
||||
{
|
||||
name: "RCDATA", source: `<textarea>{{.}}</textarea>`, prefix: `<textarea>`, suffix: `</textarea>`,
|
||||
write: func(output *bytes.Buffer, value any) error { return WriteRCDATA(output, value) },
|
||||
values: differentialTextValues(),
|
||||
},
|
||||
{
|
||||
name: "safe URL", source: `<a href="{{.}}">x</a>`, prefix: `<a href="`, suffix: `">x</a>`,
|
||||
write: func(output *bytes.Buffer, value any) error { return WriteURL(output, value) },
|
||||
values: []string{"", "/", "./relative", "?q=a&next=b", "#section", "https://example.test/a?x=1&y=2", "HTTP://example.test/", "mailto:reader@example.test"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed := template.Must(template.New(test.name).Parse(test.source))
|
||||
for _, value := range test.values {
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatalf("html/template value %q: %v", value, err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
output.WriteString(test.prefix)
|
||||
if err := test.write(&output, value); err != nil {
|
||||
t.Fatalf("Sandwich Hime value %q: %v", value, err)
|
||||
}
|
||||
output.WriteString(test.suffix)
|
||||
if output.String() != baseline.String() {
|
||||
t.Fatalf("differential mismatch for %q\nhtml/template: %q\nSandwich Hime: %q", value, baseline.String(), output.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTemplateDifferentialUnsafeURLPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed := template.Must(template.New("url").Parse(`<a href="{{.}}">x</a>`))
|
||||
values := []string{
|
||||
"javascript:alert(1)",
|
||||
" JAVASCRIPT:alert(1) ",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
"unknown:opaque",
|
||||
"java%73cript:alert(1)",
|
||||
}
|
||||
for _, value := range values {
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatalf("html/template value %q: %v", value, err)
|
||||
}
|
||||
if !strings.Contains(baseline.String(), "#ZgotmplZ") {
|
||||
t.Fatalf("html/template did not block corpus URL %q: %q", value, baseline.String())
|
||||
}
|
||||
var output bytes.Buffer
|
||||
err := WriteURL(&output, value)
|
||||
if !errors.Is(err, ErrUnsafeURL) {
|
||||
t.Fatalf("Sandwich Hime accepted corpus URL %q: output=%q err=%v", value, output.String(), err)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("Sandwich Hime wrote bytes before rejecting %q: %q", value, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTemplateDifferentialDocumentedStrictness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invalid UTF-8", func(t *testing.T) {
|
||||
value := "invalid UTF-8: \xff:end"
|
||||
parsed := template.Must(template.New("text").Parse(`<p>{{.}}</p>`))
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if utf8.Valid(baseline.Bytes()) {
|
||||
t.Fatalf("baseline unexpectedly normalized invalid UTF-8: %q", baseline.Bytes())
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := WriteText(&output, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !utf8.Valid(output.Bytes()) || !strings.Contains(output.String(), "\uFFFD") {
|
||||
t.Fatalf("Sandwich Hime did not normalize invalid UTF-8: %q", output.Bytes())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("control in otherwise allowed URL", func(t *testing.T) {
|
||||
value := "https:\n//example.test/"
|
||||
parsed := template.Must(template.New("url").Parse(`<a href="{{.}}">x</a>`))
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(baseline.String()), "%0a") {
|
||||
t.Fatalf("baseline did not visibly encode the control: %q", baseline.String())
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := WriteURL(&output, value); !errors.Is(err, ErrUnsafeURL) {
|
||||
t.Fatalf("Sandwich Hime did not fail closed: output=%q err=%v", output.String(), err)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("Sandwich Hime wrote before rejecting the control: %q", output.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit tel allowlist", func(t *testing.T) {
|
||||
value := "tel:+15555550100"
|
||||
parsed := template.Must(template.New("url").Parse(`<a href="{{.}}">x</a>`))
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(baseline.String(), "#ZgotmplZ") {
|
||||
t.Fatalf("baseline URL policy changed: %q", baseline.String())
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := WriteURL(&output, value); err != nil {
|
||||
t.Fatalf("Sandwich Hime rejected its documented tel scheme: %v", err)
|
||||
}
|
||||
if output.String() != value {
|
||||
t.Fatalf("Sandwich Hime tel output = %q", output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTMLTemplateDifferentialExplicitTrustedHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
value := `<strong data-note="reviewed & trusted">ok</strong>`
|
||||
parsed := template.Must(template.New("trusted HTML").Parse(`<div>{{.}}</div>`))
|
||||
var baseline bytes.Buffer
|
||||
if err := parsed.Execute(&baseline, template.HTML(value)); err != nil { // #nosec G203 -- the test is the explicit trust-boundary comparison.
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
output.WriteString("<div>")
|
||||
if err := WriteText(&output, TrustHTML(value)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output.WriteString("</div>")
|
||||
if output.String() != baseline.String() {
|
||||
t.Fatalf("trusted HTML mismatch\nhtml/template: %q\nSandwich Hime: %q", baseline.String(), output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func differentialTextValues() []string {
|
||||
return []string{
|
||||
"",
|
||||
"ordinary text",
|
||||
`<script>alert("x")</script>`,
|
||||
`quotes: "double" and 'single' & ampersand`,
|
||||
"Unicode: 雪 🥪 e\u0301",
|
||||
"NUL:\x00:end",
|
||||
"line separators: \u2028\u2029",
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzWriteURLPolicy(f *testing.F) {
|
||||
for _, seed := range []string{
|
||||
"",
|
||||
"/relative?one=1&two=2",
|
||||
"https://example.test/path",
|
||||
" JAVASCRIPT:alert(1) ",
|
||||
"https:\n//example.test/",
|
||||
"tel:+15555550100",
|
||||
"invalid:\xff",
|
||||
} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, value string) {
|
||||
if len(value) > 64<<10 {
|
||||
t.Skip()
|
||||
}
|
||||
var first, second bytes.Buffer
|
||||
firstErr := WriteURL(&first, value)
|
||||
secondErr := WriteURL(&second, value)
|
||||
if first.String() != second.String() || fmt.Sprint(firstErr) != fmt.Sprint(secondErr) {
|
||||
t.Fatal("URL policy was not deterministic")
|
||||
}
|
||||
if firstErr != nil {
|
||||
if !errors.Is(firstErr, ErrUnsafeURL) || first.Len() != 0 {
|
||||
t.Fatalf("URL rejection was not fail-closed: output=%q err=%v", first.String(), firstErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !utf8.ValidString(first.String()) || strings.ContainsAny(first.String(), "\x00\r\n") {
|
||||
t.Fatalf("accepted URL output is not valid single-line UTF-8: %q", first.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
const ABI = "sando.v1"
|
||||
const ABISandoV1 = "sando.v1"
|
||||
const RuntimeABI = "sando.v1"
|
||||
func (*URLSafetyError).Error() string
|
||||
func (*URLSafetyError).Unwrap() error
|
||||
func (ComponentFunc).Render(ctx context.Context, w io.Writer) error
|
||||
func Render(ctx context.Context, w io.Writer, component Component) error
|
||||
func TrustCSS(value string) TrustedCSS
|
||||
func TrustHTML(value string) TrustedHTML
|
||||
func TrustJS(value string) TrustedJS
|
||||
func TrustURL(value string) TrustedURL
|
||||
func WriteAttr(w io.Writer, value any) error
|
||||
func WriteCSS(w io.Writer, value TrustedCSS) error
|
||||
func WriteHTML(w io.Writer, value TrustedHTML) error
|
||||
func WriteJS(w io.Writer, value TrustedJS) error
|
||||
func WriteRCDATA(w io.Writer, value any) error
|
||||
func WriteString(w io.Writer, value string) error
|
||||
func WriteText(w io.Writer, value any) error
|
||||
func WriteURL(w io.Writer, value any) error
|
||||
type Component interface{Render(context.Context, io.Writer) error}
|
||||
type ComponentFunc func(context.Context, io.Writer) error
|
||||
type TrustedCSS struct{ /* opaque */ }
|
||||
type TrustedHTML struct{ /* opaque */ }
|
||||
type TrustedJS struct{ /* opaque */ }
|
||||
type TrustedURL struct{ /* opaque */ }
|
||||
type URLSafetyError struct{Scheme string; Reason string}
|
||||
var ErrNilComponent error
|
||||
var ErrNilContext error
|
||||
var ErrNilWriter error
|
||||
var ErrUnsafeURL error
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+258
@@ -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"
|
||||
Executable
+71
@@ -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"
|
||||
Executable
+8
@@ -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" "$@"
|
||||
Executable
+81
@@ -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'
|
||||
Executable
+121
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
+104
-39
@@ -8,20 +8,24 @@ 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
|
||||
@@ -30,6 +34,11 @@ while (( $# > 0 )); do
|
||||
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
|
||||
if [[ -z "$native_evidence_dir" || ! -d "$native_evidence_dir" ]]; then
|
||||
printf 'error: --public requires HIMESAN_NATIVE_EVIDENCE_DIR\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
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
|
||||
|
||||
Executable
+80
@@ -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"
|
||||
Executable
+278
@@ -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"
|
||||
Executable
+25
@@ -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'
|
||||
@@ -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
|
||||
|
||||
Executable
+35
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user